Merge branch 'main' into relative/user-data-dir

Signed-off-by: Max Schmitt <max@schmitt.mx>
This commit is contained in:
Max Schmitt 2025-02-11 15:43:12 +01:00 committed by GitHub
commit f44451e2b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 1724 additions and 1026 deletions

315
package-lock.json generated
View file

@ -50,7 +50,7 @@
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"electron": "^30.1.2",
"esbuild": "^0.18.11",
"esbuild": "^0.25.0",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
@ -885,355 +885,411 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
"integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
"integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
"integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
"integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
"integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
"integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
"integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
"integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
"integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
"integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
"integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
"integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
"integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
"integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
"integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
"integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
"integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
"integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
"integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
"integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
"integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
"integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
"integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
"integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
"integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
"integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
"integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
"integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
"integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
"integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
"integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
"integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
"integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
"integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
"cpu": [
"x64"
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
"integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
"integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
"integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
"integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
"integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
"integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
"integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
"integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
"integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
"integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
"integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
"integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
@ -3857,40 +3913,61 @@
"optional": true
},
"node_modules/esbuild": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
"integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.18.20",
"@esbuild/android-arm64": "0.18.20",
"@esbuild/android-x64": "0.18.20",
"@esbuild/darwin-arm64": "0.18.20",
"@esbuild/darwin-x64": "0.18.20",
"@esbuild/freebsd-arm64": "0.18.20",
"@esbuild/freebsd-x64": "0.18.20",
"@esbuild/linux-arm": "0.18.20",
"@esbuild/linux-arm64": "0.18.20",
"@esbuild/linux-ia32": "0.18.20",
"@esbuild/linux-loong64": "0.18.20",
"@esbuild/linux-mips64el": "0.18.20",
"@esbuild/linux-ppc64": "0.18.20",
"@esbuild/linux-riscv64": "0.18.20",
"@esbuild/linux-s390x": "0.18.20",
"@esbuild/linux-x64": "0.18.20",
"@esbuild/netbsd-x64": "0.18.20",
"@esbuild/openbsd-x64": "0.18.20",
"@esbuild/sunos-x64": "0.18.20",
"@esbuild/win32-arm64": "0.18.20",
"@esbuild/win32-ia32": "0.18.20",
"@esbuild/win32-x64": "0.18.20"
"@esbuild/aix-ppc64": "0.25.0",
"@esbuild/android-arm": "0.25.0",
"@esbuild/android-arm64": "0.25.0",
"@esbuild/android-x64": "0.25.0",
"@esbuild/darwin-arm64": "0.25.0",
"@esbuild/darwin-x64": "0.25.0",
"@esbuild/freebsd-arm64": "0.25.0",
"@esbuild/freebsd-x64": "0.25.0",
"@esbuild/linux-arm": "0.25.0",
"@esbuild/linux-arm64": "0.25.0",
"@esbuild/linux-ia32": "0.25.0",
"@esbuild/linux-loong64": "0.25.0",
"@esbuild/linux-mips64el": "0.25.0",
"@esbuild/linux-ppc64": "0.25.0",
"@esbuild/linux-riscv64": "0.25.0",
"@esbuild/linux-s390x": "0.25.0",
"@esbuild/linux-x64": "0.25.0",
"@esbuild/netbsd-arm64": "0.25.0",
"@esbuild/netbsd-x64": "0.25.0",
"@esbuild/openbsd-arm64": "0.25.0",
"@esbuild/openbsd-x64": "0.25.0",
"@esbuild/sunos-x64": "0.25.0",
"@esbuild/win32-arm64": "0.25.0",
"@esbuild/win32-ia32": "0.25.0",
"@esbuild/win32-x64": "0.25.0"
}
},
"node_modules/esbuild/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/escalade": {

View file

@ -89,7 +89,7 @@
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"electron": "^30.1.2",
"esbuild": "^0.18.11",
"esbuild": "^0.25.0",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",

View file

@ -39,7 +39,7 @@ export const CopyToClipboard: React.FunctionComponent<CopyToClipboardProps> = ({
});
}, [value]);
const iconElement = icon === 'check' ? icons.check() : icon === 'cross' ? icons.cross() : icons.copy();
return <button className='copy-icon' aria-label='Copy to clipboard' onClick={handleCopy}>{iconElement}</button>;
return <button className='copy-icon' title='Copy to clipboard' aria-label='Copy to clipboard' onClick={handleCopy}>{iconElement}</button>;
};
type CopyToClipboardContainerProps = CopyToClipboardProps & {

View file

@ -18,6 +18,7 @@
cursor: pointer;
user-select: none;
margin-left: 5px;
color: var(--color-fg-default);
}
.metadata-view {
@ -26,16 +27,46 @@
margin-top: 8px;
}
.metadata-view .metadata-section {
margin: 8px 10px 8px 32px;
}
.metadata-view span:not(.copy-button-container),
.metadata-view a {
display: inline-block;
line-height: 24px;
}
.metadata-section {
align-items: center;
}
.metadata-properties {
display: flex;
flex-direction: column;
align-items: normal;
gap: 8px;
}
.metadata-properties > div {
height: 24px;
}
.metadata-separator {
height: 1px;
border-bottom: 1px solid var(--color-border-default);
}
.metadata-view .copy-value-container {
margin-top: -2px;
}
.git-commit-info a {
color: var(--color-fg-default);
font-weight: 600;
}
.copyable-property {
white-space: pre;
}
.copyable-property > span {
display: flex;
align-items: center;
}

View file

@ -26,9 +26,24 @@ import { linkifyText } from '@web/renderUtils';
type MetadataEntries = [string, unknown][];
export function filterMetadata(metadata: Metadata): MetadataEntries {
// TODO: do not plumb actualWorkers through metadata.
return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers');
export const MetadataContext = React.createContext<MetadataEntries>([]);
export function MetadataProvider({ metadata, children }: React.PropsWithChildren<{ metadata: Metadata }>) {
const entries = React.useMemo(() => {
// TODO: do not plumb actualWorkers through metadata.
return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers');
}, [metadata]);
return <MetadataContext.Provider value={entries}>{children}</MetadataContext.Provider>;
}
export function useMetadata() {
return React.useContext(MetadataContext);
}
export function useGitCommitInfo() {
const metadataEntries = useMetadata();
return metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined;
}
class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> {
@ -57,12 +72,13 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
}
}
export const MetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
return <ErrorBoundary><InnerMetadataView metadataEntries={metadataEntries}/></ErrorBoundary>;
export const MetadataView = () => {
return <ErrorBoundary><InnerMetadataView/></ErrorBoundary>;
};
const InnerMetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
const gitCommitInfo = metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined;
const InnerMetadataView = () => {
const metadataEntries = useMetadata();
const gitCommitInfo = useGitCommitInfo();
const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info');
if (!gitCommitInfo && !entries.length)
return null;
@ -71,30 +87,43 @@ const InnerMetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ met
<GitCommitInfoView info={gitCommitInfo}/>
{entries.length > 0 && <div className='metadata-separator' />}
</>}
{entries.map(([key, value]) => {
const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value);
const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
return <div className='m-1 ml-5' key={key}>
<span style={{ fontWeight: 'bold' }} title={key}>{key}</span>
{valueString && <CopyToClipboardContainer value={valueString}>: <span title={trimmedValue}>{linkifyText(trimmedValue)}</span></CopyToClipboardContainer>}
</div>;
})}
<div className='metadata-section metadata-properties'>
{entries.map(([propertyName, value]) => {
const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value);
const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
return (
<div key={propertyName} className='copyable-property'>
<CopyToClipboardContainer value={valueString}>
<span style={{ fontWeight: 'bold' }} title={propertyName}>{propertyName}</span>
: <span title={trimmedValue}>{linkifyText(trimmedValue)}</span>
</CopyToClipboardContainer>
</div>
);
})}
</div>
</div>;
};
const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
const email = info['revision.email'] ? ` <${info['revision.email']}>` : '';
const author = `${info['revision.author'] || ''}${email}`;
const subject = info['revision.subject'] || '';
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info['revision.timestamp']);
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info['revision.timestamp']);
return <div className='hbox pl-4 pr-2 git-commit-info' style={{ alignItems: 'center' }}>
<div className='vbox'>
<a className='m-2' href={info['revision.link']} target='_blank' rel='noopener noreferrer'>
<span title={info['revision.subject'] || ''}>{info['revision.subject'] || ''}</span>
</a>
<div className='hbox m-2 mt-1'>
<div className='mr-1'>{author}</div>
<div title={longTimestamp}> on {shortTimestamp}</div>
return <div className='hbox git-commit-info metadata-section'>
<div className='vbox metadata-properties'>
<div>
{info['revision.link'] ? (
<a href={info['revision.link']} target='_blank' rel='noopener noreferrer' title={subject}>
{subject}
</a>
) : <span title={subject}>
{subject}
</span>}
</div>
<div className='hbox'>
<span className='mr-1'>{author}</span>
<span title={longTimestamp}> on {shortTimestamp}</span>
{info['ci.link'] && (
<>
<span className='mx-2'>·</span>
@ -109,9 +138,10 @@ const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
)}
</div>
</div>
{!!info['revision.link'] && <a href={info['revision.link']} target='_blank' rel='noopener noreferrer'>
<span title='View commit details'>{info['revision.id']?.slice(0, 7) || 'unknown'}</span>
</a>}
{!info['revision.link'] && !!info['revision.id'] && <span>{info['revision.id'].slice(0, 7)}</span>}
{!!info['revision.link'] ? (
<a href={info['revision.link']} target='_blank' rel='noopener noreferrer' title='View commit details'>
{info['revision.id']?.slice(0, 7) || 'unknown'}
</a>
) : !!info['revision.id'] && <span>{info['revision.id'].slice(0, 7)}</span>}
</div>;
};

View file

@ -26,6 +26,7 @@ import './reportView.css';
import { TestCaseView } from './testCaseView';
import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css';
import { MetadataProvider } from './metadataView';
declare global {
interface Window {
@ -72,7 +73,7 @@ export const ReportView: React.FC<{
return result;
}, [report, filter]);
return <div className='htmlreport vbox px-4 pb-4'>
return <MetadataProvider metadata={report?.json().metadata ?? {}}><div className='htmlreport vbox px-4 pb-4'>
<main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
<Route predicate={testFilesRoutePredicate}>
@ -88,7 +89,7 @@ export const ReportView: React.FC<{
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
</Route>
</main>
</div>;
</div></MetadataProvider>;
};
const TestCaseViewLoader: React.FC<{

View file

@ -16,18 +16,47 @@
@import '@web/third_party/vscode/colors.css';
.test-error-view {
.test-error-container {
white-space: pre;
overflow: auto;
flex: none;
padding: 0;
background-color: var(--color-canvas-subtle);
border-radius: 6px;
padding: 16px;
line-height: initial;
margin-bottom: 6px;
}
.test-error-view {
padding: 16px;
}
.test-error-text {
font-family: monospace;
}
.prompt-button {
flex: none;
height: 24px;
width: 80px;
border: 1px solid var(--color-btn-border);
outline: none;
color: var(--color-btn-text);
background: var(--color-btn-bg);
padding: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.prompt-button svg {
color: var(--color-fg-subtle);
}
.prompt-button:not(:disabled):hover {
border-color: var(--color-btn-hover-border);
background-color: var(--color-btn-hover-bg);
}

View file

@ -17,15 +17,57 @@
import { ansi2html } from '@web/ansi2html';
import * as React from 'react';
import './testErrorView.css';
import * as icons from './icons';
import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView';
import type { TestResult } from './types';
import { fixTestPrompt } from '@web/components/prompts';
import { useGitCommitInfo } from './metadataView';
export const TestErrorView: React.FC<{
export const TestErrorView: React.FC<{ error: string; testId?: string; result?: TestResult }> = ({ error, testId, result }) => {
return (
<CodeSnippet code={error} testId={testId}>
<div style={{ float: 'right', padding: '5px' }}>
<PromptButton error={error} result={result} />
</div>
</CodeSnippet>
);
};
export const CodeSnippet = ({ code, children, testId }: React.PropsWithChildren<{ code: string; testId?: string; }>) => {
const html = React.useMemo(() => ansiErrorToHtml(code), [code]);
return (
<div className='test-error-container test-error-text' data-testid={testId}>
{children}
<div className='test-error-view' dangerouslySetInnerHTML={{ __html: html || '' }}></div>
</div>
);
};
const PromptButton: React.FC<{
error: string;
testId?: string;
}> = ({ error, testId }) => {
const html = React.useMemo(() => ansiErrorToHtml(error), [error]);
return <div className='test-error-view test-error-text' data-testid={testId} dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
result?: TestResult;
}> = ({ error, result }) => {
const gitCommitInfo = useGitCommitInfo();
const prompt = React.useMemo(() => fixTestPrompt(
error,
gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'],
result?.attachments.find(a => a.name === 'pageSnapshot')?.body
), [gitCommitInfo, result, error]);
const [copied, setCopied] = React.useState(false);
return <button
className='prompt-button'
onClick={async () => {
await navigator.clipboard.writeText(prompt);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
}}>
{copied ? <span className='prompt-button-copied'>Copied <icons.copy/></span> : 'Fix with AI'}
</button>;
};
export const TestScreenshotErrorView: React.FC<{

View file

@ -69,4 +69,11 @@
.test-file-test-status-icon {
flex: none;
}
.test-file-header-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-fg-subtle);
}

View file

@ -22,7 +22,7 @@ import { msToString } from './utils';
import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView';
import * as icons from './icons';
import { filterMetadata, MetadataView } from './metadataView';
import { MetadataView, useMetadata } from './metadataView';
export const TestFilesView: React.FC<{
tests: TestFileSummary[],
@ -67,21 +67,23 @@ export const TestFilesHeader: React.FC<{
metadataVisible: boolean,
toggleMetadataVisible: () => void,
}> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => {
const metadataEntries = useMetadata();
if (!report)
return;
const metadataEntries = filterMetadata(report.metadata || {});
return null;
return <>
<div className='mx-1' style={{ display: 'flex', marginTop: 10 }}>
{metadataEntries.length > 0 && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
</div>}
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {report.projectNames[0]}</div>}
{filteredStats && <div data-testid='filtered-tests-count' style={{ color: 'var(--color-fg-subtle)', padding: '0 10px' }}>Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}</div>}
<div className='test-file-header-info'>
{metadataEntries.length > 0 && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
</div>}
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name'>Project: {report.projectNames[0]}</div>}
{filteredStats && <div data-testid='filtered-tests-count'>Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}</div>}
</div>
<div style={{ flex: 'auto' }}></div>
<div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
<div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
</div>
{metadataVisible && <MetadataView metadataEntries={metadataEntries}/>}
{metadataVisible && <MetadataView/>}
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>}

View file

@ -24,7 +24,7 @@ import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './link
import { statusIcon } from './statusIcon';
import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView';
import { TestErrorView, TestScreenshotErrorView } from './testErrorView';
import { CodeSnippet, TestErrorView, TestScreenshotErrorView } from './testErrorView';
import * as icons from './icons';
import './testResultView.css';
@ -90,7 +90,7 @@ export const TestResultView: React.FC<{
{errors.map((error, index) => {
if (error.type === 'screenshot')
return <TestScreenshotErrorView key={'test-result-error-message-' + index} errorPrefix={error.errorPrefix} diff={error.diff!} errorSuffix={error.errorSuffix}></TestScreenshotErrorView>;
return <TestErrorView key={'test-result-error-message-' + index} error={error.error!}></TestErrorView>;
return <TestErrorView key={'test-result-error-message-' + index} error={error.error!} result={result}></TestErrorView>;
})}
</AutoChip>}
{!!result.steps.length && <AutoChip header='Test Steps'>
@ -182,7 +182,7 @@ const StepTreeItem: React.FC<{
{step.count > 1 && <> <span className='test-result-counter'>{step.count}</span></>}
{step.location && <span className='test-result-path'> {step.location.file}:{step.location.line}</span>}
</span>} loadChildren={step.steps.length || step.snippet ? () => {
const snippet = step.snippet ? [<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>] : [];
const snippet = step.snippet ? [<CodeSnippet testId='test-snippet' key='line' code={step.snippet} />] : [];
const steps = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
return snippet.concat(steps);
} : undefined} depth={depth}/>;

View file

@ -39,7 +39,7 @@
},
{
"name": "webkit",
"revision": "2130",
"revision": "2132",
"installByDefault": true,
"revisionOverrides": {
"debian11-x64": "2105",

View file

@ -7,7 +7,11 @@
[inProcessFactory.ts]
**
[inprocess.ts]
utils/
[outofprocess.ts]
client/
protocol/
utils/
common/

View file

@ -4,6 +4,7 @@
../common
../debug/injected
../generated/
../server/
../server/injected/
../server/trace
../utils

View file

@ -22,7 +22,7 @@ import * as playwright from '../..';
import { PipeTransport } from '../protocol/transport';
import { PlaywrightServer } from '../remote/playwrightServer';
import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher, createPlaywright } from '../server';
import { gracefullyProcessExitDoNotHang } from '../utils/processLauncher';
import { gracefullyProcessExitDoNotHang } from '../server/processLauncher';
import type { BrowserType } from '../client/browserType';
import type { LaunchServerOptions } from '../client/types';

View file

@ -21,11 +21,11 @@ import * as os from 'os';
import * as path from 'path';
import * as playwright from '../..';
import { registry, writeDockerVersion } from '../server';
import { launchBrowserServer, printApiJson, runDriver, runServer } from './driver';
import { isTargetClosedError } from '../client/errors';
import { gracefullyProcessExitDoNotHang, registry, writeDockerVersion } from '../server';
import { runTraceInBrowser, runTraceViewerApp } from '../server/trace/viewer/traceViewer';
import { assert, getPackageManagerExecCommand, gracefullyProcessExitDoNotHang, isLikelyNpxGlobal, wrapInASCIIBox } from '../utils';
import { assert, getPackageManagerExecCommand, isLikelyNpxGlobal, wrapInASCIIBox } from '../utils';
import { dotenv, program } from '../utilsBundle';
import type { Browser } from '../client/browser';

View file

@ -16,7 +16,8 @@
/* eslint-disable no-console */
import { getPackageManager, gracefullyProcessExitDoNotHang } from '../utils';
import { gracefullyProcessExitDoNotHang } from '../server';
import { getPackageManager } from '../utils';
import { program } from './program';
export { program } from './program';

View file

@ -15,22 +15,22 @@
*/
import { EventEmitter } from 'events';
import * as fs from 'fs';
import { isRegExp, isString, monotonicTime } from '../utils';
import { BrowserContext, prepareBrowserContextParams } from './browserContext';
import { ChannelOwner } from './channelOwner';
import { Connection } from './connection';
import { TargetClosedError, isTargetClosedError } from './errors';
import { Events } from './events';
import { Waiter } from './waiter';
import { TimeoutSettings } from '../common/timeoutSettings';
import { isRegExp, isString } from '../utils/rtti';
import { monotonicTime } from '../utils/time';
import { raceAgainstDeadline } from '../utils/timeoutRunner';
import type { Page } from './page';
import type * as types from './types';
import type * as api from '../../types/types';
import type { AndroidServerLauncherImpl } from '../androidServerImpl';
import type { Platform } from '../utils/platform';
import type * as channels from '@protocol/channels';
type Direction = 'down' | 'up' | 'left' | 'right';
@ -71,45 +71,28 @@ export class Android extends ChannelOwner<channels.AndroidChannel> implements ap
const headers = { 'x-playwright-browser': 'android', ...options.headers };
const localUtils = this._connection.localUtils();
const connectParams: channels.LocalUtilsConnectParams = { wsEndpoint, headers, slowMo: options.slowMo, timeout: options.timeout };
const { pipe } = await localUtils._channel.connect(connectParams);
const closePipe = () => pipe.close().catch(() => {});
const connection = new Connection(localUtils, this._instrumentation);
connection.markAsRemote();
connection.on('close', closePipe);
const connection = await localUtils.connect(connectParams);
let device: AndroidDevice;
let closeError: string | undefined;
const onPipeClosed = () => {
connection.on('close', () => {
device?._didClose();
connection.close(closeError);
};
pipe.on('closed', onPipeClosed);
connection.onmessage = message => pipe.send({ message }).catch(onPipeClosed);
pipe.on('message', ({ message }) => {
try {
connection!.dispatch(message);
} catch (e) {
closeError = String(e);
closePipe();
}
});
const result = await raceAgainstDeadline(async () => {
const playwright = await connection!.initializePlaywright();
if (!playwright._initializer.preConnectedAndroidDevice) {
closePipe();
connection.close();
throw new Error('Malformed endpoint. Did you use Android.launchServer method?');
}
device = AndroidDevice.from(playwright._initializer.preConnectedAndroidDevice!);
device._shouldCloseConnectionOnClose = true;
device.on(Events.AndroidDevice.Close, closePipe);
device.on(Events.AndroidDevice.Close, () => connection.close());
return device;
}, deadline);
if (!result.timedOut) {
return result.result;
} else {
closePipe();
connection.close();
throw new Error(`Timeout ${options.timeout}ms exceeded`);
}
});
@ -232,7 +215,7 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i
async screenshot(options: { path?: string } = {}): Promise<Buffer> {
const { binary } = await this._channel.screenshot();
if (options.path)
await fs.promises.writeFile(options.path, binary);
await this._platform.fs().promises.writeFile(options.path, binary);
return binary;
}
@ -267,15 +250,15 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel> i
}
async installApk(file: string | Buffer, options?: { args: string[] }): Promise<void> {
await this._channel.installApk({ file: await loadFile(file), args: options && options.args });
await this._channel.installApk({ file: await loadFile(this._platform, file), args: options && options.args });
}
async push(file: string | Buffer, path: string, options?: { mode: number }): Promise<void> {
await this._channel.push({ file: await loadFile(file), path, mode: options ? options.mode : undefined });
await this._channel.push({ file: await loadFile(this._platform, file), path, mode: options ? options.mode : undefined });
}
async launchBrowser(options: types.BrowserContextOptions & { pkg?: string } = {}): Promise<BrowserContext> {
const contextOptions = await prepareBrowserContextParams(options);
const contextOptions = await prepareBrowserContextParams(this._platform, options);
const result = await this._channel.launchBrowser(contextOptions);
const context = BrowserContext.from(result.context) as BrowserContext;
context._setOptions(contextOptions, {});
@ -321,9 +304,9 @@ export class AndroidSocket extends ChannelOwner<channels.AndroidSocketChannel> i
}
}
async function loadFile(file: string | Buffer): Promise<Buffer> {
async function loadFile(platform: Platform, file: string | Buffer): Promise<Buffer> {
if (isString(file))
return await fs.promises.readFile(file);
return await platform.fs().promises.readFile(file);
return file;
}

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
import * as fs from 'fs';
import { ChannelOwner } from './channelOwner';
import { Stream } from './stream';
import { mkdirIfNeeded } from '../utils/fileUtils';
@ -42,9 +40,9 @@ export class Artifact extends ChannelOwner<channels.ArtifactChannel> {
const result = await this._channel.saveAsStream();
const stream = Stream.from(result.stream);
await mkdirIfNeeded(path);
await mkdirIfNeeded(this._platform, path);
await new Promise((resolve, reject) => {
stream.stream().pipe(fs.createWriteStream(path))
stream.stream().pipe(this._platform.fs().createWriteStream(path))
.on('finish' as any, resolve)
.on('error' as any, reject);
});

View file

@ -14,19 +14,17 @@
* limitations under the License.
*/
import * as fs from 'fs';
import { Artifact } from './artifact';
import { BrowserContext, prepareBrowserContextParams } from './browserContext';
import { CDPSession } from './cdpSession';
import { ChannelOwner } from './channelOwner';
import { isTargetClosedError } from './errors';
import { Events } from './events';
import { mkdirIfNeeded } from '../utils';
import { mkdirIfNeeded } from '../utils/fileUtils';
import type { BrowserType } from './browserType';
import type { Page } from './page';
import type { BrowserContextOptions, HeadersArray, LaunchOptions } from './types';
import type { BrowserContextOptions, LaunchOptions } from './types';
import type * as api from '../../types/types';
import type * as channels from '@protocol/channels';
@ -39,9 +37,6 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
_options: LaunchOptions = {};
readonly _name: string;
private _path: string | undefined;
// Used from @playwright/test fixtures.
_connectHeaders?: HeadersArray;
_closeReason: string | undefined;
static from(browser: channels.BrowserChannel): Browser {
@ -83,7 +78,7 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise<BrowserContext> {
options = { ...this._browserType._playwright._defaultContextOptions, ...options };
const contextOptions = await prepareBrowserContextParams(options);
const contextOptions = await prepareBrowserContextParams(this._platform, options);
const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
const context = BrowserContext.from(response.context);
await this._browserType._didCreateContext(context, contextOptions, this._options, options.logger || this._logger);
@ -126,8 +121,8 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
const buffer = await artifact.readIntoBuffer();
await artifact.delete();
if (this._path) {
await mkdirIfNeeded(this._path);
await fs.promises.writeFile(this._path, buffer);
await mkdirIfNeeded(this._platform, this._path);
await this._platform.fs().promises.writeFile(this._path, buffer);
this._path = undefined;
}
return buffer;

View file

@ -15,9 +15,6 @@
* limitations under the License.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Artifact } from './artifact';
import { Browser } from './browser';
import { CDPSession } from './cdpSession';
@ -38,14 +35,18 @@ import { Waiter } from './waiter';
import { WebError } from './webError';
import { Worker } from './worker';
import { TimeoutSettings } from '../common/timeoutSettings';
import { headersObjectToArray, isRegExp, isString, mkdirIfNeeded, urlMatchesEqual } from '../utils';
import { mkdirIfNeeded } from '../utils/fileUtils';
import { headersObjectToArray } from '../utils/headers';
import { urlMatchesEqual } from '../utils/isomorphic/urlMatch';
import { isRegExp, isString } from '../utils/rtti';
import { rewriteErrorMessage } from '../utils/stackTrace';
import type { BrowserType } from './browserType';
import type { BrowserContextOptions, Headers, LaunchOptions, StorageState, WaitForEventOptions } from './types';
import type * as structs from '../../types/structs';
import type * as api from '../../types/types';
import type { URLMatch } from '../utils';
import type { URLMatch } from '../utils/isomorphic/urlMatch';
import type { Platform } from '../utils/platform';
import type * as channels from '@protocol/channels';
export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel> implements api.BrowserContext {
@ -107,7 +108,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
this.emit(Events.BrowserContext.ServiceWorker, serviceWorker);
});
this._channel.on('console', event => {
const consoleMessage = new ConsoleMessage(event);
const consoleMessage = new ConsoleMessage(this._platform, event);
this.emit(Events.BrowserContext.Console, consoleMessage);
const page = consoleMessage.page();
if (page)
@ -321,7 +322,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
}
async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): Promise<void> {
const source = await evaluationScript(script, arg);
const source = await evaluationScript(this._platform, script, arg);
await this._channel.addInitScript({ source });
}
@ -431,8 +432,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise<StorageState> {
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
if (options.path) {
await mkdirIfNeeded(options.path);
await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8');
await mkdirIfNeeded(this._platform, options.path);
await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8');
}
return state;
}
@ -484,7 +485,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
const needCompressed = harParams.path.endsWith('.zip');
if (isCompressed && !needCompressed) {
await artifact.saveAs(harParams.path + '.tmp');
await this._connection.localUtils()._channel.harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path });
await this._connection.localUtils().harUnzip({ zipFile: harParams.path + '.tmp', harFile: harParams.path });
} else {
await artifact.saveAs(harParams.path);
}
@ -500,11 +501,11 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
}
}
async function prepareStorageState(options: BrowserContextOptions): Promise<channels.BrowserNewContextParams['storageState']> {
async function prepareStorageState(platform: Platform, options: BrowserContextOptions): Promise<channels.BrowserNewContextParams['storageState']> {
if (typeof options.storageState !== 'string')
return options.storageState;
try {
return JSON.parse(await fs.promises.readFile(options.storageState, 'utf8'));
return JSON.parse(await platform.fs().promises.readFile(options.storageState, 'utf8'));
} catch (e) {
rewriteErrorMessage(e, `Error reading storage state from ${options.storageState}:\n` + e.message);
throw e;
@ -524,7 +525,7 @@ function prepareRecordHarOptions(options: BrowserContextOptions['recordHar']): c
};
}
export async function prepareBrowserContextParams(options: BrowserContextOptions): Promise<channels.BrowserNewContextParams> {
export async function prepareBrowserContextParams(platform: Platform, options: BrowserContextOptions): Promise<channels.BrowserNewContextParams> {
if (options.videoSize && !options.videosPath)
throw new Error(`"videoSize" option requires "videosPath" to be specified`);
if (options.extraHTTPHeaders)
@ -534,7 +535,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions
viewport: options.viewport === null ? undefined : options.viewport,
noDefaultViewport: options.viewport === null,
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
storageState: await prepareStorageState(options),
storageState: await prepareStorageState(platform, options),
serviceWorkers: options.serviceWorkers,
recordHar: prepareRecordHarOptions(options.recordHar),
colorScheme: options.colorScheme === null ? 'no-override' : options.colorScheme,
@ -542,7 +543,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions
forcedColors: options.forcedColors === null ? 'no-override' : options.forcedColors,
contrast: options.contrast === null ? 'no-override' : options.contrast,
acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads),
clientCertificates: await toClientCertificatesProtocol(options.clientCertificates),
clientCertificates: await toClientCertificatesProtocol(platform, options.clientCertificates),
};
if (!contextParams.recordVideo && options.videosPath) {
contextParams.recordVideo = {
@ -551,7 +552,7 @@ export async function prepareBrowserContextParams(options: BrowserContextOptions
};
}
if (contextParams.recordVideo && contextParams.recordVideo.dir)
contextParams.recordVideo.dir = path.resolve(process.cwd(), contextParams.recordVideo.dir);
contextParams.recordVideo.dir = platform.path().resolve(process.cwd(), contextParams.recordVideo.dir);
return contextParams;
}
@ -563,7 +564,7 @@ function toAcceptDownloadsProtocol(acceptDownloads?: boolean) {
return 'deny';
}
export async function toClientCertificatesProtocol(certs?: BrowserContextOptions['clientCertificates']): Promise<channels.PlaywrightNewRequestParams['clientCertificates']> {
export async function toClientCertificatesProtocol(platform: Platform, certs?: BrowserContextOptions['clientCertificates']): Promise<channels.PlaywrightNewRequestParams['clientCertificates']> {
if (!certs)
return undefined;
@ -571,7 +572,7 @@ export async function toClientCertificatesProtocol(certs?: BrowserContextOptions
if (value)
return value;
if (path)
return await fs.promises.readFile(path);
return await platform.fs().promises.readFile(path);
};
return await Promise.all(certs.map(async cert => ({

View file

@ -20,9 +20,10 @@ import { Browser } from './browser';
import { BrowserContext, prepareBrowserContextParams } from './browserContext';
import { ChannelOwner } from './channelOwner';
import { envObjectToArray } from './clientHelper';
import { Connection } from './connection';
import { Events } from './events';
import { assert, headersObjectToArray, monotonicTime } from '../utils';
import { assert } from '../utils/debug';
import { headersObjectToArray } from '../utils/headers';
import { monotonicTime } from '../utils/time';
import { raceAgainstDeadline } from '../utils/timeoutRunner';
import type { Playwright } from './playwright';
@ -92,7 +93,7 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
options = { ...this._playwright._defaultLaunchOptions, ...this._playwright._defaultContextOptions, ...options };
const contextParams = await prepareBrowserContextParams(options);
const contextParams = await prepareBrowserContextParams(this._platform, options);
const persistentParams: channels.BrowserTypeLaunchPersistentContextParams = {
...contextParams,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined,
@ -133,40 +134,16 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
};
if ((params as any).__testHookRedirectPortForwarding)
connectParams.socksProxyRedirectPortForTest = (params as any).__testHookRedirectPortForwarding;
const { pipe, headers: connectHeaders } = await localUtils._channel.connect(connectParams);
const closePipe = () => pipe.close().catch(() => {});
const connection = new Connection(localUtils, this._instrumentation);
connection.markAsRemote();
connection.on('close', closePipe);
const connection = await localUtils.connect(connectParams);
let browser: Browser;
let closeError: string | undefined;
const onPipeClosed = (reason?: string) => {
connection.on('close', () => {
// Emulate all pages, contexts and the browser closing upon disconnect.
for (const context of browser?.contexts() || []) {
for (const page of context.pages())
page._onClose();
context._onClose();
}
connection.close(reason || closeError);
// Give a chance to any API call promises to reject upon page/context closure.
// This happens naturally when we receive page.onClose and browser.onClose from the server
// in separate tasks. However, upon pipe closure we used to dispatch them all synchronously
// here and promises did not have a chance to reject.
// The order of rejects vs closure is a part of the API contract and our test runner
// relies on it to attribute rejections to the right test.
setTimeout(() => browser?._didClose(), 0);
};
pipe.on('closed', params => onPipeClosed(params.reason));
connection.onmessage = message => this._wrapApiCall(() => pipe.send({ message }).catch(() => onPipeClosed()), /* isInternal */ true);
pipe.on('message', ({ message }) => {
try {
connection!.dispatch(message);
} catch (e) {
closeError = String(e);
closePipe();
}
});
const result = await raceAgainstDeadline(async () => {
@ -176,21 +153,20 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
const playwright = await connection!.initializePlaywright();
if (!playwright._initializer.preLaunchedBrowser) {
closePipe();
connection.close();
throw new Error('Malformed endpoint. Did you use BrowserType.launchServer method?');
}
playwright._setSelectors(this._playwright.selectors);
browser = Browser.from(playwright._initializer.preLaunchedBrowser!);
this._didLaunchBrowser(browser, {}, logger);
browser._shouldCloseConnectionOnClose = true;
browser._connectHeaders = connectHeaders;
browser.on(Events.Browser.Disconnected, () => this._wrapApiCall(() => closePipe(), /* isInternal */ true));
browser.on(Events.Browser.Disconnected, () => connection.close());
return browser;
}, deadline);
if (!result.timedOut) {
return result.result;
} else {
closePipe();
connection.close();
throw new Error(`Timeout ${params.timeout}ms exceeded`);
}
});

View file

@ -16,7 +16,7 @@
import { EventEmitter } from './eventEmitter';
import { ValidationError, maybeFindValidator } from '../protocol/validator';
import { isUnderTest } from '../utils';
import { isUnderTest } from '../utils/debug';
import { debugLogger } from '../utils/debugLogger';
import { captureLibraryStackTrace, stringifyStackFrames } from '../utils/stackTrace';
import { zones } from '../utils/zones';
@ -25,6 +25,7 @@ import type { ClientInstrumentation } from './clientInstrumentation';
import type { Connection } from './connection';
import type { Logger } from './types';
import type { ValidatorContext } from '../protocol/validator';
import type { Platform } from '../utils/platform';
import type * as channels from '@protocol/channels';
type Listener = (...args: any[]) => void;
@ -39,6 +40,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
readonly _channel: T;
readonly _initializer: channels.InitializerTraits<T>;
_logger: Logger | undefined;
readonly _platform: Platform;
readonly _instrumentation: ClientInstrumentation;
private _eventToSubscriptionMapping: Map<string, string> = new Map();
private _isInternalType = false;
@ -52,6 +54,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
this._guid = guid;
this._parent = parent instanceof ChannelOwner ? parent : undefined;
this._instrumentation = this._connection._instrumentation;
this._platform = this._connection.platform;
this._connection._objects.set(guid, this);
if (this._parent) {

View file

@ -15,11 +15,10 @@
* limitations under the License.
*/
import * as fs from 'fs';
import { isString } from '../utils';
import { isString } from '../utils/rtti';
import type * as types from './types';
import type { Platform } from '../utils/platform';
export function envObjectToArray(env: types.Env): { name: string, value: string }[] {
const result: { name: string, value: string }[] = [];
@ -30,7 +29,7 @@ export function envObjectToArray(env: types.Env): { name: string, value: string
return result;
}
export async function evaluationScript(fun: Function | string | { path?: string, content?: string }, arg?: any, addSourceUrl: boolean = true): Promise<string> {
export async function evaluationScript(platform: Platform, fun: Function | string | { path?: string, content?: string }, arg?: any, addSourceUrl: boolean = true): Promise<string> {
if (typeof fun === 'function') {
const source = fun.toString();
const argString = Object.is(arg, undefined) ? 'undefined' : JSON.stringify(arg);
@ -43,7 +42,7 @@ export async function evaluationScript(fun: Function | string | { path?: string,
if (fun.content !== undefined)
return fun.content;
if (fun.path !== undefined) {
let source = await fs.promises.readFile(fun.path, 'utf8');
let source = await platform.fs().promises.readFile(fun.path, 'utf8');
if (addSourceUrl)
source = addSourceUrlToScript(source, fun.path);
return source;

View file

@ -42,11 +42,14 @@ import { Tracing } from './tracing';
import { Worker } from './worker';
import { WritableStream } from './writableStream';
import { ValidationError, findValidator } from '../protocol/validator';
import { formatCallLog, rewriteErrorMessage, zones } from '../utils';
import { debugLogger } from '../utils/debugLogger';
import { formatCallLog, rewriteErrorMessage } from '../utils/stackTrace';
import { zones } from '../utils/zones';
import type { ClientInstrumentation } from './clientInstrumentation';
import type { HeadersArray } from './types';
import type { ValidatorContext } from '../protocol/validator';
import type { Platform } from '../utils/platform';
import type * as channels from '@protocol/channels';
class Root extends ChannelOwner<channels.RootChannel> {
@ -78,12 +81,17 @@ export class Connection extends EventEmitter {
toImpl: ((client: ChannelOwner) => any) | undefined;
private _tracingCount = 0;
readonly _instrumentation: ClientInstrumentation;
readonly platform: Platform;
// Used from @playwright/test fixtures -> TODO remove?
readonly headers: HeadersArray;
constructor(localUtils: LocalUtils | undefined, instrumentation: ClientInstrumentation | undefined) {
constructor(localUtils: LocalUtils | undefined, platform: Platform, instrumentation: ClientInstrumentation | undefined, headers: HeadersArray) {
super();
this._instrumentation = instrumentation || createInstrumentation();
this._localUtils = localUtils;
this.platform = platform;
this._rootObject = new Root(this);
this.headers = headers;
}
markAsRemote() {
@ -138,7 +146,7 @@ export class Connection extends EventEmitter {
const location = frames[0] ? { file: frames[0].file, line: frames[0].line, column: frames[0].column } : undefined;
const metadata: channels.Metadata = { apiName, location, internal: !apiName, stepId };
if (this._tracingCount && frames && type !== 'LocalUtils')
this._localUtils?._channel.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
this._localUtils?.addStackToTracingNoReply({ callData: { stack: frames, id } }).catch(() => {});
// We need to exit zones before calling into the server, otherwise
// when we receive events from the server, we would be in an API zone.
zones.empty().run(() => this.onmessage({ ...message, metadata }));

View file

@ -14,12 +14,11 @@
* limitations under the License.
*/
import * as util from 'util';
import { JSHandle } from './jsHandle';
import { Page } from './page';
import type * as api from '../../types/types';
import type { Platform } from '../utils/platform';
import type * as channels from '@protocol/channels';
type ConsoleMessageLocation = channels.BrowserContextConsoleEvent['location'];
@ -29,9 +28,11 @@ export class ConsoleMessage implements api.ConsoleMessage {
private _page: Page | null;
private _event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent;
constructor(event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent) {
constructor(platform: Platform, event: channels.BrowserContextConsoleEvent | channels.ElectronApplicationConsoleEvent) {
this._page = ('page' in event && event.page) ? Page.from(event.page) : null;
this._event = event;
if (platform.inspectCustom)
(this as any)[platform.inspectCustom] = () => this._inspect();
}
page() {
@ -54,7 +55,7 @@ export class ConsoleMessage implements api.ConsoleMessage {
return this._event.location;
}
[util.inspect.custom]() {
private _inspect() {
return this.text();
}
}

View file

@ -53,7 +53,7 @@ export class Electron extends ChannelOwner<channels.ElectronChannel> implements
async launch(options: ElectronOptions = {}): Promise<ElectronApplication> {
const params: channels.ElectronLaunchParams = {
...await prepareBrowserContextParams(options),
...await prepareBrowserContextParams(this._platform, options),
env: envObjectToArray(options.env ? options.env : process.env),
tracesDir: options.tracesDir,
};
@ -81,7 +81,7 @@ export class ElectronApplication extends ChannelOwner<channels.ElectronApplicati
this._channel.on('close', () => {
this.emit(Events.ElectronApplication.Close);
});
this._channel.on('console', event => this.emit(Events.ElectronApplication.Console, new ConsoleMessage(event)));
this._channel.on('console', event => this.emit(Events.ElectronApplication.Console, new ConsoleMessage(this._platform, event)));
this._setEventToSubscriptionMapping(new Map<string, channels.ElectronApplicationUpdateSubscriptionParams['event']>([
[Events.ElectronApplication.Console, 'console'],
]));

View file

@ -14,15 +14,14 @@
* limitations under the License.
*/
import * as fs from 'fs';
import * as path from 'path';
import { pipeline } from 'stream';
import { promisify } from 'util';
import { Frame } from './frame';
import { JSHandle, parseResult, serializeArgument } from './jsHandle';
import { assert, isString } from '../utils';
import { assert } from '../utils/debug';
import { fileUploadSizeLimit, mkdirIfNeeded } from '../utils/fileUtils';
import { isString } from '../utils/rtti';
import { mime } from '../utilsBundle';
import { WritableStream } from './writableStream';
@ -32,6 +31,7 @@ import type { Locator } from './locator';
import type { FilePayload, Rect, SelectOption, SelectOptionOptions } from './types';
import type * as structs from '../../types/structs';
import type * as api from '../../types/types';
import type { Platform } from '../utils/platform';
import type * as channels from '@protocol/channels';
const pipelineAsync = promisify(pipeline);
@ -156,7 +156,7 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements
const frame = await this.ownerFrame();
if (!frame)
throw new Error('Cannot set input files to detached element');
const converted = await convertInputFiles(files, frame.page().context());
const converted = await convertInputFiles(this._platform, files, frame.page().context());
await this._elementChannel.setInputFiles({ ...converted, ...options });
}
@ -192,20 +192,21 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> implements
return value === undefined ? null : value;
}
async screenshot(options: Omit<channels.ElementHandleScreenshotOptions, 'mask'> & { path?: string, mask?: Locator[] } = {}): Promise<Buffer> {
async screenshot(options: Omit<channels.ElementHandleScreenshotOptions, 'mask'> & { path?: string, mask?: api.Locator[] } = {}): Promise<Buffer> {
const mask = options.mask as Locator[] | undefined;
const copy: channels.ElementHandleScreenshotOptions = { ...options, mask: undefined };
if (!copy.type)
copy.type = determineScreenshotType(options);
if (options.mask) {
copy.mask = options.mask.map(locator => ({
if (mask) {
copy.mask = mask.map(locator => ({
frame: locator._frame._channel,
selector: locator._selector,
}));
}
const result = await this._elementChannel.screenshot(copy);
if (options.path) {
await mkdirIfNeeded(options.path);
await fs.promises.writeFile(options.path, result.binary);
await mkdirIfNeeded(this._platform, options.path);
await this._platform.fs().promises.writeFile(options.path, result.binary);
}
return result.binary;
}
@ -263,18 +264,18 @@ function filePayloadExceedsSizeLimit(payloads: FilePayload[]) {
return payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) >= fileUploadSizeLimit;
}
async function resolvePathsAndDirectoryForInputFiles(items: string[]): Promise<[string[] | undefined, string | undefined]> {
async function resolvePathsAndDirectoryForInputFiles(platform: Platform, items: string[]): Promise<[string[] | undefined, string | undefined]> {
let localPaths: string[] | undefined;
let localDirectory: string | undefined;
for (const item of items) {
const stat = await fs.promises.stat(item as string);
const stat = await platform.fs().promises.stat(item as string);
if (stat.isDirectory()) {
if (localDirectory)
throw new Error('Multiple directories are not supported');
localDirectory = path.resolve(item as string);
localDirectory = platform.path().resolve(item as string);
} else {
localPaths ??= [];
localPaths.push(path.resolve(item as string));
localPaths.push(platform.path().resolve(item as string));
}
}
if (localPaths?.length && localDirectory)
@ -282,30 +283,30 @@ async function resolvePathsAndDirectoryForInputFiles(items: string[]): Promise<[
return [localPaths, localDirectory];
}
export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise<SetInputFilesFiles> {
export async function convertInputFiles(platform: Platform, files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise<SetInputFilesFiles> {
const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files];
if (items.some(item => typeof item === 'string')) {
if (!items.every(item => typeof item === 'string'))
throw new Error('File paths cannot be mixed with buffers');
const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(items);
const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(platform, items);
if (context._connection.isRemote()) {
const files = localDirectory ? (await fs.promises.readdir(localDirectory, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => path.join(f.path, f.name)) : localPaths!;
const files = localDirectory ? (await platform.fs().promises.readdir(localDirectory, { withFileTypes: true, recursive: true })).filter(f => f.isFile()).map(f => platform.path().join(f.path, f.name)) : localPaths!;
const { writableStreams, rootDir } = await context._wrapApiCall(async () => context._channel.createTempFiles({
rootDirName: localDirectory ? path.basename(localDirectory) : undefined,
rootDirName: localDirectory ? platform.path().basename(localDirectory) : undefined,
items: await Promise.all(files.map(async file => {
const lastModifiedMs = (await fs.promises.stat(file)).mtimeMs;
const lastModifiedMs = (await platform.fs().promises.stat(file)).mtimeMs;
return {
name: localDirectory ? path.relative(localDirectory, file) : path.basename(file),
name: localDirectory ? platform.path().relative(localDirectory, file) : platform.path().basename(file),
lastModifiedMs
};
})),
}), true);
for (let i = 0; i < files.length; i++) {
const writable = WritableStream.from(writableStreams[i]);
await pipelineAsync(fs.createReadStream(files[i]), writable.stream());
await pipelineAsync(platform.fs().createReadStream(files[i]), writable.stream());
}
return {
directoryStream: rootDir,

View file

@ -15,7 +15,7 @@
*/
import { parseSerializedValue, serializeValue } from '../protocol/serializers';
import { isError } from '../utils';
import { isError } from '../utils/rtti';
import type { SerializedError } from '@protocol/channels';

View file

@ -24,7 +24,7 @@
import { EventEmitter as OriginalEventEmitter } from 'events';
import { isUnderTest } from '../utils';
import { isUnderTest } from '../utils/debug';
import type { EventEmitter as EventEmitterType } from 'events';

View file

@ -14,24 +14,24 @@
* limitations under the License.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as util from 'util';
import { assert, headersObjectToArray, isString } from '../utils';
import { toClientCertificatesProtocol } from './browserContext';
import { ChannelOwner } from './channelOwner';
import { TargetClosedError, isTargetClosedError } from './errors';
import { RawHeaders } from './network';
import { Tracing } from './tracing';
import { assert } from '../utils/debug';
import { mkdirIfNeeded } from '../utils/fileUtils';
import { headersObjectToArray } from '../utils/headers';
import { isString } from '../utils/rtti';
import type { Playwright } from './playwright';
import type { ClientCertificate, FilePayload, Headers, SetStorageState, StorageState } from './types';
import type { Serializable } from '../../types/structs';
import type * as api from '../../types/types';
import type { HeadersArray, NameValue } from '../common/types';
import type { Platform } from '../utils/platform';
import type * as channels from '@protocol/channels';
import type * as fs from 'fs';
export type FetchOptions = {
params?: { [key: string]: string | number | boolean; } | URLSearchParams | string,
@ -70,14 +70,14 @@ export class APIRequest implements api.APIRequest {
...options,
};
const storageState = typeof options.storageState === 'string' ?
JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')) :
JSON.parse(await this._playwright._platform.fs().promises.readFile(options.storageState, 'utf8')) :
options.storageState;
const context = APIRequestContext.from((await this._playwright._channel.newRequest({
...options,
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
storageState,
tracesDir: this._playwright._defaultLaunchOptions?.tracesDir, // We do not expose tracesDir in the API, so do not allow options to accidentally override it.
clientCertificates: await toClientCertificatesProtocol(options.clientCertificates),
clientCertificates: await toClientCertificatesProtocol(this._playwright._platform, options.clientCertificates),
})).request);
this._contexts.add(context);
context._request = this;
@ -232,7 +232,7 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
} else {
// Convert file-like values to ServerFilePayload structs.
for (const [name, value] of Object.entries(options.multipart))
multipartData.push(await toFormField(name, value));
multipartData.push(await toFormField(this._platform, name, value));
}
}
if (postDataBuffer === undefined && jsonData === undefined && formData === undefined && multipartData === undefined)
@ -264,23 +264,24 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise<StorageState> {
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
if (options.path) {
await mkdirIfNeeded(options.path);
await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8');
await mkdirIfNeeded(this._platform, options.path);
await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8');
}
return state;
}
}
async function toFormField(name: string, value: string|number|boolean|fs.ReadStream|FilePayload): Promise<channels.FormField> {
async function toFormField(platform: Platform, name: string, value: string | number | boolean | fs.ReadStream | FilePayload): Promise<channels.FormField> {
const typeOfValue = typeof value;
if (isFilePayload(value)) {
const payload = value as FilePayload;
if (!Buffer.isBuffer(payload.buffer))
throw new Error(`Unexpected buffer type of 'data.${name}'`);
return { name, file: filePayloadToJson(payload) };
} else if (value instanceof fs.ReadStream) {
return { name, file: await readStreamToJson(value as fs.ReadStream) };
} else {
} else if (typeOfValue === 'string' || typeOfValue === 'number' || typeOfValue === 'boolean') {
return { name, value: String(value) };
} else {
return { name, file: await readStreamToJson(platform, value as fs.ReadStream) };
}
}
@ -307,6 +308,9 @@ export class APIResponse implements api.APIResponse {
this._request = context;
this._initializer = initializer;
this._headers = new RawHeaders(this._initializer.headers);
if (context._platform.inspectCustom)
(this as any)[context._platform.inspectCustom] = () => this._inspect();
}
ok(): boolean {
@ -364,7 +368,7 @@ export class APIResponse implements api.APIResponse {
await this._request._channel.disposeAPIResponse({ fetchUid: this._fetchUid() });
}
[util.inspect.custom]() {
private _inspect() {
const headers = this.headersArray().map(({ name, value }) => ` ${name}: ${value}`);
return `APIResponse: ${this.status()} ${this.statusText()}\n${headers.join('\n')}`;
}
@ -389,7 +393,7 @@ function filePayloadToJson(payload: FilePayload): ServerFilePayload {
};
}
async function readStreamToJson(stream: fs.ReadStream): Promise<ServerFilePayload> {
async function readStreamToJson(platform: Platform, stream: fs.ReadStream): Promise<ServerFilePayload> {
const buffer = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', chunk => chunks.push(chunk as Buffer));
@ -398,7 +402,7 @@ async function readStreamToJson(stream: fs.ReadStream): Promise<ServerFilePayloa
});
const streamPath: string = Buffer.isBuffer(stream.path) ? stream.path.toString('utf8') : stream.path;
return {
name: path.basename(streamPath),
name: platform.path().basename(streamPath),
buffer,
};
}

View file

@ -16,28 +16,27 @@
*/
import { EventEmitter } from 'events';
import * as fs from 'fs';
import { ChannelOwner } from './channelOwner';
import { FrameLocator, Locator, testIdAttributeName } from './locator';
import { assert } from '../utils';
import { urlMatches } from '../utils';
import { addSourceUrlToScript } from './clientHelper';
import { ElementHandle, convertInputFiles, convertSelectOptionValues } from './elementHandle';
import { Events } from './events';
import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle';
import { FrameLocator, Locator, testIdAttributeName } from './locator';
import * as network from './network';
import { kLifecycleEvents } from './types';
import { Waiter } from './waiter';
import { assert } from '../utils/debug';
import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../utils/isomorphic/locatorUtils';
import { urlMatches } from '../utils/isomorphic/urlMatch';
import type { LocatorOptions } from './locator';
import type { Page } from './page';
import type { FilePayload, LifecycleEvent, SelectOption, SelectOptionOptions, StrictOptions, WaitForFunctionOptions } from './types';
import type * as structs from '../../types/structs';
import type * as api from '../../types/types';
import type { URLMatch } from '../utils';
import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils';
import type { URLMatch } from '../utils/isomorphic/urlMatch';
import type * as channels from '@protocol/channels';
export type WaitForNavigationOptions = {
@ -269,7 +268,7 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
async addScriptTag(options: { url?: string, path?: string, content?: string, type?: string } = {}): Promise<ElementHandle> {
const copy = { ...options };
if (copy.path) {
copy.content = (await fs.promises.readFile(copy.path)).toString();
copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString();
copy.content = addSourceUrlToScript(copy.content, copy.path);
}
return ElementHandle.from((await this._channel.addScriptTag({ ...copy })).element);
@ -278,7 +277,7 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
async addStyleTag(options: { url?: string; path?: string; content?: string; } = {}): Promise<ElementHandle> {
const copy = { ...options };
if (copy.path) {
copy.content = (await fs.promises.readFile(copy.path)).toString();
copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString();
copy.content += '/*# sourceURL=' + copy.path.replace(/\n/g, '') + '*/';
}
return ElementHandle.from((await this._channel.addStyleTag({ ...copy })).element);
@ -403,7 +402,7 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
}
async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options: channels.FrameSetInputFilesOptions = {}): Promise<void> {
const converted = await convertInputFiles(files, this.page().context());
const converted = await convertInputFiles(this._platform, files, this.page().context());
await this._channel.setInputFiles({ selector, ...converted, ...options });
}

View file

@ -19,8 +19,8 @@ import { debugLogger } from '../utils/debugLogger';
import type { BrowserContext } from './browserContext';
import type { LocalUtils } from './localUtils';
import type { Route } from './network';
import type { URLMatch } from '../utils';
import type { Page } from './page';
import type { URLMatch } from '../utils/isomorphic/urlMatch';
type HarNotFoundAction = 'abort' | 'fallback';
@ -31,7 +31,7 @@ export class HarRouter {
private _options: { urlMatch?: URLMatch; baseURL?: string; };
static async create(localUtils: LocalUtils, file: string, notFoundAction: HarNotFoundAction, options: { urlMatch?: URLMatch }): Promise<HarRouter> {
const { harId, error } = await localUtils._channel.harOpen({ file });
const { harId, error } = await localUtils.harOpen({ file });
if (error)
throw new Error(error);
return new HarRouter(localUtils, harId!, notFoundAction, options);
@ -47,7 +47,7 @@ export class HarRouter {
private async _handle(route: Route) {
const request = route.request();
const response = await this._localUtils._channel.harLookup({
const response = await this._localUtils.harLookup({
harId: this._harId,
url: request.url(),
method: request.method(),
@ -103,6 +103,6 @@ export class HarRouter {
}
dispose() {
this._localUtils._channel.harClose({ harId: this._harId }).catch(() => {});
this._localUtils.harClose({ harId: this._harId }).catch(() => {});
}
}

View file

@ -15,8 +15,11 @@
*/
import { ChannelOwner } from './channelOwner';
import { Connection } from './connection';
import * as localUtils from '../utils/localUtils';
import type { Size } from './types';
import type { HarBackend } from '../utils/harBackend';
import type * as channels from '@protocol/channels';
type DeviceDescriptor = {
@ -31,6 +34,8 @@ type Devices = { [name: string]: DeviceDescriptor };
export class LocalUtils extends ChannelOwner<channels.LocalUtilsChannel> {
readonly devices: Devices;
private _harBackends = new Map<string, HarBackend>();
private _stackSessions = new Map<string, localUtils.StackSession>();
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.LocalUtilsInitializer) {
super(parent, type, guid, initializer);
@ -39,4 +44,61 @@ export class LocalUtils extends ChannelOwner<channels.LocalUtilsChannel> {
for (const { name, descriptor } of initializer.deviceDescriptors)
this.devices[name] = descriptor;
}
async zip(params: channels.LocalUtilsZipParams): Promise<void> {
return await localUtils.zip(this._platform, this._stackSessions, params);
}
async harOpen(params: channels.LocalUtilsHarOpenParams): Promise<channels.LocalUtilsHarOpenResult> {
return await localUtils.harOpen(this._harBackends, params);
}
async harLookup(params: channels.LocalUtilsHarLookupParams): Promise<channels.LocalUtilsHarLookupResult> {
return await localUtils.harLookup(this._harBackends, params);
}
async harClose(params: channels.LocalUtilsHarCloseParams): Promise<void> {
return await localUtils.harClose(this._harBackends, params);
}
async harUnzip(params: channels.LocalUtilsHarUnzipParams): Promise<void> {
return await localUtils.harUnzip(params);
}
async tracingStarted(params: channels.LocalUtilsTracingStartedParams): Promise<channels.LocalUtilsTracingStartedResult> {
return await localUtils.tracingStarted(this._stackSessions, params);
}
async traceDiscarded(params: channels.LocalUtilsTraceDiscardedParams): Promise<void> {
return await localUtils.traceDiscarded(this._platform, this._stackSessions, params);
}
async addStackToTracingNoReply(params: channels.LocalUtilsAddStackToTracingNoReplyParams): Promise<void> {
return await localUtils.addStackToTracingNoReply(this._stackSessions, params);
}
async connect(params: channels.LocalUtilsConnectParams): Promise<Connection> {
const { pipe, headers: connectHeaders } = await this._channel.connect(params);
const closePipe = () => this._wrapApiCall(() => pipe.close().catch(() => {}), /* isInternal */ true);
const connection = new Connection(this, this._platform, this._instrumentation, connectHeaders);
connection.markAsRemote();
connection.on('close', closePipe);
let closeError: string | undefined;
const onPipeClosed = (reason?: string) => {
connection.close(reason || closeError);
};
pipe.on('closed', params => onPipeClosed(params.reason));
connection.onmessage = message => this._wrapApiCall(() => pipe.send({ message }).catch(() => onPipeClosed()), /* isInternal */ true);
pipe.on('message', ({ message }) => {
try {
connection!.dispatch(message);
} catch (e) {
closeError = String(e);
closePipe();
}
});
return connection;
}
}

View file

@ -14,13 +14,13 @@
* limitations under the License.
*/
import * as util from 'util';
import { asLocator, isString, monotonicTime } from '../utils';
import { ElementHandle } from './elementHandle';
import { parseResult, serializeArgument } from './jsHandle';
import { asLocator } from '../utils/isomorphic/locatorGenerators';
import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '../utils/isomorphic/locatorUtils';
import { escapeForTextSelector } from '../utils/isomorphic/stringUtils';
import { isString } from '../utils/rtti';
import { monotonicTime } from '../utils/time';
import type { Frame } from './frame';
import type { FilePayload, FrameExpectParams, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types';
@ -64,6 +64,9 @@ export class Locator implements api.Locator {
throw new Error(`Inner "hasNot" locator must belong to the same frame.`);
this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector);
}
if (this._frame._platform.inspectCustom)
(this as any)[this._frame._platform.inspectCustom] = () => this._inspect();
}
private async _withElement<R>(task: (handle: ElementHandle<SVGElement | HTMLElement>, timeout?: number) => Promise<R>, timeout?: number): Promise<R> {
@ -291,8 +294,9 @@ export class Locator implements api.Locator {
return await this._frame.press(this._selector, key, { strict: true, ...options });
}
async screenshot(options: Omit<channels.ElementHandleScreenshotOptions, 'mask'> & { path?: string, mask?: Locator[] } = {}): Promise<Buffer> {
return await this._withElement((h, timeout) => h.screenshot({ ...options, timeout }), options.timeout);
async screenshot(options: Omit<channels.ElementHandleScreenshotOptions, 'mask'> & { path?: string, mask?: api.Locator[] } = {}): Promise<Buffer> {
const mask = options.mask as Locator[] | undefined;
return await this._withElement((h, timeout) => h.screenshot({ ...options, mask, timeout }), options.timeout);
}
async ariaSnapshot(options?: { _id?: boolean, _mode?: 'raw' | 'regex' } & TimeoutOptions): Promise<string> {
@ -370,7 +374,7 @@ export class Locator implements api.Locator {
return result;
}
[util.inspect.custom]() {
private _inspect() {
return this.toString();
}

View file

@ -14,7 +14,6 @@
* limitations under the License.
*/
import * as fs from 'fs';
import { URLSearchParams } from 'url';
import { ChannelOwner } from './channelOwner';
@ -22,19 +21,26 @@ import { isTargetClosedError } from './errors';
import { Events } from './events';
import { APIResponse } from './fetch';
import { Frame } from './frame';
import { Worker } from './worker';
import { MultiMap, assert, headersObjectToArray, isRegExp, isString, rewriteErrorMessage, urlMatches, zones } from '../utils';
import { Waiter } from './waiter';
import { Worker } from './worker';
import { assert } from '../utils/debug';
import { headersObjectToArray } from '../utils/headers';
import { urlMatches } from '../utils/isomorphic/urlMatch';
import { LongStandingScope, ManualPromise } from '../utils/manualPromise';
import { MultiMap } from '../utils/multimap';
import { isRegExp, isString } from '../utils/rtti';
import { rewriteErrorMessage } from '../utils/stackTrace';
import { zones } from '../utils/zones';
import { mime } from '../utilsBundle';
import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from './types';
import type { URLMatch, Zone } from '../utils';
import type { BrowserContext } from './browserContext';
import type { Page } from './page';
import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from './types';
import type { Serializable } from '../../types/structs';
import type * as api from '../../types/types';
import type { HeadersArray } from '../common/types';
import type { URLMatch } from '../utils/isomorphic/urlMatch';
import type { Zone } from '../utils/zones';
import type * as channels from '@protocol/channels';
export type NetworkCookie = {
@ -387,7 +393,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
let isBase64 = false;
let length = 0;
if (options.path) {
const buffer = await fs.promises.readFile(options.path);
const buffer = await this._platform.fs().promises.readFile(options.path);
body = buffer.toString('base64');
isBase64 = true;
length = buffer.length;

View file

@ -15,12 +15,6 @@
* limitations under the License.
*/
import * as fs from 'fs';
import * as path from 'path';
import { TargetClosedError, isTargetClosedError, serializeError } from './errors';
import { TimeoutSettings } from '../common/timeoutSettings';
import { LongStandingScope, assert, headersObjectToArray, isObject, isRegExp, isString, mkdirIfNeeded, trimStringWithEllipsis, urlMatches, urlMatchesEqual } from '../utils';
import { Accessibility } from './accessibility';
import { Artifact } from './artifact';
import { ChannelOwner } from './channelOwner';
@ -28,16 +22,25 @@ import { evaluationScript } from './clientHelper';
import { Coverage } from './coverage';
import { Download } from './download';
import { ElementHandle, determineScreenshotType } from './elementHandle';
import { TargetClosedError, isTargetClosedError, serializeError } from './errors';
import { Events } from './events';
import { FileChooser } from './fileChooser';
import { Frame, verifyLoadState } from './frame';
import { HarRouter } from './harRouter';
import { Keyboard, Mouse, Touchscreen } from './input';
import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle';
import { Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network';
import { Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network';
import { Video } from './video';
import { Waiter } from './waiter';
import { Worker } from './worker';
import { TimeoutSettings } from '../common/timeoutSettings';
import { assert } from '../utils/debug';
import { mkdirIfNeeded } from '../utils/fileUtils';
import { headersObjectToArray } from '../utils/headers';
import { trimStringWithEllipsis } from '../utils/isomorphic/stringUtils';
import { urlMatches, urlMatchesEqual } from '../utils/isomorphic/urlMatch';
import { LongStandingScope } from '../utils/manualPromise';
import { isObject, isRegExp, isString } from '../utils/rtti';
import type { BrowserContext } from './browserContext';
import type { Clock } from './clock';
@ -48,8 +51,8 @@ import type { Request, RouteHandlerCallback, WebSocketRouteHandlerCallback } fro
import type { FilePayload, Headers, LifecycleEvent, SelectOption, SelectOptionOptions, Size, WaitForEventOptions, WaitForFunctionOptions } from './types';
import type * as structs from '../../types/structs';
import type * as api from '../../types/types';
import type { URLMatch } from '../utils';
import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils';
import type { URLMatch } from '../utils/isomorphic/urlMatch';
import type * as channels from '@protocol/channels';
type PDFOptions = Omit<channels.PagePdfParams, 'width' | 'height' | 'margin'> & {
@ -512,7 +515,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
}
async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) {
const source = await evaluationScript(script, arg);
const source = await evaluationScript(this._platform, script, arg);
await this._channel.addInitScript({ source });
}
@ -590,8 +593,8 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
}
const result = await this._channel.screenshot(copy);
if (options.path) {
await mkdirIfNeeded(options.path);
await fs.promises.writeFile(options.path, result.binary);
await mkdirIfNeeded(this._platform, options.path);
await this._platform.fs().promises.writeFile(options.path, result.binary);
}
return result.binary;
}
@ -820,8 +823,9 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
}
const result = await this._channel.pdf(transportOptions);
if (options.path) {
await fs.promises.mkdir(path.dirname(options.path), { recursive: true });
await fs.promises.writeFile(options.path, result.pdf);
const platform = this._platform;
await platform.fs().promises.mkdir(platform.path().dirname(options.path), { recursive: true });
await platform.fs().promises.writeFile(options.path, result.pdf);
}
return result.pdf;
}

View file

@ -17,6 +17,7 @@
import { ChannelOwner } from './channelOwner';
import { evaluationScript } from './clientHelper';
import { setTestIdAttribute, testIdAttributeName } from './locator';
import { nodePlatform } from '../utils/platform';
import type { SelectorEngine } from './types';
import type * as api from '../../types/types';
@ -28,7 +29,7 @@ export class Selectors implements api.Selectors {
private _registrations: channels.SelectorsRegisterParams[] = [];
async register(name: string, script: string | (() => SelectorEngine) | { path?: string, content?: string }, options: { contentScript?: boolean } = {}): Promise<void> {
const source = await evaluationScript(script, undefined, false);
const source = await evaluationScript(nodePlatform, script, undefined, false);
const params = { ...options, name, source };
for (const channel of this._channels)
await channel._channel.register(params);

View file

@ -69,7 +69,7 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
this._isTracing = true;
this._connection.setIsTracing(true);
}
const result = await this._connection.localUtils()._channel.tracingStarted({ tracesDir: this._tracesDir, traceName });
const result = await this._connection.localUtils().tracingStarted({ tracesDir: this._tracesDir, traceName });
this._stacksId = result.stacksId;
}
@ -89,7 +89,7 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
// Not interested in artifacts.
await this._channel.tracingStopChunk({ mode: 'discard' });
if (this._stacksId)
await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId });
await this._connection.localUtils().traceDiscarded({ stacksId: this._stacksId });
return;
}
@ -97,7 +97,7 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
if (isLocal) {
const result = await this._channel.tracingStopChunk({ mode: 'entries' });
await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: result.entries!, mode: 'write', stacksId: this._stacksId, includeSources: this._includeSources });
await this._connection.localUtils().zip({ zipFile: filePath, entries: result.entries!, mode: 'write', stacksId: this._stacksId, includeSources: this._includeSources });
return;
}
@ -106,7 +106,7 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
// The artifact may be missing if the browser closed while stopping tracing.
if (!result.artifact) {
if (this._stacksId)
await this._connection.localUtils()._channel.traceDiscarded({ stacksId: this._stacksId });
await this._connection.localUtils().traceDiscarded({ stacksId: this._stacksId });
return;
}
@ -115,7 +115,7 @@ export class Tracing extends ChannelOwner<channels.TracingChannel> implements ap
await artifact.saveAs(filePath);
await artifact.delete();
await this._connection.localUtils()._channel.zip({ zipFile: filePath, entries: [], mode: 'append', stacksId: this._stacksId, includeSources: this._includeSources });
await this._connection.localUtils().zip({ zipFile: filePath, entries: [], mode: 'append', stacksId: this._stacksId, includeSources: this._includeSources });
}
_resetStackCounter() {

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ManualPromise } from '../utils';
import { ManualPromise } from '../utils/manualPromise';
import type { Artifact } from './artifact';
import type { Connection } from './connection';

View file

@ -15,11 +15,12 @@
*/
import { TimeoutError } from './errors';
import { createGuid, zones } from '../utils';
import { createGuid } from '../utils/crypto';
import { rewriteErrorMessage } from '../utils/stackTrace';
import { zones } from '../utils/zones';
import type { Zone } from '../utils';
import type { ChannelOwner } from './channelOwner';
import type { Zone } from '../utils/zones';
import type * as channels from '@protocol/channels';
import type { EventEmitter } from 'events';

View file

@ -15,10 +15,10 @@
*/
import { ChannelOwner } from './channelOwner';
import { TargetClosedError } from './errors';
import { Events } from './events';
import { JSHandle, assertMaxArguments, parseResult, serializeArgument } from './jsHandle';
import { LongStandingScope } from '../utils';
import { TargetClosedError } from './errors';
import { LongStandingScope } from '../utils/manualPromise';
import type { BrowserContext } from './browserContext';
import type { Page } from './page';

View file

@ -1,3 +1,4 @@
[*]
../utils/
../utilsBundle.ts
../utilsBundle.ts
../zipBundle.ts

View file

@ -0,0 +1,23 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface Progress {
log(message: string): void;
timeUntilDeadline(): number;
isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void;
throwIfAborted(): void;
}

View file

@ -21,11 +21,12 @@ import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher, createPlayw
import type { Playwright as PlaywrightAPI } from './client/playwright';
import type { Language } from './utils';
import type { Platform } from './utils/platform';
export function createInProcessPlaywright(): PlaywrightAPI {
export function createInProcessPlaywright(platform: Platform): PlaywrightAPI {
const playwright = createPlaywright({ sdkLanguage: (process.env.PW_LANG_NAME as Language | undefined) || 'javascript' });
const clientConnection = new Connection(undefined, undefined);
const clientConnection = new Connection(undefined, platform, undefined, []);
clientConnection.useRawBuffers();
const dispatcherConnection = new DispatcherConnection(true /* local */);

View file

@ -15,5 +15,6 @@
*/
import { createInProcessPlaywright } from './inProcessFactory';
import { nodePlatform } from './utils/platform';
module.exports = createInProcessPlaywright();
module.exports = createInProcessPlaywright(nodePlatform);

View file

@ -20,10 +20,10 @@ import * as path from 'path';
import { Connection } from './client/connection';
import { PipeTransport } from './protocol/transport';
import { ManualPromise } from './utils/manualPromise';
import { nodePlatform } from './utils/platform';
import type { Playwright } from './client/playwright';
export async function start(env: any = {}): Promise<{ playwright: Playwright, stop: () => Promise<void> }> {
const client = new PlaywrightClient(env);
const playwright = await client._playwright;
@ -48,7 +48,7 @@ class PlaywrightClient {
this._driverProcess.unref();
this._driverProcess.stderr!.on('data', data => process.stderr.write(data));
const connection = new Connection(undefined, undefined);
const connection = new Connection(undefined, nodePlatform, undefined, []);
const transport = new PipeTransport(this._driverProcess.stdin!, this._driverProcess.stdout!);
connection.onmessage = message => transport.send(JSON.stringify(message));
transport.onmessage = message => connection.dispatch(JSON.parse(message));

View file

@ -23,15 +23,15 @@ import { TimeoutSettings } from '../../common/timeoutSettings';
import { PipeTransport } from '../../protocol/transport';
import { createGuid, getPackageManagerExecCommand, isUnderTest, makeWaitForNextTask } from '../../utils';
import { RecentLogsCollector } from '../../utils/debugLogger';
import { removeFolders } from '../../utils/fileUtils';
import { gracefullyCloseSet } from '../../utils/processLauncher';
import { debug } from '../../utilsBundle';
import { wsReceiver, wsSender } from '../../utilsBundle';
import { validateBrowserContextOptions } from '../browserContext';
import { chromiumSwitches } from '../chromium/chromiumSwitches';
import { CRBrowser } from '../chromium/crBrowser';
import { removeFolders } from '../fileUtils';
import { helper } from '../helper';
import { SdkObject, serverSideCallMetadata } from '../instrumentation';
import { gracefullyCloseSet } from '../processLauncher';
import { ProgressController } from '../progress';
import { registry } from '../registry';

View file

@ -22,9 +22,9 @@ import { BidiBrowser } from './bidiBrowser';
import { kBrowserCloseMessageId } from './bidiConnection';
import { chromiumSwitches } from '../chromium/chromiumSwitches';
import type { Env } from '../../utils/processLauncher';
import type { BrowserOptions } from '../browser';
import type { SdkObject } from '../instrumentation';
import type { Env } from '../processLauncher';
import type { ProtocolError } from '../protocolError';
import type { ConnectionTransport } from '../transport';
import type * as types from '../types';

View file

@ -23,9 +23,9 @@ import { BidiBrowser } from './bidiBrowser';
import { kBrowserCloseMessageId } from './bidiConnection';
import { createProfile } from './third_party/firefoxPrefs';
import type { Env } from '../../utils/processLauncher';
import type { BrowserOptions } from '../browser';
import type { SdkObject } from '../instrumentation';
import type { Env } from '../processLauncher';
import type { ProtocolError } from '../protocolError';
import type { ConnectionTransport } from '../transport';
import type * as types from '../types';

View file

@ -23,6 +23,7 @@ import { createGuid, debugMode } from '../utils';
import { Clock } from './clock';
import { Debugger } from './debugger';
import { BrowserContextAPIRequestContext } from './fetch';
import { mkdirIfNeeded } from './fileUtils';
import { HarRecorder } from './har/harRecorder';
import { helper } from './helper';
import { SdkObject, serverSideCallMetadata } from './instrumentation';
@ -31,9 +32,8 @@ import * as network from './network';
import { InitScript } from './page';
import { Page, PageBinding } from './page';
import { Recorder } from './recorder';
import * as storageScript from './storageScript';
import { mkdirIfNeeded } from '../utils/fileUtils';
import { RecorderApp } from './recorder/recorderApp';
import * as storageScript from './storageScript';
import * as consoleApiSource from '../generated/consoleApiSource';
import { Tracing } from './trace/recorder/tracing';

View file

@ -21,27 +21,27 @@ import * as path from 'path';
import { normalizeProxySettings, validateBrowserContextOptions } from './browserContext';
import { DEFAULT_TIMEOUT, TimeoutSettings } from '../common/timeoutSettings';
import { ManualPromise, assert, debugMode } from '../utils';
import { existsAsync } from './fileUtils';
import { helper } from './helper';
import { SdkObject } from './instrumentation';
import { PipeTransport } from './pipeTransport';
import { envArrayToObject, launchProcess } from './processLauncher';
import { ProgressController } from './progress';
import { isProtocolError } from './protocolError';
import { registry } from './registry';
import { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
import { WebSocketTransport } from './transport';
import { RecentLogsCollector } from '../utils/debugLogger';
import { existsAsync } from '../utils/fileUtils';
import { envArrayToObject, launchProcess } from '../utils/processLauncher';
import type { Browser, BrowserOptions, BrowserProcess } from './browser';
import type { BrowserContext } from './browserContext';
import type { CallMetadata } from './instrumentation';
import type { Env } from './processLauncher';
import type { Progress } from './progress';
import type { ProtocolError } from './protocolError';
import type { BrowserName } from './registry';
import type { ConnectionTransport } from './transport';
import type * as types from './types';
import type { Env } from '../utils/processLauncher';
import type * as channels from '@protocol/channels';
export const kNoXServerRunningError = 'Looks like you launched a headed browser without having a XServer running.\n' +

View file

@ -26,10 +26,8 @@ import { TimeoutSettings } from '../../common/timeoutSettings';
import { debugMode, headersArrayToObject, headersObjectToArray, } from '../../utils';
import { wrapInASCIIBox } from '../../utils/ascii';
import { RecentLogsCollector } from '../../utils/debugLogger';
import { removeFolders } from '../../utils/fileUtils';
import { ManualPromise } from '../../utils/manualPromise';
import { fetchData } from '../../utils/network';
import { gracefullyCloseSet } from '../../utils/processLauncher';
import { getUserAgent } from '../../utils/userAgent';
import { validateBrowserContextOptions } from '../browserContext';
import { BrowserType, kNoXServerRunningError } from '../browserType';
@ -39,12 +37,14 @@ import { registry } from '../registry';
import { WebSocketTransport } from '../transport';
import { CRDevTools } from './crDevTools';
import { Browser } from '../browser';
import { removeFolders } from '../fileUtils';
import { gracefullyCloseSet } from '../processLauncher';
import { ProgressController } from '../progress';
import type { HTTPRequestParams } from '../../utils/network';
import type { Env } from '../../utils/processLauncher';
import type { BrowserOptions, BrowserProcess } from '../browser';
import type { CallMetadata, SdkObject } from '../instrumentation';
import type { Env } from '../processLauncher';
import type { Progress } from '../progress';
import type { ProtocolError } from '../protocolError';
import type { ConnectionTransport, ProtocolRequest } from '../transport';

View file

@ -17,8 +17,8 @@
import * as fs from 'fs';
import { mkdirIfNeeded } from '../../utils/fileUtils';
import { splitErrorMessage } from '../../utils/stackTrace';
import { mkdirIfNeeded } from '../fileUtils';
import type { CRSession } from './crConnection';
import type { Protocol } from './protocol';

View file

@ -15,9 +15,9 @@
*/
import { assert, monotonicTime } from '../../utils';
import { launchProcess } from '../../utils/processLauncher';
import { serverSideCallMetadata } from '../instrumentation';
import { Page } from '../page';
import { launchProcess } from '../processLauncher';
import { ProgressController } from '../progress';
import type { Progress } from '../progress';

View file

@ -15,13 +15,13 @@
*/
import { SdkObject, createInstrumentation, serverSideCallMetadata } from './instrumentation';
import { gracefullyProcessExitDoNotHang } from './processLauncher';
import { Recorder } from './recorder';
import { asLocator } from '../utils';
import { parseAriaSnapshotUnsafe } from '../utils/isomorphic/ariaSnapshot';
import { yaml } from '../utilsBundle';
import { EmptyRecorderApp } from './recorder/recorderApp';
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
import { gracefullyProcessExitDoNotHang } from '../utils/processLauncher';
import type { Language } from '../utils';
import type { Browser } from './browser';

View file

@ -18,7 +18,7 @@ import * as fs from 'fs';
import { Dispatcher, existingDispatcher } from './dispatcher';
import { StreamDispatcher } from './streamDispatcher';
import { mkdirIfNeeded } from '../../utils/fileUtils';
import { mkdirIfNeeded } from '../fileUtils';
import type { DispatcherScope } from './dispatcher';
import type { Artifact } from '../artifact';

View file

@ -14,45 +14,27 @@
* limitations under the License.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Dispatcher } from './dispatcher';
import { SdkObject } from '../../server/instrumentation';
import { assert, calculateSha1, createGuid, removeFolders } from '../../utils';
import { serializeClientSideCallMetadata } from '../../utils';
import { ManualPromise } from '../../utils/manualPromise';
import { fetchData } from '../../utils/network';
import * as localUtils from '../../utils/localUtils';
import { nodePlatform } from '../../utils/platform';
import { getUserAgent } from '../../utils/userAgent';
import { ZipFile } from '../../utils/zipFile';
import { yauzl, yazl } from '../../zipBundle';
import { deviceDescriptors as descriptors } from '../deviceDescriptors';
import { JsonPipeDispatcher } from '../dispatchers/jsonPipeDispatcher';
import { ProgressController } from '../progress';
import { SocksInterceptor } from '../socksInterceptor';
import { WebSocketTransport } from '../transport';
import type { HTTPRequestParams } from '../../utils/network';
import type { HarBackend } from '../../utils/harBackend';
import type { CallMetadata } from '../instrumentation';
import type { Playwright } from '../playwright';
import type { Progress } from '../progress';
import type { HeadersArray } from '../types';
import type { RootDispatcher } from './dispatcher';
import type * as channels from '@protocol/channels';
import type * as har from '@trace/har';
import type EventEmitter from 'events';
import type http from 'http';
export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.LocalUtilsChannel, RootDispatcher> implements channels.LocalUtilsChannel {
_type_LocalUtils: boolean;
private _harBackends = new Map<string, HarBackend>();
private _stackSessions = new Map<string, {
file: string,
writer: Promise<void>,
tmpDir: string | undefined,
callStacks: channels.ClientSideCallMetadata[]
}>();
private _stackSessions = new Map<string, localUtils.StackSession>();
constructor(scope: RootDispatcher, playwright: Playwright) {
const localUtils = new SdkObject(playwright, 'localUtils', 'localUtils');
@ -65,139 +47,35 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
}
async zip(params: channels.LocalUtilsZipParams): Promise<void> {
const promise = new ManualPromise<void>();
const zipFile = new yazl.ZipFile();
(zipFile as any as EventEmitter).on('error', error => promise.reject(error));
const addFile = (file: string, name: string) => {
try {
if (fs.statSync(file).isFile())
zipFile.addFile(file, name);
} catch (e) {
}
};
for (const entry of params.entries)
addFile(entry.value, entry.name);
// Add stacks and the sources.
const stackSession = params.stacksId ? this._stackSessions.get(params.stacksId) : undefined;
if (stackSession?.callStacks.length) {
await stackSession.writer;
if (process.env.PW_LIVE_TRACE_STACKS) {
zipFile.addFile(stackSession.file, 'trace.stacks');
} else {
const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(stackSession.callStacks)));
zipFile.addBuffer(buffer, 'trace.stacks');
}
}
// Collect sources from stacks.
if (params.includeSources) {
const sourceFiles = new Set<string>();
for (const { stack } of stackSession?.callStacks || []) {
if (!stack)
continue;
for (const { file } of stack)
sourceFiles.add(file);
}
for (const sourceFile of sourceFiles)
addFile(sourceFile, 'resources/src@' + calculateSha1(sourceFile) + '.txt');
}
if (params.mode === 'write') {
// New file, just compress the entries.
await fs.promises.mkdir(path.dirname(params.zipFile), { recursive: true });
zipFile.end(undefined, () => {
zipFile.outputStream.pipe(fs.createWriteStream(params.zipFile))
.on('close', () => promise.resolve())
.on('error', error => promise.reject(error));
});
await promise;
await this._deleteStackSession(params.stacksId);
return;
}
// File already exists. Repack and add new entries.
const tempFile = params.zipFile + '.tmp';
await fs.promises.rename(params.zipFile, tempFile);
yauzl.open(tempFile, (err, inZipFile) => {
if (err) {
promise.reject(err);
return;
}
assert(inZipFile);
let pendingEntries = inZipFile.entryCount;
inZipFile.on('entry', entry => {
inZipFile.openReadStream(entry, (err, readStream) => {
if (err) {
promise.reject(err);
return;
}
zipFile.addReadStream(readStream!, entry.fileName);
if (--pendingEntries === 0) {
zipFile.end(undefined, () => {
zipFile.outputStream.pipe(fs.createWriteStream(params.zipFile)).on('close', () => {
fs.promises.unlink(tempFile).then(() => {
promise.resolve();
}).catch(error => promise.reject(error));
});
});
}
});
});
});
await promise;
await this._deleteStackSession(params.stacksId);
return await localUtils.zip(nodePlatform, this._stackSessions, params);
}
async harOpen(params: channels.LocalUtilsHarOpenParams, metadata: CallMetadata): Promise<channels.LocalUtilsHarOpenResult> {
let harBackend: HarBackend;
if (params.file.endsWith('.zip')) {
const zipFile = new ZipFile(params.file);
const entryNames = await zipFile.entries();
const harEntryName = entryNames.find(e => e.endsWith('.har'));
if (!harEntryName)
return { error: 'Specified archive does not have a .har file' };
const har = await zipFile.read(harEntryName);
const harFile = JSON.parse(har.toString()) as har.HARFile;
harBackend = new HarBackend(harFile, null, zipFile);
} else {
const harFile = JSON.parse(await fs.promises.readFile(params.file, 'utf-8')) as har.HARFile;
harBackend = new HarBackend(harFile, path.dirname(params.file), null);
}
this._harBackends.set(harBackend.id, harBackend);
return { harId: harBackend.id };
return await localUtils.harOpen(this._harBackends, params);
}
async harLookup(params: channels.LocalUtilsHarLookupParams, metadata: CallMetadata): Promise<channels.LocalUtilsHarLookupResult> {
const harBackend = this._harBackends.get(params.harId);
if (!harBackend)
return { action: 'error', message: `Internal error: har was not opened` };
return await harBackend.lookup(params.url, params.method, params.headers, params.postData, params.isNavigationRequest);
return await localUtils.harLookup(this._harBackends, params);
}
async harClose(params: channels.LocalUtilsHarCloseParams, metadata: CallMetadata): Promise<void> {
const harBackend = this._harBackends.get(params.harId);
if (harBackend) {
this._harBackends.delete(harBackend.id);
harBackend.dispose();
}
return await localUtils.harClose(this._harBackends, params);
}
async harUnzip(params: channels.LocalUtilsHarUnzipParams, metadata: CallMetadata): Promise<void> {
const dir = path.dirname(params.zipFile);
const zipFile = new ZipFile(params.zipFile);
for (const entry of await zipFile.entries()) {
const buffer = await zipFile.read(entry);
if (entry === 'har.har')
await fs.promises.writeFile(params.harFile, buffer);
else
await fs.promises.writeFile(path.join(dir, entry), buffer);
}
zipFile.close();
await fs.promises.unlink(params.zipFile);
return await localUtils.harUnzip(params);
}
async tracingStarted(params: channels.LocalUtilsTracingStartedParams, metadata?: CallMetadata | undefined): Promise<channels.LocalUtilsTracingStartedResult> {
return await localUtils.tracingStarted(this._stackSessions, params);
}
async traceDiscarded(params: channels.LocalUtilsTraceDiscardedParams, metadata?: CallMetadata | undefined): Promise<void> {
return await localUtils.traceDiscarded(nodePlatform, this._stackSessions, params);
}
async addStackToTracingNoReply(params: channels.LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata | undefined): Promise<void> {
return await localUtils.addStackToTracingNoReply(this._stackSessions, params);
}
async connect(params: channels.LocalUtilsConnectParams, metadata: CallMetadata): Promise<channels.LocalUtilsConnectResult> {
@ -209,7 +87,7 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
'x-playwright-proxy': params.exposeNetwork ?? '',
...params.headers,
};
const wsEndpoint = await urlToWSEndpoint(progress, params.wsEndpoint);
const wsEndpoint = await localUtils.urlToWSEndpoint(progress, params.wsEndpoint);
const transport = await WebSocketTransport.connect(progress, wsEndpoint, wsHeaders, true, 'x-playwright-debug-log');
const socksInterceptor = new SocksInterceptor(transport, params.exposeNetwork, params.socksProxyRedirectPortForTest);
@ -240,221 +118,4 @@ export class LocalUtilsDispatcher extends Dispatcher<{ guid: string }, channels.
return { pipe, headers: transport.headers };
}, params.timeout || 0);
}
async tracingStarted(params: channels.LocalUtilsTracingStartedParams, metadata?: CallMetadata | undefined): Promise<channels.LocalUtilsTracingStartedResult> {
let tmpDir = undefined;
if (!params.tracesDir)
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-tracing-'));
const traceStacksFile = path.join(params.tracesDir || tmpDir!, params.traceName + '.stacks');
this._stackSessions.set(traceStacksFile, { callStacks: [], file: traceStacksFile, writer: Promise.resolve(), tmpDir });
return { stacksId: traceStacksFile };
}
async traceDiscarded(params: channels.LocalUtilsTraceDiscardedParams, metadata?: CallMetadata | undefined): Promise<void> {
await this._deleteStackSession(params.stacksId);
}
async addStackToTracingNoReply(params: channels.LocalUtilsAddStackToTracingNoReplyParams, metadata?: CallMetadata | undefined): Promise<void> {
for (const session of this._stackSessions.values()) {
session.callStacks.push(params.callData);
if (process.env.PW_LIVE_TRACE_STACKS) {
session.writer = session.writer.then(() => {
const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(session.callStacks)));
return fs.promises.writeFile(session.file, buffer);
});
}
}
}
private async _deleteStackSession(stacksId?: string) {
const session = stacksId ? this._stackSessions.get(stacksId) : undefined;
if (!session)
return;
await session.writer;
if (session.tmpDir)
await removeFolders([session.tmpDir]);
this._stackSessions.delete(stacksId!);
}
}
const redirectStatus = [301, 302, 303, 307, 308];
class HarBackend {
readonly id = createGuid();
private _harFile: har.HARFile;
private _zipFile: ZipFile | null;
private _baseDir: string | null;
constructor(harFile: har.HARFile, baseDir: string | null, zipFile: ZipFile | null) {
this._harFile = harFile;
this._baseDir = baseDir;
this._zipFile = zipFile;
}
async lookup(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, isNavigationRequest: boolean): Promise<{
action: 'error' | 'redirect' | 'fulfill' | 'noentry',
message?: string,
redirectURL?: string,
status?: number,
headers?: HeadersArray,
body?: Buffer }> {
let entry;
try {
entry = await this._harFindResponse(url, method, headers, postData);
} catch (e) {
return { action: 'error', message: 'HAR error: ' + e.message };
}
if (!entry)
return { action: 'noentry' };
// If navigation is being redirected, restart it with the final url to ensure the document's url changes.
if (entry.request.url !== url && isNavigationRequest)
return { action: 'redirect', redirectURL: entry.request.url };
const response = entry.response;
try {
const buffer = await this._loadContent(response.content);
return {
action: 'fulfill',
status: response.status,
headers: response.headers,
body: buffer,
};
} catch (e) {
return { action: 'error', message: e.message };
}
}
private async _loadContent(content: { text?: string, encoding?: string, _file?: string }): Promise<Buffer> {
const file = content._file;
let buffer: Buffer;
if (file) {
if (this._zipFile)
buffer = await this._zipFile.read(file);
else
buffer = await fs.promises.readFile(path.resolve(this._baseDir!, file));
} else {
buffer = Buffer.from(content.text || '', content.encoding === 'base64' ? 'base64' : 'utf-8');
}
return buffer;
}
private async _harFindResponse(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined): Promise<har.Entry | undefined> {
const harLog = this._harFile.log;
const visited = new Set<har.Entry>();
while (true) {
const entries: har.Entry[] = [];
for (const candidate of harLog.entries) {
if (candidate.request.url !== url || candidate.request.method !== method)
continue;
if (method === 'POST' && postData && candidate.request.postData) {
const buffer = await this._loadContent(candidate.request.postData);
if (!buffer.equals(postData)) {
const boundary = multipartBoundary(headers);
if (!boundary)
continue;
const candidataBoundary = multipartBoundary(candidate.request.headers);
if (!candidataBoundary)
continue;
// Try to match multipart/form-data ignroing boundary as it changes between requests.
if (postData.toString().replaceAll(boundary, '') !== buffer.toString().replaceAll(candidataBoundary, ''))
continue;
}
}
entries.push(candidate);
}
if (!entries.length)
return;
let entry = entries[0];
// Disambiguate using headers - then one with most matching headers wins.
if (entries.length > 1) {
const list: { candidate: har.Entry, matchingHeaders: number }[] = [];
for (const candidate of entries) {
const matchingHeaders = countMatchingHeaders(candidate.request.headers, headers);
list.push({ candidate, matchingHeaders });
}
list.sort((a, b) => b.matchingHeaders - a.matchingHeaders);
entry = list[0].candidate;
}
if (visited.has(entry))
throw new Error(`Found redirect cycle for ${url}`);
visited.add(entry);
// Follow redirects.
const locationHeader = entry.response.headers.find(h => h.name.toLowerCase() === 'location');
if (redirectStatus.includes(entry.response.status) && locationHeader) {
const locationURL = new URL(locationHeader.value, url);
url = locationURL.toString();
if ((entry.response.status === 301 || entry.response.status === 302) && method === 'POST' ||
entry.response.status === 303 && !['GET', 'HEAD'].includes(method)) {
// HTTP-redirect fetch step 13 (https://fetch.spec.whatwg.org/#http-redirect-fetch)
method = 'GET';
}
continue;
}
return entry;
}
}
dispose() {
this._zipFile?.close();
}
}
function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray): number {
const set = new Set(headers.map(h => h.name.toLowerCase() + ':' + h.value));
let matches = 0;
for (const h of harHeaders) {
if (set.has(h.name.toLowerCase() + ':' + h.value))
++matches;
}
return matches;
}
export async function urlToWSEndpoint(progress: Progress|undefined, endpointURL: string): Promise<string> {
if (endpointURL.startsWith('ws'))
return endpointURL;
progress?.log(`<ws preparing> retrieving websocket url from ${endpointURL}`);
const fetchUrl = new URL(endpointURL);
if (!fetchUrl.pathname.endsWith('/'))
fetchUrl.pathname += '/';
fetchUrl.pathname += 'json';
const json = await fetchData({
url: fetchUrl.toString(),
method: 'GET',
timeout: progress?.timeUntilDeadline() ?? 30_000,
headers: { 'User-Agent': getUserAgent() },
}, async (params: HTTPRequestParams, response: http.IncomingMessage) => {
return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` +
`This does not look like a Playwright server, try connecting via ws://.`);
});
progress?.throwIfAborted();
const wsUrl = new URL(endpointURL);
let wsEndpointPath = JSON.parse(json).wsEndpointPath;
if (wsEndpointPath.startsWith('/'))
wsEndpointPath = wsEndpointPath.substring(1);
if (!wsUrl.pathname.endsWith('/'))
wsUrl.pathname += '/';
wsUrl.pathname += wsEndpointPath;
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
return wsUrl.toString();
}
function multipartBoundary(headers: HeadersArray) {
const contentType = headers.find(h => h.name.toLowerCase() === 'content-type');
if (!contentType?.value.includes('multipart/form-data'))
return undefined;
const boundary = contentType.value.match(/boundary=(\S+)/);
if (boundary)
return boundary[1];
return undefined;
}

View file

@ -23,7 +23,6 @@ import { TimeoutSettings } from '../../common/timeoutSettings';
import { ManualPromise, wrapInASCIIBox } from '../../utils';
import { RecentLogsCollector } from '../../utils/debugLogger';
import { eventsHelper } from '../../utils/eventsHelper';
import { envArrayToObject, launchProcess } from '../../utils/processLauncher';
import { validateBrowserContextOptions } from '../browserContext';
import { CRBrowser } from '../chromium/crBrowser';
import { CRConnection } from '../chromium/crConnection';
@ -33,6 +32,7 @@ import { ConsoleMessage } from '../console';
import { helper } from '../helper';
import { SdkObject, serverSideCallMetadata } from '../instrumentation';
import * as js from '../javascript';
import { envArrayToObject, launchProcess } from '../processLauncher';
import { ProgressController } from '../progress';
import { WebSocketTransport } from '../transport';

View file

@ -0,0 +1,205 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as fs from 'fs';
import * as path from 'path';
import { ManualPromise } from '../utils/manualPromise';
import { yazl } from '../zipBundle';
import type { EventEmitter } from 'events';
export const existsAsync = (path: string): Promise<boolean> => new Promise(resolve => fs.stat(path, err => resolve(!err)));
export async function mkdirIfNeeded(filePath: string) {
// This will harmlessly throw on windows if the dirname is the root directory.
await fs.promises.mkdir(path.dirname(filePath), { recursive: true }).catch(() => {});
}
export async function removeFolders(dirs: string[]): Promise<Error[]> {
return await Promise.all(dirs.map((dir: string) =>
fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch(e => e)
));
}
export function canAccessFile(file: string) {
if (!file)
return false;
try {
fs.accessSync(file);
return true;
} catch (e) {
return false;
}
}
export async function copyFileAndMakeWritable(from: string, to: string) {
await fs.promises.copyFile(from, to);
await fs.promises.chmod(to, 0o664);
}
export function sanitizeForFilePath(s: string) {
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
}
export function toPosixPath(aPath: string): string {
return aPath.split(path.sep).join(path.posix.sep);
}
type NameValue = { name: string, value: string };
type SerializedFSOperation = {
op: 'mkdir', dir: string,
} | {
op: 'writeFile', file: string, content: string | Buffer, skipIfExists?: boolean,
} | {
op: 'appendFile', file: string, content: string,
} | {
op: 'copyFile', from: string, to: string,
} | {
op: 'zip', entries: NameValue[], zipFileName: string,
};
export class SerializedFS {
private _buffers = new Map<string, string[]>(); // Should never be accessed from within appendOperation.
private _error: Error | undefined;
private _operations: SerializedFSOperation[] = [];
private _operationsDone: ManualPromise<void>;
constructor() {
this._operationsDone = new ManualPromise();
this._operationsDone.resolve(); // No operations scheduled yet.
}
mkdir(dir: string) {
this._appendOperation({ op: 'mkdir', dir });
}
writeFile(file: string, content: string | Buffer, skipIfExists?: boolean) {
this._buffers.delete(file); // No need to flush the buffer since we'll overwrite anyway.
this._appendOperation({ op: 'writeFile', file, content, skipIfExists });
}
appendFile(file: string, text: string, flush?: boolean) {
if (!this._buffers.has(file))
this._buffers.set(file, []);
this._buffers.get(file)!.push(text);
if (flush)
this._flushFile(file);
}
private _flushFile(file: string) {
const buffer = this._buffers.get(file);
if (buffer === undefined)
return;
const content = buffer.join('');
this._buffers.delete(file);
this._appendOperation({ op: 'appendFile', file, content });
}
copyFile(from: string, to: string) {
this._flushFile(from);
this._buffers.delete(to); // No need to flush the buffer since we'll overwrite anyway.
this._appendOperation({ op: 'copyFile', from, to });
}
async syncAndGetError() {
for (const file of this._buffers.keys())
this._flushFile(file);
await this._operationsDone;
return this._error;
}
zip(entries: NameValue[], zipFileName: string) {
for (const file of this._buffers.keys())
this._flushFile(file);
// Chain the export operation against write operations,
// so that files do not change during the export.
this._appendOperation({ op: 'zip', entries, zipFileName });
}
// This method serializes all writes to the trace.
private _appendOperation(op: SerializedFSOperation): void {
const last = this._operations[this._operations.length - 1];
if (last?.op === 'appendFile' && op.op === 'appendFile' && last.file === op.file) {
// Merge pending appendFile operations for performance.
last.content += op.content;
return;
}
this._operations.push(op);
if (this._operationsDone.isDone())
this._performOperations();
}
private async _performOperations() {
this._operationsDone = new ManualPromise();
while (this._operations.length) {
const op = this._operations.shift()!;
// Ignore all operations after the first error.
if (this._error)
continue;
try {
await this._performOperation(op);
} catch (e) {
this._error = e;
}
}
this._operationsDone.resolve();
}
private async _performOperation(op: SerializedFSOperation) {
switch (op.op) {
case 'mkdir': {
await fs.promises.mkdir(op.dir, { recursive: true });
return;
}
case 'writeFile': {
// Note: 'wx' flag only writes when the file does not exist.
// See https://nodejs.org/api/fs.html#file-system-flags.
// This way tracing never have to write the same resource twice.
if (op.skipIfExists)
await fs.promises.writeFile(op.file, op.content, { flag: 'wx' }).catch(() => {});
else
await fs.promises.writeFile(op.file, op.content);
return;
}
case 'copyFile': {
await fs.promises.copyFile(op.from, op.to);
return;
}
case 'appendFile': {
await fs.promises.appendFile(op.file, op.content);
return;
}
case 'zip': {
const zipFile = new yazl.ZipFile();
const result = new ManualPromise<void>();
(zipFile as any as EventEmitter).on('error', error => result.reject(error));
for (const entry of op.entries)
zipFile.addFile(entry.value, entry.name);
zipFile.end();
zipFile.outputStream
.pipe(fs.createWriteStream(op.zipFileName))
.on('close', () => result.resolve())
.on('error', error => result.reject(error));
await result;
return;
}
}
}
}

View file

@ -24,9 +24,9 @@ import { wrapInASCIIBox } from '../../utils';
import { BrowserType, kNoXServerRunningError } from '../browserType';
import { BrowserReadyState } from '../browserType';
import type { Env } from '../../utils/processLauncher';
import type { BrowserOptions } from '../browser';
import type { SdkObject } from '../instrumentation';
import type { Env } from '../processLauncher';
import type { ProtocolError } from '../protocolError';
import type { ConnectionTransport } from '../transport';
import type * as types from '../types';

View file

@ -31,3 +31,5 @@ export type { Playwright } from './playwright';
export { installRootRedirect, openTraceInBrowser, openTraceViewerApp, runTraceViewerApp, startTraceViewerServer } from './trace/viewer/traceViewer';
export { serverSideCallMetadata } from './instrumentation';
export { SocksProxy } from '../common/socksProxy';
export * from './fileUtils';
export * from './processLauncher';

View file

@ -20,8 +20,7 @@ import * as fs from 'fs';
import * as readline from 'readline';
import { removeFolders } from './fileUtils';
import { isUnderTest } from './';
import { isUnderTest } from '../utils';
export type Env = {[key: string]: string | number | boolean | undefined};

View file

@ -19,14 +19,10 @@ import { assert, monotonicTime } from '../utils';
import { ManualPromise } from '../utils/manualPromise';
import type { CallMetadata, Instrumentation, SdkObject } from './instrumentation';
import type { Progress as CommonProgress } from '../common/progress';
import type { LogName } from '../utils/debugLogger';
export interface Progress {
log(message: string): void;
timeUntilDeadline(): number;
isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void;
throwIfAborted(): void;
export interface Progress extends CommonProgress {
metadata: CallMetadata;
}

View file

@ -21,10 +21,10 @@ import * as os from 'os';
import * as path from 'path';
import { debugLogger } from '../../utils/debugLogger';
import { existsAsync } from '../../utils/fileUtils';
import { ManualPromise } from '../../utils/manualPromise';
import { getUserAgent } from '../../utils/userAgent';
import { colors, progress as ProgressBar } from '../../utilsBundle';
import { existsAsync } from '../fileUtils';
import { browserDirectoryToMarkerFilePath } from '.';

View file

@ -25,12 +25,12 @@ import { dockerVersion, readDockerVersionSync, transformCommandsForRoot } from '
import { installDependenciesLinux, installDependenciesWindows, validateDependenciesLinux, validateDependenciesWindows } from './dependencies';
import { calculateSha1, getAsBooleanFromENV, getFromENV, getPackageManagerExecCommand, wrapInASCIIBox } from '../../utils';
import { debugLogger } from '../../utils/debugLogger';
import { canAccessFile, existsAsync, removeFolders } from '../../utils/fileUtils';
import { hostPlatform, isOfficiallySupportedPlatform } from '../../utils/hostPlatform';
import { fetchData } from '../../utils/network';
import { spawnAsync } from '../../utils/spawnAsync';
import { getEmbedderName } from '../../utils/userAgent';
import { lockfile } from '../../utilsBundle';
import { canAccessFile, existsAsync, removeFolders } from '../fileUtils';
import type { DependencyGroup } from './dependencies';
import type { HostPlatform } from '../../utils/hostPlatform';

View file

@ -20,11 +20,12 @@ import * as path from 'path';
import { Snapshotter } from './snapshotter';
import { commandsWithTracingSnapshots } from '../../../protocol/debug';
import { SerializedFS, assert, createGuid, eventsHelper, monotonicTime, removeFolders } from '../../../utils';
import { assert, createGuid, eventsHelper, monotonicTime } from '../../../utils';
import { Artifact } from '../../artifact';
import { BrowserContext } from '../../browserContext';
import { Dispatcher } from '../../dispatchers/dispatcher';
import { serializeError } from '../../errors';
import { SerializedFS, removeFolders } from '../../fileUtils';
import { HarTracer } from '../../har/harTracer';
import { SdkObject } from '../../instrumentation';
import { Page } from '../../page';

View file

@ -17,7 +17,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { gracefullyProcessExitDoNotHang, isUnderTest } from '../../../utils';
import { gracefullyProcessExitDoNotHang } from '../../../server';
import { isUnderTest } from '../../../utils';
import { HttpServer } from '../../../utils/httpServer';
import { open } from '../../../utilsBundle';
import { serverSideCallMetadata } from '../../instrumentation';

View file

@ -7781,6 +7781,18 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
}
export type setIgnoreCertificateErrorsReturnValue = {
}
/**
* Changes page zoom factor.
*/
export type setPageZoomFactorParameters = {
/**
* Unique identifier of the page proxy.
*/
pageProxyId: PageProxyID;
zoomFactor: number;
}
export type setPageZoomFactorReturnValue = {
}
/**
* Returns all cookies in the given browser context.
*/
@ -9658,6 +9670,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
"Playwright.grantFileReadAccess": Playwright.grantFileReadAccessParameters;
"Playwright.takePageScreenshot": Playwright.takePageScreenshotParameters;
"Playwright.setIgnoreCertificateErrors": Playwright.setIgnoreCertificateErrorsParameters;
"Playwright.setPageZoomFactor": Playwright.setPageZoomFactorParameters;
"Playwright.getAllCookies": Playwright.getAllCookiesParameters;
"Playwright.setCookies": Playwright.setCookiesParameters;
"Playwright.deleteAllCookies": Playwright.deleteAllCookiesParameters;
@ -9970,6 +9983,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
"Playwright.grantFileReadAccess": Playwright.grantFileReadAccessReturnValue;
"Playwright.takePageScreenshot": Playwright.takePageScreenshotReturnValue;
"Playwright.setIgnoreCertificateErrors": Playwright.setIgnoreCertificateErrorsReturnValue;
"Playwright.setPageZoomFactor": Playwright.setPageZoomFactorReturnValue;
"Playwright.getAllCookies": Playwright.getAllCookiesReturnValue;
"Playwright.setCookies": Playwright.setCookiesReturnValue;
"Playwright.deleteAllCookies": Playwright.deleteAllCookiesReturnValue;

View file

@ -22,9 +22,9 @@ import { wrapInASCIIBox } from '../../utils';
import { BrowserType, kNoXServerRunningError } from '../browserType';
import { WKBrowser } from '../webkit/wkBrowser';
import type { Env } from '../../utils/processLauncher';
import type { BrowserOptions } from '../browser';
import type { SdkObject } from '../instrumentation';
import type { Env } from '../processLauncher';
import type { ProtocolError } from '../protocolError';
import type { ConnectionTransport } from '../transport';
import type * as types from '../types';

View file

@ -14,194 +14,17 @@
* limitations under the License.
*/
import * as fs from 'fs';
import * as path from 'path';
import { ManualPromise } from './manualPromise';
import { yazl } from '../zipBundle';
import type { EventEmitter } from 'events';
import type { Platform } from './platform';
export const fileUploadSizeLimit = 50 * 1024 * 1024;
export const existsAsync = (path: string): Promise<boolean> => new Promise(resolve => fs.stat(path, err => resolve(!err)));
export async function mkdirIfNeeded(filePath: string) {
export async function mkdirIfNeeded(platform: Platform, filePath: string) {
// This will harmlessly throw on windows if the dirname is the root directory.
await fs.promises.mkdir(path.dirname(filePath), { recursive: true }).catch(() => {});
await platform.fs().promises.mkdir(platform.path().dirname(filePath), { recursive: true }).catch(() => {});
}
export async function removeFolders(dirs: string[]): Promise<Error[]> {
export async function removeFolders(platform: Platform, dirs: string[]): Promise<Error[]> {
return await Promise.all(dirs.map((dir: string) =>
fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch(e => e)
platform.fs().promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch(e => e)
));
}
export function canAccessFile(file: string) {
if (!file)
return false;
try {
fs.accessSync(file);
return true;
} catch (e) {
return false;
}
}
export async function copyFileAndMakeWritable(from: string, to: string) {
await fs.promises.copyFile(from, to);
await fs.promises.chmod(to, 0o664);
}
export function sanitizeForFilePath(s: string) {
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
}
export function toPosixPath(aPath: string): string {
return aPath.split(path.sep).join(path.posix.sep);
}
type NameValue = { name: string, value: string };
type SerializedFSOperation = {
op: 'mkdir', dir: string,
} | {
op: 'writeFile', file: string, content: string | Buffer, skipIfExists?: boolean,
} | {
op: 'appendFile', file: string, content: string,
} | {
op: 'copyFile', from: string, to: string,
} | {
op: 'zip', entries: NameValue[], zipFileName: string,
};
export class SerializedFS {
private _buffers = new Map<string, string[]>(); // Should never be accessed from within appendOperation.
private _error: Error | undefined;
private _operations: SerializedFSOperation[] = [];
private _operationsDone: ManualPromise<void>;
constructor() {
this._operationsDone = new ManualPromise();
this._operationsDone.resolve(); // No operations scheduled yet.
}
mkdir(dir: string) {
this._appendOperation({ op: 'mkdir', dir });
}
writeFile(file: string, content: string | Buffer, skipIfExists?: boolean) {
this._buffers.delete(file); // No need to flush the buffer since we'll overwrite anyway.
this._appendOperation({ op: 'writeFile', file, content, skipIfExists });
}
appendFile(file: string, text: string, flush?: boolean) {
if (!this._buffers.has(file))
this._buffers.set(file, []);
this._buffers.get(file)!.push(text);
if (flush)
this._flushFile(file);
}
private _flushFile(file: string) {
const buffer = this._buffers.get(file);
if (buffer === undefined)
return;
const content = buffer.join('');
this._buffers.delete(file);
this._appendOperation({ op: 'appendFile', file, content });
}
copyFile(from: string, to: string) {
this._flushFile(from);
this._buffers.delete(to); // No need to flush the buffer since we'll overwrite anyway.
this._appendOperation({ op: 'copyFile', from, to });
}
async syncAndGetError() {
for (const file of this._buffers.keys())
this._flushFile(file);
await this._operationsDone;
return this._error;
}
zip(entries: NameValue[], zipFileName: string) {
for (const file of this._buffers.keys())
this._flushFile(file);
// Chain the export operation against write operations,
// so that files do not change during the export.
this._appendOperation({ op: 'zip', entries, zipFileName });
}
// This method serializes all writes to the trace.
private _appendOperation(op: SerializedFSOperation): void {
const last = this._operations[this._operations.length - 1];
if (last?.op === 'appendFile' && op.op === 'appendFile' && last.file === op.file) {
// Merge pending appendFile operations for performance.
last.content += op.content;
return;
}
this._operations.push(op);
if (this._operationsDone.isDone())
this._performOperations();
}
private async _performOperations() {
this._operationsDone = new ManualPromise();
while (this._operations.length) {
const op = this._operations.shift()!;
// Ignore all operations after the first error.
if (this._error)
continue;
try {
await this._performOperation(op);
} catch (e) {
this._error = e;
}
}
this._operationsDone.resolve();
}
private async _performOperation(op: SerializedFSOperation) {
switch (op.op) {
case 'mkdir': {
await fs.promises.mkdir(op.dir, { recursive: true });
return;
}
case 'writeFile': {
// Note: 'wx' flag only writes when the file does not exist.
// See https://nodejs.org/api/fs.html#file-system-flags.
// This way tracing never have to write the same resource twice.
if (op.skipIfExists)
await fs.promises.writeFile(op.file, op.content, { flag: 'wx' }).catch(() => {});
else
await fs.promises.writeFile(op.file, op.content);
return;
}
case 'copyFile': {
await fs.promises.copyFile(op.from, op.to);
return;
}
case 'appendFile': {
await fs.promises.appendFile(op.file, op.content);
return;
}
case 'zip': {
const zipFile = new yazl.ZipFile();
const result = new ManualPromise<void>();
(zipFile as any as EventEmitter).on('error', error => result.reject(error));
for (const entry of op.entries)
zipFile.addFile(entry.value, entry.name);
zipFile.end();
zipFile.outputStream
.pipe(fs.createWriteStream(op.zipFileName))
.on('close', () => result.resolve())
.on('error', error => result.reject(error));
await result;
return;
}
}
}
}

View file

@ -0,0 +1,175 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the 'License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as fs from 'fs';
import * as path from 'path';
import { createGuid } from './crypto';
import { ZipFile } from './zipFile';
import type { HeadersArray } from '../common/types';
import type * as har from '@trace/har';
const redirectStatus = [301, 302, 303, 307, 308];
export class HarBackend {
readonly id = createGuid();
private _harFile: har.HARFile;
private _zipFile: ZipFile | null;
private _baseDir: string | null;
constructor(harFile: har.HARFile, baseDir: string | null, zipFile: ZipFile | null) {
this._harFile = harFile;
this._baseDir = baseDir;
this._zipFile = zipFile;
}
async lookup(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined, isNavigationRequest: boolean): Promise<{
action: 'error' | 'redirect' | 'fulfill' | 'noentry',
message?: string,
redirectURL?: string,
status?: number,
headers?: HeadersArray,
body?: Buffer }> {
let entry;
try {
entry = await this._harFindResponse(url, method, headers, postData);
} catch (e) {
return { action: 'error', message: 'HAR error: ' + e.message };
}
if (!entry)
return { action: 'noentry' };
// If navigation is being redirected, restart it with the final url to ensure the document's url changes.
if (entry.request.url !== url && isNavigationRequest)
return { action: 'redirect', redirectURL: entry.request.url };
const response = entry.response;
try {
const buffer = await this._loadContent(response.content);
return {
action: 'fulfill',
status: response.status,
headers: response.headers,
body: buffer,
};
} catch (e) {
return { action: 'error', message: e.message };
}
}
private async _loadContent(content: { text?: string, encoding?: string, _file?: string }): Promise<Buffer> {
const file = content._file;
let buffer: Buffer;
if (file) {
if (this._zipFile)
buffer = await this._zipFile.read(file);
else
buffer = await fs.promises.readFile(path.resolve(this._baseDir!, file));
} else {
buffer = Buffer.from(content.text || '', content.encoding === 'base64' ? 'base64' : 'utf-8');
}
return buffer;
}
private async _harFindResponse(url: string, method: string, headers: HeadersArray, postData: Buffer | undefined): Promise<har.Entry | undefined> {
const harLog = this._harFile.log;
const visited = new Set<har.Entry>();
while (true) {
const entries: har.Entry[] = [];
for (const candidate of harLog.entries) {
if (candidate.request.url !== url || candidate.request.method !== method)
continue;
if (method === 'POST' && postData && candidate.request.postData) {
const buffer = await this._loadContent(candidate.request.postData);
if (!buffer.equals(postData)) {
const boundary = multipartBoundary(headers);
if (!boundary)
continue;
const candidataBoundary = multipartBoundary(candidate.request.headers);
if (!candidataBoundary)
continue;
// Try to match multipart/form-data ignroing boundary as it changes between requests.
if (postData.toString().replaceAll(boundary, '') !== buffer.toString().replaceAll(candidataBoundary, ''))
continue;
}
}
entries.push(candidate);
}
if (!entries.length)
return;
let entry = entries[0];
// Disambiguate using headers - then one with most matching headers wins.
if (entries.length > 1) {
const list: { candidate: har.Entry, matchingHeaders: number }[] = [];
for (const candidate of entries) {
const matchingHeaders = countMatchingHeaders(candidate.request.headers, headers);
list.push({ candidate, matchingHeaders });
}
list.sort((a, b) => b.matchingHeaders - a.matchingHeaders);
entry = list[0].candidate;
}
if (visited.has(entry))
throw new Error(`Found redirect cycle for ${url}`);
visited.add(entry);
// Follow redirects.
const locationHeader = entry.response.headers.find(h => h.name.toLowerCase() === 'location');
if (redirectStatus.includes(entry.response.status) && locationHeader) {
const locationURL = new URL(locationHeader.value, url);
url = locationURL.toString();
if ((entry.response.status === 301 || entry.response.status === 302) && method === 'POST' ||
entry.response.status === 303 && !['GET', 'HEAD'].includes(method)) {
// HTTP-redirect fetch step 13 (https://fetch.spec.whatwg.org/#http-redirect-fetch)
method = 'GET';
}
continue;
}
return entry;
}
}
dispose() {
this._zipFile?.close();
}
}
function countMatchingHeaders(harHeaders: har.Header[], headers: HeadersArray): number {
const set = new Set(headers.map(h => h.name.toLowerCase() + ':' + h.value));
let matches = 0;
for (const h of harHeaders) {
if (set.has(h.name.toLowerCase() + ':' + h.value))
++matches;
}
return matches;
}
function multipartBoundary(headers: HeadersArray) {
const contentType = headers.find(h => h.name.toLowerCase() === 'content-type');
if (!contentType?.value.includes('multipart/form-data'))
return undefined;
const boundary = contentType.value.match(/boundary=(\S+)/);
if (boundary)
return boundary[1];
return undefined;
}

View file

@ -33,7 +33,6 @@ export * from './isomorphic/stringUtils';
export * from './isomorphic/urlMatch';
export * from './multimap';
export * from './network';
export * from './processLauncher';
export * from './profiler';
export * from './rtti';
export * from './semaphore';

View file

@ -0,0 +1,248 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the 'License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { removeFolders } from './fileUtils';
import { HarBackend } from './harBackend';
import { ManualPromise } from './manualPromise';
import { fetchData } from './network';
import { getUserAgent } from './userAgent';
import { ZipFile } from './zipFile';
import { yauzl, yazl } from '../zipBundle';
import { serializeClientSideCallMetadata } from '.';
import { assert, calculateSha1 } from '.';
import type { HTTPRequestParams } from './network';
import type { Platform } from './platform';
import type { Progress } from '../common/progress';
import type * as channels from '@protocol/channels';
import type * as har from '@trace/har';
import type EventEmitter from 'events';
import type http from 'http';
export type StackSession = {
file: string;
writer: Promise<void>;
tmpDir: string | undefined;
callStacks: channels.ClientSideCallMetadata[];
};
export async function zip(platform: Platform, stackSessions: Map<string, StackSession>, params: channels.LocalUtilsZipParams): Promise<void> {
const promise = new ManualPromise<void>();
const zipFile = new yazl.ZipFile();
(zipFile as any as EventEmitter).on('error', error => promise.reject(error));
const addFile = (file: string, name: string) => {
try {
if (fs.statSync(file).isFile())
zipFile.addFile(file, name);
} catch (e) {
}
};
for (const entry of params.entries)
addFile(entry.value, entry.name);
// Add stacks and the sources.
const stackSession = params.stacksId ? stackSessions.get(params.stacksId) : undefined;
if (stackSession?.callStacks.length) {
await stackSession.writer;
if (process.env.PW_LIVE_TRACE_STACKS) {
zipFile.addFile(stackSession.file, 'trace.stacks');
} else {
const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(stackSession.callStacks)));
zipFile.addBuffer(buffer, 'trace.stacks');
}
}
// Collect sources from stacks.
if (params.includeSources) {
const sourceFiles = new Set<string>();
for (const { stack } of stackSession?.callStacks || []) {
if (!stack)
continue;
for (const { file } of stack)
sourceFiles.add(file);
}
for (const sourceFile of sourceFiles)
addFile(sourceFile, 'resources/src@' + calculateSha1(sourceFile) + '.txt');
}
if (params.mode === 'write') {
// New file, just compress the entries.
await fs.promises.mkdir(path.dirname(params.zipFile), { recursive: true });
zipFile.end(undefined, () => {
zipFile.outputStream.pipe(fs.createWriteStream(params.zipFile))
.on('close', () => promise.resolve())
.on('error', error => promise.reject(error));
});
await promise;
await deleteStackSession(platform, stackSessions, params.stacksId);
return;
}
// File already exists. Repack and add new entries.
const tempFile = params.zipFile + '.tmp';
await fs.promises.rename(params.zipFile, tempFile);
yauzl.open(tempFile, (err, inZipFile) => {
if (err) {
promise.reject(err);
return;
}
assert(inZipFile);
let pendingEntries = inZipFile.entryCount;
inZipFile.on('entry', entry => {
inZipFile.openReadStream(entry, (err, readStream) => {
if (err) {
promise.reject(err);
return;
}
zipFile.addReadStream(readStream!, entry.fileName);
if (--pendingEntries === 0) {
zipFile.end(undefined, () => {
zipFile.outputStream.pipe(fs.createWriteStream(params.zipFile)).on('close', () => {
fs.promises.unlink(tempFile).then(() => {
promise.resolve();
}).catch(error => promise.reject(error));
});
});
}
});
});
});
await promise;
await deleteStackSession(platform, stackSessions, params.stacksId);
}
async function deleteStackSession(platform: Platform, stackSessions: Map<string, StackSession>, stacksId?: string) {
const session = stacksId ? stackSessions.get(stacksId) : undefined;
if (!session)
return;
await session.writer;
if (session.tmpDir)
await removeFolders(platform, [session.tmpDir]);
stackSessions.delete(stacksId!);
}
export async function harOpen(harBackends: Map<string, HarBackend>, params: channels.LocalUtilsHarOpenParams): Promise<channels.LocalUtilsHarOpenResult> {
let harBackend: HarBackend;
if (params.file.endsWith('.zip')) {
const zipFile = new ZipFile(params.file);
const entryNames = await zipFile.entries();
const harEntryName = entryNames.find(e => e.endsWith('.har'));
if (!harEntryName)
return { error: 'Specified archive does not have a .har file' };
const har = await zipFile.read(harEntryName);
const harFile = JSON.parse(har.toString()) as har.HARFile;
harBackend = new HarBackend(harFile, null, zipFile);
} else {
const harFile = JSON.parse(await fs.promises.readFile(params.file, 'utf-8')) as har.HARFile;
harBackend = new HarBackend(harFile, path.dirname(params.file), null);
}
harBackends.set(harBackend.id, harBackend);
return { harId: harBackend.id };
}
export async function harLookup(harBackends: Map<string, HarBackend>, params: channels.LocalUtilsHarLookupParams): Promise<channels.LocalUtilsHarLookupResult> {
const harBackend = harBackends.get(params.harId);
if (!harBackend)
return { action: 'error', message: `Internal error: har was not opened` };
return await harBackend.lookup(params.url, params.method, params.headers, params.postData, params.isNavigationRequest);
}
export async function harClose(harBackends: Map<string, HarBackend>, params: channels.LocalUtilsHarCloseParams): Promise<void> {
const harBackend = harBackends.get(params.harId);
if (harBackend) {
harBackends.delete(harBackend.id);
harBackend.dispose();
}
}
export async function harUnzip(params: channels.LocalUtilsHarUnzipParams): Promise<void> {
const dir = path.dirname(params.zipFile);
const zipFile = new ZipFile(params.zipFile);
for (const entry of await zipFile.entries()) {
const buffer = await zipFile.read(entry);
if (entry === 'har.har')
await fs.promises.writeFile(params.harFile, buffer);
else
await fs.promises.writeFile(path.join(dir, entry), buffer);
}
zipFile.close();
await fs.promises.unlink(params.zipFile);
}
export async function tracingStarted(stackSessions: Map<string, StackSession>, params: channels.LocalUtilsTracingStartedParams): Promise<channels.LocalUtilsTracingStartedResult> {
let tmpDir = undefined;
if (!params.tracesDir)
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-tracing-'));
const traceStacksFile = path.join(params.tracesDir || tmpDir!, params.traceName + '.stacks');
stackSessions.set(traceStacksFile, { callStacks: [], file: traceStacksFile, writer: Promise.resolve(), tmpDir });
return { stacksId: traceStacksFile };
}
export async function traceDiscarded(platform: Platform, stackSessions: Map<string, StackSession>, params: channels.LocalUtilsTraceDiscardedParams): Promise<void> {
await deleteStackSession(platform, stackSessions, params.stacksId);
}
export async function addStackToTracingNoReply(stackSessions: Map<string, StackSession>, params: channels.LocalUtilsAddStackToTracingNoReplyParams): Promise<void> {
for (const session of stackSessions.values()) {
session.callStacks.push(params.callData);
if (process.env.PW_LIVE_TRACE_STACKS) {
session.writer = session.writer.then(() => {
const buffer = Buffer.from(JSON.stringify(serializeClientSideCallMetadata(session.callStacks)));
return fs.promises.writeFile(session.file, buffer);
});
}
}
}
export async function urlToWSEndpoint(progress: Progress | undefined, endpointURL: string): Promise<string> {
if (endpointURL.startsWith('ws'))
return endpointURL;
progress?.log(`<ws preparing> retrieving websocket url from ${endpointURL}`);
const fetchUrl = new URL(endpointURL);
if (!fetchUrl.pathname.endsWith('/'))
fetchUrl.pathname += '/';
fetchUrl.pathname += 'json';
const json = await fetchData({
url: fetchUrl.toString(),
method: 'GET',
timeout: progress?.timeUntilDeadline() ?? 30_000,
headers: { 'User-Agent': getUserAgent() },
}, async (params: HTTPRequestParams, response: http.IncomingMessage) => {
return new Error(`Unexpected status ${response.statusCode} when connecting to ${fetchUrl.toString()}.\n` +
`This does not look like a Playwright server, try connecting via ws://.`);
});
progress?.throwIfAborted();
const wsUrl = new URL(endpointURL);
let wsEndpointPath = JSON.parse(json).wsEndpointPath;
if (wsEndpointPath.startsWith('/'))
wsEndpointPath = wsEndpointPath.substring(1);
if (!wsUrl.pathname.endsWith('/'))
wsUrl.pathname += '/';
wsUrl.pathname += wsEndpointPath;
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
return wsUrl.toString();
}

View file

@ -0,0 +1,43 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as util from 'util';
export type Platform = {
fs: () => typeof fs;
path: () => typeof path;
inspectCustom: symbol | undefined;
};
export const emptyPlatform: Platform = {
fs: () => {
throw new Error('File system is not available');
},
path: () => {
throw new Error('Path module is not available');
},
inspectCustom: undefined,
};
export const nodePlatform: Platform = {
fs: () => fs,
path: () => path,
inspectCustom: util.inspect.custom,
};

View file

@ -17,7 +17,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { gracefullyProcessExitDoNotHang, isRegExp } from 'playwright-core/lib/utils';
import { gracefullyProcessExitDoNotHang } from 'playwright-core/lib/server';
import { isRegExp } from 'playwright-core/lib/utils';
import { requireOrImport, setSingleTSConfig, setTransformConfig } from '../transform/transform';
import { errorWithFile, fileIsModule } from '../util';

View file

@ -16,7 +16,8 @@
import * as path from 'path';
import { calculateSha1, toPosixPath } from 'playwright-core/lib/utils';
import { toPosixPath } from 'playwright-core/lib/server';
import { calculateSha1 } from 'playwright-core/lib/utils';
import { createFileMatcher } from '../util';

View file

@ -471,7 +471,7 @@ function normalizeScreenshotMode(screenshot: ScreenshotOption): ScreenshotMode {
}
function attachConnectedHeaderIfNeeded(testInfo: TestInfo, browser: Browser | null) {
const connectHeaders: { name: string, value: string }[] | undefined = (browser as any)?._connectHeaders;
const connectHeaders: { name: string, value: string }[] | undefined = (browser as any)?._connection.headers;
if (!connectHeaders)
return;
for (const header of connectHeaders) {

View file

@ -18,7 +18,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { escapeTemplateString, isString, sanitizeForFilePath } from 'playwright-core/lib/utils';
import { sanitizeForFilePath } from 'playwright-core/lib/server';
import { escapeTemplateString, isString } from 'playwright-core/lib/utils';
import { kNoElementsFoundError, matcherHint } from './matcherHint';
import { EXPECTED_COLOR } from '../common/expectBundle';

View file

@ -17,7 +17,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { compareBuffersOrStrings, getComparator, isString, sanitizeForFilePath } from 'playwright-core/lib/utils';
import { sanitizeForFilePath } from 'playwright-core/lib/server';
import { compareBuffersOrStrings, getComparator, isString } from 'playwright-core/lib/utils';
import { colors } from 'playwright-core/lib/utilsBundle';
import { mime } from 'playwright-core/lib/utilsBundle';

View file

@ -16,7 +16,8 @@
import * as net from 'net';
import * as path from 'path';
import { isURLAvailable, launchProcess, monotonicTime, raceAgainstDeadline } from 'playwright-core/lib/utils';
import { launchProcess } from 'playwright-core/lib/server';
import { isURLAvailable, monotonicTime, raceAgainstDeadline } from 'playwright-core/lib/utils';
import { colors, debug } from 'playwright-core/lib/utilsBundle';
import type { TestRunnerPlugin } from '.';

View file

@ -20,7 +20,8 @@ import * as fs from 'fs';
import * as path from 'path';
import { program } from 'playwright-core/lib/cli/program';
import { gracefullyProcessExitDoNotHang, startProfiling, stopProfiling } from 'playwright-core/lib/utils';
import { gracefullyProcessExitDoNotHang } from 'playwright-core/lib/server';
import { startProfiling, stopProfiling } from 'playwright-core/lib/utils';
import { builtInReporters, defaultReporter, defaultTimeout } from './common/config';
import { loadConfigFromFileRestartIfNeeded, loadEmptyConfigForMergeReports, resolveConfigLocation } from './common/configLoader';
@ -28,6 +29,7 @@ export { program } from 'playwright-core/lib/cli/program';
import { prepareErrorStack } from './reporters/base';
import { showHTMLReport } from './reporters/html';
import { createMergedReport } from './reporters/merge';
import { filterProjects } from './runner/projectUtils';
import { Runner } from './runner/runner';
import * as testServer from './runner/testServer';
import { runWatchModeLoop } from './runner/watchMode';
@ -160,6 +162,23 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
await startProfiling();
const cliOverrides = overridesFromOptions(opts);
const config = await loadConfigFromFileRestartIfNeeded(opts.config, cliOverrides, opts.deps === false);
if (!config)
return;
config.cliArgs = args;
config.cliGrep = opts.grep as string | undefined;
config.cliOnlyChanged = opts.onlyChanged === true ? 'HEAD' : opts.onlyChanged;
config.cliGrepInvert = opts.grepInvert as string | undefined;
config.cliListOnly = !!opts.list;
config.cliProjectFilter = opts.project || undefined;
config.cliPassWithNoTests = !!opts.passWithNoTests;
config.cliFailOnFlakyTests = !!opts.failOnFlakyTests;
config.cliLastFailed = !!opts.lastFailed;
// Evaluate project filters against config before starting execution. This enables a consistent error message across run modes
filterProjects(config.projects, config.cliProjectFilter);
if (opts.ui || opts.uiHost || opts.uiPort) {
if (opts.onlyChanged)
throw new Error(`--only-changed is not supported in UI mode. If you'd like that to change, see https://github.com/microsoft/playwright/issues/15075 for more details.`);
@ -201,20 +220,6 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
return;
}
const config = await loadConfigFromFileRestartIfNeeded(opts.config, cliOverrides, opts.deps === false);
if (!config)
return;
config.cliArgs = args;
config.cliGrep = opts.grep as string | undefined;
config.cliOnlyChanged = opts.onlyChanged === true ? 'HEAD' : opts.onlyChanged;
config.cliGrepInvert = opts.grepInvert as string | undefined;
config.cliListOnly = !!opts.list;
config.cliProjectFilter = opts.project || undefined;
config.cliPassWithNoTests = !!opts.passWithNoTests;
config.cliFailOnFlakyTests = !!opts.failOnFlakyTests;
config.cliLastFailed = !!opts.lastFailed;
const runner = new Runner(config);
const status = await runner.runAllTests();
await stopProfiling('runner');

View file

@ -18,7 +18,8 @@ import * as fs from 'fs';
import * as path from 'path';
import { Readable } from 'stream';
import { ManualPromise, calculateSha1, createGuid, getUserAgent, removeFolders, sanitizeForFilePath } from 'playwright-core/lib/utils';
import { removeFolders, sanitizeForFilePath } from 'playwright-core/lib/server';
import { ManualPromise, calculateSha1, createGuid, getUserAgent } from 'playwright-core/lib/utils';
import { mime } from 'playwright-core/lib/utilsBundle';
import { yazl } from 'playwright-core/lib/zipBundle';

View file

@ -18,8 +18,8 @@ import * as fs from 'fs';
import * as path from 'path';
import { Transform } from 'stream';
import { MultiMap, getPackageManagerExecCommand } from 'playwright-core/lib/utils';
import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/utils';
import { copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath, toPosixPath } from 'playwright-core/lib/server';
import { HttpServer, MultiMap, assert, calculateSha1, getPackageManagerExecCommand } from 'playwright-core/lib/utils';
import { colors, open } from 'playwright-core/lib/utilsBundle';
import { mime } from 'playwright-core/lib/utilsBundle';
import { yazl } from 'playwright-core/lib/zipBundle';
@ -449,6 +449,17 @@ class HtmlBuilder {
return a;
}
if (a.name === 'pageSnapshot') {
try {
const body = fs.readFileSync(a.path!, { encoding: 'utf-8' });
return {
name: 'pageSnapshot',
contentType: a.contentType,
body,
};
} catch {}
}
if (a.path) {
let fileName = a.path;
try {

View file

@ -17,7 +17,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { MultiMap, toPosixPath } from 'playwright-core/lib/utils';
import { toPosixPath } from 'playwright-core/lib/server';
import { MultiMap } from 'playwright-core/lib/utils';
import { formatError, nonTerminalScreen, prepareErrorStack, resolveOutputFile } from './base';
import { getProjectId } from '../common/config';

View file

@ -18,7 +18,8 @@ import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
import { monotonicTime, removeFolders } from 'playwright-core/lib/utils';
import { removeFolders } from 'playwright-core/lib/server';
import { monotonicTime } from 'playwright-core/lib/utils';
import { debug } from 'playwright-core/lib/utilsBundle';
import { Dispatcher } from './dispatcher';
@ -26,12 +27,12 @@ import { FailureTracker } from './failureTracker';
import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook } from './loadUtils';
import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils';
import { applySuggestedRebaselines, clearSuggestedRebaselines } from './rebase';
import { Suite } from '../common/test';
import { createTestGroups } from '../runner/testGroups';
import { removeDirAndLogToConsole } from '../util';
import { TaskRunner } from './taskRunner';
import { detectChangedTestFiles } from './vcs';
import { Suite } from '../common/test';
import { createTestGroups } from '../runner/testGroups';
import { cacheDir } from '../transform/compilationCache';
import { removeDirAndLogToConsole } from '../util';
import type { TestGroup } from '../runner/testGroups';
import type { Matcher } from '../util';

View file

@ -17,8 +17,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { installRootRedirect, openTraceInBrowser, openTraceViewerApp, registry, startTraceViewerServer } from 'playwright-core/lib/server';
import { ManualPromise, gracefullyProcessExitDoNotHang, isUnderTest } from 'playwright-core/lib/utils';
import { gracefullyProcessExitDoNotHang, installRootRedirect, openTraceInBrowser, openTraceViewerApp, registry, startTraceViewerServer } from 'playwright-core/lib/server';
import { ManualPromise, isUnderTest } from 'playwright-core/lib/utils';
import { open } from 'playwright-core/lib/utilsBundle';
import { createErrorCollectingReporter, createReporterForTestServer, createReporters } from './reporters';

View file

@ -17,7 +17,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { removeFolders } from 'playwright-core/lib/utils';
import { removeFolders } from 'playwright-core/lib/server';
import { ProcessHost } from './processHost';
import { stdioChunkToParams } from '../common/ipc';

View file

@ -19,8 +19,8 @@ import * as path from 'path';
import * as url from 'url';
import util from 'util';
import { formatCallLog } from 'playwright-core/lib/utils';
import { calculateSha1, isRegExp, isString, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils';
import { sanitizeForFilePath } from 'playwright-core/lib/server';
import { calculateSha1, formatCallLog, isRegExp, isString, stringifyStackFrames } from 'playwright-core/lib/utils';
import { debug, mime, minimatch, parseStackTraceLine } from 'playwright-core/lib/utilsBundle';
import type { Location } from './../types/testReporter';

View file

@ -17,7 +17,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { captureRawStack, monotonicTime, sanitizeForFilePath, stringifyStackFrames, zones } from 'playwright-core/lib/utils';
import { sanitizeForFilePath } from 'playwright-core/lib/server';
import { captureRawStack, monotonicTime, stringifyStackFrames, zones } from 'playwright-core/lib/utils';
import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager';
import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normalizeAndSaveAttachment, trimLongString, windowsFilesystemFriendlyLength } from '../util';

View file

@ -17,7 +17,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { ManualPromise, SerializedFS, calculateSha1, createGuid, monotonicTime } from 'playwright-core/lib/utils';
import { SerializedFS } from 'playwright-core/lib/server';
import { ManualPromise, calculateSha1, createGuid, monotonicTime } from 'playwright-core/lib/utils';
import { yauzl, yazl } from 'playwright-core/lib/zipBundle';
import { filteredStackTrace } from '../util';

View file

@ -14,7 +14,9 @@
* limitations under the License.
*/
import { ManualPromise, gracefullyCloseAll, removeFolders } from 'playwright-core/lib/utils';
import { removeFolders } from 'playwright-core/lib/server';
import { gracefullyCloseAll } from 'playwright-core/lib/server';
import { ManualPromise } from 'playwright-core/lib/utils';
import { colors } from 'playwright-core/lib/utilsBundle';
import { deserializeConfig } from '../common/configLoader';

View file

@ -165,7 +165,7 @@ function isEqualAttachment(a: Attachment, b: AfterActionTraceEventAttachment): b
return a.name === b.name && a.path === b.path && a.sha1 === b.sha1;
}
function attachmentURL(attachment: Attachment, queryParams: Record<string, string> = {}) {
export function attachmentURL(attachment: Attachment, queryParams: Record<string, string> = {}) {
const params = new URLSearchParams(queryParams);
if (attachment.sha1) {
params.set('trace', attachment.traceUrl);

View file

@ -46,11 +46,16 @@ export const CopyToClipboard: React.FunctionComponent<{
export const CopyToClipboardTextButton: React.FunctionComponent<{
value: string | (() => Promise<string>),
description: string,
}> = ({ value, description }) => {
copiedDescription?: React.ReactNode,
style?: React.CSSProperties,
}> = ({ value, description, copiedDescription = description, style }) => {
const [copied, setCopied] = React.useState(false);
const handleCopy = React.useCallback(async () => {
const valueToCopy = typeof value === 'function' ? await value() : value;
await navigator.clipboard.writeText(valueToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
}, [value]);
return <ToolbarButton title={description} onClick={handleCopy} className='copy-to-clipboard-text-button'>{description}</ToolbarButton>;
return <ToolbarButton style={style} title={description} onClick={handleCopy} className='copy-to-clipboard-text-button'>{copied ? copiedDescription : description}</ToolbarButton>;
};

View file

@ -21,6 +21,59 @@ import { PlaceholderPanel } from './placeholderPanel';
import { renderAction } from './actionList';
import type { Language } from '@isomorphic/locatorGenerators';
import type { StackFrame } from '@protocol/channels';
import { CopyToClipboardTextButton } from './copyToClipboard';
import { attachmentURL } from './attachmentsTab';
import { fixTestPrompt } from '@web/components/prompts';
import type { GitCommitInfo } from '@testIsomorphic/types';
const GitCommitInfoContext = React.createContext<GitCommitInfo | undefined>(undefined);
export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) {
return <GitCommitInfoContext.Provider value={gitCommitInfo}>{children}</GitCommitInfoContext.Provider>;
}
export function useGitCommitInfo() {
return React.useContext(GitCommitInfoContext);
}
const PromptButton: React.FC<{
error: string;
actions: modelUtil.ActionTraceEventInContext[];
}> = ({ error, actions }) => {
const [pageSnapshot, setPageSnapshot] = React.useState<string>();
React.useEffect(() => {
for (const action of actions) {
for (const attachment of action.attachments ?? []) {
if (attachment.name === 'pageSnapshot') {
fetch(attachmentURL({ ...attachment, traceUrl: action.context.traceUrl })).then(async response => {
setPageSnapshot(await response.text());
});
return;
}
}
}
}, [actions]);
const gitCommitInfo = useGitCommitInfo();
const prompt = React.useMemo(
() => fixTestPrompt(
error,
gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'],
pageSnapshot
),
[error, gitCommitInfo, pageSnapshot]
);
return (
<CopyToClipboardTextButton
value={prompt}
description='Fix with AI'
copiedDescription={<>Copied <span className='codicon codicon-copy' style={{ marginLeft: '5px' }}/></>}
style={{ width: '90px', justifyContent: 'center' }}
/>
);
};
export type ErrorDescription = {
action?: modelUtil.ActionTraceEventInContext;
@ -44,9 +97,10 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined):
export const ErrorsTab: React.FunctionComponent<{
errorsModel: ErrorsTabModel,
actions: modelUtil.ActionTraceEventInContext[],
sdkLanguage: Language,
revealInSource: (error: ErrorDescription) => void,
}> = ({ errorsModel, sdkLanguage, revealInSource }) => {
}> = ({ errorsModel, sdkLanguage, revealInSource, actions }) => {
if (!errorsModel.errors.size)
return <PlaceholderPanel text='No errors' />;
@ -72,6 +126,9 @@ export const ErrorsTab: React.FunctionComponent<{
{location && <div className='action-location'>
@ <span title={longLocation} onClick={() => revealInSource(error)}>{location}</span>
</div>}
<span style={{ position: 'absolute', right: '5px' }}>
<PromptButton error={message} actions={actions} />
</span>
</div>
<ErrorMessage error={message} />
</div>;

View file

@ -37,6 +37,7 @@ import { TestListView } from './uiModeTestListView';
import { TraceView } from './uiModeTraceView';
import { SettingsView } from './settingsView';
import { DefaultSettingsView } from './defaultSettingsView';
import { GitCommitInfoProvider } from './errorsTab';
let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = {
@ -430,13 +431,15 @@ export const UIModeView: React.FC<{}> = ({
<XtermWrapper source={xtermDataSource}></XtermWrapper>
</div>
<div className={clsx('vbox', isShowingOutput && 'hidden')}>
<TraceView
pathSeparator={queryParams.pathSeparator}
item={selectedItem}
rootDir={testModel?.config?.rootDir}
revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/>
<GitCommitInfoProvider gitCommitInfo={testModel?.config.metadata['git.commit.info']}>
<TraceView
pathSeparator={queryParams.pathSeparator}
item={selectedItem}
rootDir={testModel?.config?.rootDir}
revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/>
</GitCommitInfoProvider>
</div>
</div>}
sidebar={<div className='vbox ui-mode-sidebar'>

View file

@ -199,7 +199,7 @@ export const Workbench: React.FunctionComponent<{
else
setRevealedError(error);
selectPropertiesTab('source');
}} />
}} actions={model?.actions ?? []} />
};
// Fallback location w/o action stands for file / test.

View file

@ -0,0 +1,56 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g');
function stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, '');
}
export function fixTestPrompt(error: string, diff?: string, pageSnapshot?: string) {
const promptParts = [
`My Playwright test failed. What's going wrong?`,
`Please give me a suggestion how to fix it, and then explain what went wrong. Be very concise and apply Playwright best practices.`,
`Don't include many headings in your output. Make sure what you're saying is correct, and take into account whether there might be a bug in the app.`,
'Here is the error:',
'\n',
'```js',
stripAnsiEscapes(error),
'```',
'\n',
];
if (pageSnapshot) {
promptParts.push(
'This is how the page looked at the end of the test:\n',
'```yaml',
pageSnapshot,
'```',
'\n'
);
}
if (diff) {
promptParts.push(
'And this is the code diff:\n',
'```diff',
diff,
'```',
'\n'
);
}
return promptParts.join('\n');
}

View file

@ -16,15 +16,17 @@
import * as fs from 'fs';
import * as os from 'os';
import type { PageTestFixtures, PageWorkerFixtures } from '../page/pageTestApi';
import * as path from 'path';
import type { BrowserContext, BrowserContextOptions, BrowserType, Page } from 'playwright-core';
import { removeFolders } from '../../packages/playwright-core/lib/utils/fileUtils';
import { baseTest } from './baseTest';
import { type RemoteServerOptions, type PlaywrightServer, RunServer, RemoteServer } from './remoteServer';
import type { Log } from '../../packages/trace/src/har';
import { RunServer, RemoteServer } from './remoteServer';
import { removeFolders } from '../../packages/playwright-core/lib/server/fileUtils';
import { parseHar } from '../config/utils';
import { createSkipTestPredicate } from '../bidi/expectationUtil';
import type { PageTestFixtures, PageWorkerFixtures } from '../page/pageTestApi';
import type { RemoteServerOptions, PlaywrightServer } from './remoteServer';
import type { BrowserContext, BrowserContextOptions, BrowserType, Page } from 'playwright-core';
import type { Log } from '../../packages/trace/src/har';
import type { TestInfo } from '@playwright/test';
export type BrowserTestWorkerFixtures = PageWorkerFixtures & {

View file

@ -17,7 +17,7 @@
import path from 'path';
import fs from 'fs';
import { spawnAsync } from '../../packages/playwright-core/lib/utils/spawnAsync';
import { removeFolders } from '../../packages/playwright-core/lib/utils/fileUtils';
import { removeFolders } from '../../packages/playwright-core/lib/server/fileUtils';
import { TMP_WORKSPACES } from './npmTest';
const PACKAGE_BUILDER_SCRIPT = path.join(__dirname, '..', '..', 'utils', 'pack_package.js');

Some files were not shown because too many files have changed in this diff Show more