Compare commits
284 Commits
Author | SHA1 | Date | |
---|---|---|---|
79fb9d449c | |||
73a93d3ab6 | |||
8eda5a152b | |||
7b82ce0c67 | |||
2eb5fe699f | |||
f99febcdf9 | |||
36cbfb1771 | |||
1f5315f6f7 | |||
eeebe621fe | |||
05f279df49 | |||
485d67bfed | |||
a1592f5a20 | |||
a251374ecd | |||
2b00c17091 | |||
81724f5790 | |||
1f06b6c99b | |||
6790709b93 | |||
9f7f67121c | |||
db49beae15 | |||
97609daea9 | |||
abcb03cb82 | |||
4f09f7db73 | |||
f1e14a3224 | |||
50de03a83a | |||
65555fe35d | |||
6da3867d63 | |||
c9488b5432 | |||
b22c376123 | |||
8a6f3723ca | |||
ccb0ec9c35 | |||
0c9a087809 | |||
2515ff660b | |||
70c79cb969 | |||
ed04e99c95 | |||
623adbbdf7 | |||
3660ff80b7 | |||
3b4d9dc576 | |||
8c6c2fc80d | |||
3886bfadb0 | |||
b35ab4f0e6 | |||
39d979c5fa | |||
ff980032e7 | |||
2fe401dfbb | |||
b4421bb96b | |||
ecb28bf5aa | |||
5c5164b6e7 | |||
b8a081a8a5 | |||
59d80c471a | |||
9e5b0794c5 | |||
1c1fd98591 | |||
d815e4137f | |||
fe0c5bfdb3 | |||
1975c0a4d2 | |||
efde073ab9 | |||
a68c29da4b | |||
becb775d08 | |||
a273491be0 | |||
7d45386262 | |||
82fcb325a1 | |||
115c874779 | |||
8cbebc673d | |||
94b2673c1f | |||
bc73dcb448 | |||
fefa171d83 | |||
5c84b91543 | |||
00b37310e1 | |||
bc27b95771 | |||
31f352c043 | |||
ad62eaa612 | |||
66c2d089f0 | |||
c1bf82adb9 | |||
c05d24e0fe | |||
adbb920ae8 | |||
f871fecf66 | |||
9f919f762a | |||
6df45a6d47 | |||
71128e2392 | |||
38980f1813 | |||
76f30524be | |||
721343349b | |||
4c19a2dba9 | |||
4aacbbe04b | |||
19c2d5b3d4 | |||
2f79aab084 | |||
63b178ec3d | |||
a48bf0bdb6 | |||
22dc8adae5 | |||
04a023c31a | |||
c12e553ff1 | |||
c8eb6182bc | |||
0a7a542edd | |||
9d7ad34873 | |||
63c2a2a74a | |||
2e0de01372 | |||
88e080003d | |||
8a62f0a36c | |||
6c8863aa09 | |||
fe92614c91 | |||
166bb8e048 | |||
c7da5d8cfd | |||
153738dce9 | |||
ce4aa5cb93 | |||
0647582292 | |||
c5620d1c7a | |||
e3a73dff45 | |||
5881f34787 | |||
acffa22a35 | |||
159e8b4fda | |||
d52dd0a8d1 | |||
05252769bf | |||
9c36a3520d | |||
1b282c278f | |||
c9fece997c | |||
c8817f39a9 | |||
2f1aec4744 | |||
e55127906a | |||
789ff49bcf | |||
df02d6dd86 | |||
fb8028a130 | |||
29647bb815 | |||
2911e99baf | |||
3952367bf3 | |||
ea6aade4ce | |||
26baf15b12 | |||
25c5cba7b3 | |||
fb06037392 | |||
90f8a1622e | |||
8c9edb8484 | |||
52cd20d4fe | |||
c7567b65f2 | |||
0fdd1bb929 | |||
559c647db7 | |||
42e2e7cf57 | |||
adad1706e0 | |||
a169743324 | |||
cea7fbe93f | |||
b907e5a2bc | |||
77d2cbda4a | |||
a730fc703f | |||
af26914ba9 | |||
7f7bc64186 | |||
33af76929f | |||
edbf3d2fe3 | |||
a39445fe09 | |||
0b05448a7d | |||
852a73ef82 | |||
b8bfc03875 | |||
c4887ab10a | |||
1abd3977be | |||
98961e3d44 | |||
3f89d3094b | |||
484d3d9a64 | |||
37f3b92ff5 | |||
50cd655c6c | |||
05d1b84f52 | |||
69452231df | |||
4f6bef5b32 | |||
ec96332559 | |||
ee9f0b5d9a | |||
a135f48b6d | |||
61b4c26893 | |||
f1cb46081c | |||
0ec925bd2f | |||
5f1b861525 | |||
f0d70545e8 | |||
26341c7fd4 | |||
e9f4f1b416 | |||
9cd534bd63 | |||
2f8e1fbab8 | |||
c7a6adc771 | |||
8fb2b473ca | |||
5886090d50 | |||
3988ebf432 | |||
5099b79545 | |||
038d06d2e9 | |||
9e1aff9fe6 | |||
a41f331cb4 | |||
71628f1837 | |||
df878a6b60 | |||
48d7f4e8b5 | |||
66f5d27e50 | |||
91dd160b21 | |||
1c44b71fd2 | |||
a5e0ae501d | |||
2d0e642dbe | |||
9ea656f20e | |||
97ae7aed41 | |||
678b4209c8 | |||
b7be4f55be | |||
e7c72ab556 | |||
af785f9e91 | |||
1ac5d68827 | |||
2c987625ae | |||
a77f567403 | |||
110c81f359 | |||
ef4b5c7e59 | |||
c69362442d | |||
6c8791ee32 | |||
274dc1e972 | |||
d9bd86050b | |||
076374ba4f | |||
e117b1ffd2 | |||
8d7fbb614b | |||
a31cfc521c | |||
55a1ce7adf | |||
9f3da659aa | |||
8f9aeaaa67 | |||
b9a5ce1c06 | |||
f67229efa3 | |||
f707f545aa | |||
62f4ea5f0f | |||
ecc3406ca6 | |||
e244b5180e | |||
f85d3d7857 | |||
b404d47b16 | |||
815d1ffa19 | |||
d1063c62b3 | |||
3a0b7355e5 | |||
3bdd4e249f | |||
2c1f55069f | |||
e72f741e78 | |||
f0bcfd0e78 | |||
82e06766b8 | |||
eea1600a38 | |||
8f8c390c75 | |||
23a96dca2d | |||
6f7df8a1fa | |||
92298e5271 | |||
27f0817000 | |||
4596fc0217 | |||
46de203f85 | |||
d752a8907b | |||
4fe369e188 | |||
d8930bbdc2 | |||
ad7be5087c | |||
a4405d7c6f | |||
88f7ddb27d | |||
98f5acebdb | |||
ff78149ec2 | |||
66b7870da7 | |||
82088a8489 | |||
ebcf762132 | |||
ed6b68babf | |||
2e09115c0c | |||
4a8d56a820 | |||
0a3dd872e3 | |||
3e690e0062 | |||
7f8d6c1066 | |||
c6d502f7f8 | |||
7aff3641a1 | |||
2194b5a5c3 | |||
8a35290686 | |||
e40519c32a | |||
b560189c0e | |||
59cfc8a729 | |||
72ed2e90d0 | |||
4e82a76998 | |||
51d5b433d0 | |||
cc0d0a9d1e | |||
82f26fe5f5 | |||
8de57c9887 | |||
ace4e4ffa5 | |||
1fa97903a3 | |||
7e61645b82 | |||
46b0ce9fc6 | |||
78750a7fec | |||
77d9975eb2 | |||
7eed4ee837 | |||
292b435495 | |||
5939c420ce | |||
a5cc9dbb53 | |||
2b810a4e57 | |||
2acf369664 | |||
860b79289f | |||
b519d41f42 | |||
faf184ad63 | |||
1e0f455855 | |||
ced30982df | |||
fed429b0cc | |||
9cb3107dda | |||
548a972c2a | |||
20dcc25eed | |||
620d1402fe | |||
36fb4f4fdb |
@ -12,8 +12,8 @@
|
||||
## IMPORTANT
|
||||
# If you change the `docker_image` version, also change the `cache_key` suffix and the version of
|
||||
# `com_github_bazelbuild_buildtools` in the `/WORKSPACE` file.
|
||||
var_1: &docker_image angular/ngcontainer:0.3.3
|
||||
var_2: &cache_key v2-angular-{{ .Branch }}-{{ checksum "yarn.lock" }}-0.3.3
|
||||
var_1: &docker_image angular/ngcontainer:0.6.0
|
||||
var_2: &cache_key v2-angular-{{ .Branch }}-{{ checksum "yarn.lock" }}-0.6.0
|
||||
|
||||
# Define common ENV vars
|
||||
var_3: &define_env_vars
|
||||
@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
# Check BUILD.bazel formatting before we have a node_modules directory
|
||||
# Then we don't need any exclude pattern to avoid checking those files
|
||||
- run: 'buildifier -mode=check $(find . -type f \( -name BUILD.bazel -or -name BUILD \)) ||
|
||||
- run: 'buildifier -mode=check $(find . -type f \( -name "*.bzl" -or -name BUILD.bazel -or -name BUILD \)) ||
|
||||
(echo "BUILD files not formatted. Please run ''yarn buildifier''" ; exit 1)'
|
||||
# Run the skylark linter to check our Bazel rules
|
||||
# deprecated-api is disabled because we use actions.new_file(genfiles_dir)
|
||||
@ -148,6 +148,39 @@ jobs:
|
||||
- run: bazel run @yarn//:yarn
|
||||
- run: bazel query --output=label //... | xargs bazel test --define=compile=local --build_tag_filters=ivy-local --test_tag_filters=-manual,ivy-local
|
||||
|
||||
# This job should only be run on PR builds, where `CIRCLE_PR_NUMBER` is defined.
|
||||
aio_preview:
|
||||
<<: *job_defaults
|
||||
environment:
|
||||
AIO_SNAPSHOT_ARTIFACT_PATH: &aio_preview_artifact_path 'aio/tmp/snapshot.tgz'
|
||||
steps:
|
||||
- checkout:
|
||||
<<: *post_checkout
|
||||
- restore_cache:
|
||||
key: *cache_key
|
||||
- run: yarn install --frozen-lockfile --non-interactive
|
||||
- run: ./aio/scripts/build-artifacts.sh $AIO_SNAPSHOT_ARTIFACT_PATH $CIRCLE_PR_NUMBER $CIRCLE_SHA1
|
||||
- store_artifacts:
|
||||
path: *aio_preview_artifact_path
|
||||
# The `destination` needs to be kept in synch with the value of
|
||||
# `AIO_ARTIFACT_PATH` in `aio/aio-builds-setup/Dockerfile`
|
||||
destination: aio/dist/aio-snapshot.tgz
|
||||
|
||||
# This job should only be run on PR builds, where `CIRCLE_PR_NUMBER` is defined.
|
||||
test_aio_preview:
|
||||
<<: *job_defaults
|
||||
steps:
|
||||
- checkout:
|
||||
<<: *post_checkout
|
||||
- restore_cache:
|
||||
key: *cache_key
|
||||
- run: yarn install --cwd aio --frozen-lockfile --non-interactive
|
||||
- run:
|
||||
name: Wait for preview and run tests
|
||||
command: |
|
||||
source "./scripts/ci/env.sh" print
|
||||
xvfb-run --auto-servernum node aio/scripts/test-preview.js $CIRCLE_PR_NUMBER $CIRCLE_SHA1 $AIO_MIN_PWA_SCORE
|
||||
|
||||
# This job exists only for backwards-compatibility with old scripts and tests
|
||||
# that rely on the pre-Bazel dist/packages-dist layout.
|
||||
# It duplicates some work with the job above: we build the bazel packages
|
||||
@ -234,7 +267,11 @@ jobs:
|
||||
<<: *post_checkout
|
||||
- restore_cache:
|
||||
key: *cache_key
|
||||
- run: xvfb-run --auto-servernum ./aio/scripts/test-production.sh
|
||||
- run:
|
||||
name: Run tests against the deployed apps
|
||||
command: |
|
||||
source "./scripts/ci/env.sh" print
|
||||
xvfb-run --auto-servernum ./aio/scripts/test-production.sh $AIO_MIN_PWA_SCORE
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
@ -245,6 +282,14 @@ workflows:
|
||||
- test_ivy_jit
|
||||
- test_ivy_aot
|
||||
- build-packages-dist
|
||||
- aio_preview:
|
||||
# Only run on PR builds. (There can be no previews for non-PR builds.)
|
||||
filters:
|
||||
branches:
|
||||
only: /pull\/\d+/
|
||||
- test_aio_preview:
|
||||
requires:
|
||||
- aio_preview
|
||||
- integration_test:
|
||||
requires:
|
||||
- build-packages-dist
|
||||
@ -273,3 +318,7 @@ workflows:
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
||||
notify:
|
||||
webhooks:
|
||||
- url: https://ngbuilds.io/circle-build
|
7
.github/angular-robot.yml
vendored
7
.github/angular-robot.yml
vendored
@ -3,11 +3,8 @@
|
||||
#options for the size plugin
|
||||
size:
|
||||
disabled: false
|
||||
maxSizeIncrease: 1000
|
||||
circleCiStatusName: "ci/circleci: build-packages-dist"
|
||||
status:
|
||||
disabled: false
|
||||
context: "ci/angular: size"
|
||||
maxSizeIncrease: 2000
|
||||
circleCiStatusName: "ci/circleci: test"
|
||||
|
||||
# options for the merge plugin
|
||||
merge:
|
||||
|
@ -24,7 +24,6 @@
|
||||
# petebacondarwin - Pete Bacon Darwin
|
||||
# pkozlowski-opensource - Pawel Kozlowski
|
||||
# robwormald - Rob Wormald
|
||||
# vicb - Victor Berchet
|
||||
# vikerman - Vikram Subramanian
|
||||
|
||||
|
||||
@ -125,7 +124,6 @@ groups:
|
||||
users:
|
||||
- alexeagle
|
||||
- mhevery
|
||||
- vicb
|
||||
- IgorMinar #fallback
|
||||
|
||||
core:
|
||||
@ -197,7 +195,6 @@ groups:
|
||||
- mhevery #primary
|
||||
- jasonaden
|
||||
- kara
|
||||
- vicb
|
||||
- IgorMinar
|
||||
- jenniferfell #docs only
|
||||
|
||||
@ -222,8 +219,7 @@ groups:
|
||||
- "aio/content/guide/i18n.md"
|
||||
- "aio/content/examples/i18n/*"
|
||||
users:
|
||||
- vicb #primary
|
||||
- alxhub
|
||||
- alxhub #primary
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
- jenniferfell #docs only
|
||||
@ -235,7 +231,6 @@ groups:
|
||||
- "aio/content/guide/aot-compiler.md"
|
||||
users:
|
||||
- alxhub #primary
|
||||
- vicb
|
||||
- mhevery
|
||||
- IgorMinar #fallback
|
||||
- jenniferfell #docs only
|
||||
@ -260,7 +255,6 @@ groups:
|
||||
users:
|
||||
- alexeagle
|
||||
- alxhub
|
||||
- vicb
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
|
||||
@ -273,7 +267,6 @@ groups:
|
||||
- "packages/common/http/*"
|
||||
users:
|
||||
- pkozlowski-opensource #primary
|
||||
- vicb
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
|
||||
@ -322,7 +315,6 @@ groups:
|
||||
users:
|
||||
- kyliau #primary
|
||||
# needs secondary
|
||||
- vicb
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
- jenniferfell #docs only
|
||||
@ -336,7 +328,6 @@ groups:
|
||||
- "aio/content/images/guide/router/*"
|
||||
users:
|
||||
- jasonaden #primary
|
||||
- vicb
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
- jenniferfell #docs only
|
||||
@ -379,10 +370,9 @@ groups:
|
||||
files:
|
||||
- "packages/platform-browser/*"
|
||||
users:
|
||||
- vicb #primary
|
||||
- mhevery #primary
|
||||
# needs secondary
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
|
||||
platform-server:
|
||||
conditions:
|
||||
@ -393,7 +383,6 @@ groups:
|
||||
users:
|
||||
- vikerman #primary
|
||||
- alxhub #secondary
|
||||
- vicb
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
- jenniferfell #docs only
|
||||
@ -403,10 +392,9 @@ groups:
|
||||
files:
|
||||
- "packages/platform-webworker/*"
|
||||
users:
|
||||
- vicb #primary
|
||||
- mhevery #primary
|
||||
# needs secondary
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
|
||||
service-worker:
|
||||
conditions:
|
||||
|
10
.travis.yml
10
.travis.yml
@ -2,7 +2,7 @@ language: node_js
|
||||
sudo: false
|
||||
dist: trusty
|
||||
node_js:
|
||||
- '8.9.1'
|
||||
- '10.9.0'
|
||||
|
||||
addons:
|
||||
# firefox: "38.0"
|
||||
@ -13,11 +13,7 @@ addons:
|
||||
packages:
|
||||
# needed to install g++ that is used by npms's native modules
|
||||
- g++-4.8
|
||||
# https://docs.travis-ci.com/user/jwt
|
||||
jwt:
|
||||
# SAUCE_ACCESS_KEY<=secret for NGBUILDS_IO_KEY to work around travis-ci/travis-ci#7223, unencrypted value in valentine as NGBUILDS_IO_KEY>
|
||||
# we alias NGBUILDS_IO_KEY to $SAUCE_ACCESS_KEY in env.sh and set the SAUCE_ACCESS_KEY there
|
||||
- secure: "L7nrZwkAtFtYrP2DykPXgZvEKjkv0J/TwQ/r2QGxFTaBq4VZn+2Dw0YS7uCxoMqYzDwH0aAOqxoutibVpk8Z/16nE3tNmU5RzltMd6Xmt3qU2f/JDQLMo6PSlBodnjOUsDHJgmtrcbjhqrx/znA237BkNUu6UZRT7mxhXIZpn0U="
|
||||
|
||||
branches:
|
||||
except:
|
||||
- g3
|
||||
@ -53,12 +49,14 @@ env:
|
||||
- CI_MODE=browserstack_optional
|
||||
- CI_MODE=aio_tools_test
|
||||
- CI_MODE=aio
|
||||
- CI_MODE=aio_local
|
||||
- CI_MODE=aio_e2e AIO_SHARD=0
|
||||
- CI_MODE=aio_e2e AIO_SHARD=1
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
allow_failures:
|
||||
- env: "CI_MODE=aio_local"
|
||||
- env: "CI_MODE=saucelabs_optional"
|
||||
- env: "CI_MODE=browserstack_optional"
|
||||
|
||||
|
78
CHANGELOG.md
78
CHANGELOG.md
@ -1,3 +1,81 @@
|
||||
<a name="6.1.10"></a>
|
||||
## [6.1.10](https://github.com/angular/angular/compare/6.1.9...6.1.10) (2018-10-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **platform-browser:** fix [#22155](https://github.com/angular/angular/issues/22155), destroy hammer manager when `HammerInstance.off()` is run ([#22156](https://github.com/angular/angular/issues/22156)) ([3b4d9dc](https://github.com/angular/angular/commit/3b4d9dc))
|
||||
* **upgrade:** properly destroy upgraded component elements and descendants ([#26209](https://github.com/angular/angular/issues/26209)) ([623adbb](https://github.com/angular/angular/commit/623adbb)), closes [#26208](https://github.com/angular/angular/issues/26208)
|
||||
|
||||
|
||||
|
||||
<a name="6.1.9"></a>
|
||||
## [6.1.9](https://github.com/angular/angular/compare/6.1.8...6.1.9) (2018-09-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **service-worker:** do not blow up when caches are unwritable ([#26042](https://github.com/angular/angular/issues/26042)) ([a169743](https://github.com/angular/angular/commit/a169743))
|
||||
|
||||
|
||||
|
||||
<a name="6.1.8"></a>
|
||||
## [6.1.8](https://github.com/angular/angular/compare/6.1.7...6.1.8) (2018-09-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **bazel:** allow compile_strategy to be (privately) imported ([#25080](https://github.com/angular/angular/issues/25080)) ([2d0e642](https://github.com/angular/angular/commit/2d0e642))
|
||||
* **bazel:** correct type concatenated to devmode_js ([#25467](https://github.com/angular/angular/issues/25467)) ([91dd160](https://github.com/angular/angular/commit/91dd160))
|
||||
* **bazel:** move bazel managed runtime deps for downstream usage ([#25690](https://github.com/angular/angular/issues/25690)) ([48d7f4e](https://github.com/angular/angular/commit/48d7f4e))
|
||||
* **bazel:** specify the package and lock files using the workspace ([#25694](https://github.com/angular/angular/issues/25694)) ([678b420](https://github.com/angular/angular/commit/678b420))
|
||||
* **compiler:** Fix look up of entryComponents in AOT Summaries ([#24892](https://github.com/angular/angular/issues/24892)) ([a31cfc5](https://github.com/angular/angular/commit/a31cfc5))
|
||||
* **router:** mount correct component if router outlet was not instantiated and if using a route reuse strategy ([#25313](https://github.com/angular/angular/issues/25313)) ([#25314](https://github.com/angular/angular/issues/25314)) ([e117b1f](https://github.com/angular/angular/commit/e117b1f))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **bazel:** add additional parameters to `ts_api_guardian_test` def ([#25694](https://github.com/angular/angular/issues/25694)) ([97ae7ae](https://github.com/angular/angular/commit/97ae7ae))
|
||||
* **ivy:** enable .ngfactory.js generation in g3 only ([#25392](https://github.com/angular/angular/issues/25392)) ([1c44b71](https://github.com/angular/angular/commit/1c44b71))
|
||||
|
||||
|
||||
|
||||
<a name="6.1.7"></a>
|
||||
## [6.1.7](https://github.com/angular/angular/compare/6.1.6...6.1.7) (2018-09-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **bazel:** protractor rule should include *.e2e-spec.js ([#25701](https://github.com/angular/angular/issues/25701)) ([ed6b68b](https://github.com/angular/angular/commit/ed6b68b))
|
||||
* **core:** size regression with closure compiler ([#25531](https://github.com/angular/angular/issues/25531)) ([ebcf762](https://github.com/angular/angular/commit/ebcf762))
|
||||
* **docs-infra:** show "suggest edits" only for /guide and /tutorial dirs ([#24378](https://github.com/angular/angular/issues/24378)) ([66b7870](https://github.com/angular/angular/commit/66b7870))
|
||||
* **upgrade:** trigger `$destroy` event on upgraded component element ([#25357](https://github.com/angular/angular/issues/25357)) ([82e0676](https://github.com/angular/angular/commit/82e0676)), closes [#25334](https://github.com/angular/angular/issues/25334)
|
||||
* **router:** warn if navigation triggered outside Angular zone ([#24959](https://github.com/angular/angular/issues/24959)) ([23a96dc](https://github.com/angular/angular/commit/23a96dc)), closes [#15770](https://github.com/angular/angular/issues/15770) [#15946](https://github.com/angular/angular/issues/15946) [#24728](https://github.com/angular/angular/issues/24728)
|
||||
|
||||
|
||||
|
||||
<a name="6.1.6"></a>
|
||||
## [6.1.6](https://github.com/angular/angular/compare/6.1.5...6.1.6) (2018-08-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **bazel:** Cache fileNameToModuleName lookups ([#25731](https://github.com/angular/angular/issues/25731)) ([3e690e0](https://github.com/angular/angular/commit/3e690e0))
|
||||
* **bazel:** only lookup amd module-name tags in .d.ts files ([#25710](https://github.com/angular/angular/issues/25710)) ([7aff364](https://github.com/angular/angular/commit/7aff364))
|
||||
|
||||
|
||||
Note: the 6.1.5 release on npm accidentally glitched-out midway, so we cut 6.1.6 instead. sorry! :-)
|
||||
|
||||
<a name="6.1.4"></a>
|
||||
## [6.1.4](https://github.com/angular/angular/compare/6.1.3...6.1.4) (2018-08-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **router:** default scroll position restoration to disabled ([#25586](https://github.com/angular/angular/issues/25586)) ([7e61645](https://github.com/angular/angular/commit/7e61645)), closes [#25145](https://github.com/angular/angular/issues/25145)
|
||||
|
||||
|
||||
|
||||
<a name="6.1.3"></a>
|
||||
## [6.1.3](https://github.com/angular/angular/compare/6.1.2...6.1.3) (2018-08-15)
|
||||
|
||||
|
@ -71,6 +71,8 @@ Before you submit your Pull Request (PR) consider the following guidelines:
|
||||
|
||||
1. Search [GitHub](https://github.com/angular/angular/pulls) for an open or closed PR
|
||||
that relates to your submission. You don't want to duplicate effort.
|
||||
1. Be sure that an issue describes the problem you're fixing, or documents the design for the feature you'd like to add.
|
||||
Discussing the design up front helps to ensure that we're ready to accept your work.
|
||||
1. Please sign our [Contributor License Agreement (CLA)](#cla) before sending PRs.
|
||||
We cannot accept code without this. Make sure you sign with the primary email address of the Git identity that has been granted access to the Angular repository.
|
||||
1. Fork the angular/angular repo.
|
||||
|
102
WORKSPACE
102
WORKSPACE
@ -3,59 +3,58 @@ workspace(name = "angular")
|
||||
#
|
||||
# Download Bazel toolchain dependencies as needed by build actions
|
||||
#
|
||||
|
||||
http_archive(
|
||||
name = "build_bazel_rules_nodejs",
|
||||
urls = ["https://github.com/bazelbuild/rules_nodejs/archive/0.11.4.zip"],
|
||||
strip_prefix = "rules_nodejs-0.11.4",
|
||||
sha256 = "c31c4ead696944a50fad2b3ee9dfbbeffe31a8dcca0b21b9bf5b3e6c6b069801",
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "bazel_skylib",
|
||||
urls = ["https://github.com/bazelbuild/bazel-skylib/archive/0.3.1.zip"],
|
||||
strip_prefix = "bazel-skylib-0.3.1",
|
||||
sha256 = "95518adafc9a2b656667bbf517a952e54ce7f350779d0dd95133db4eb5c27fb1",
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "io_bazel_rules_webtesting",
|
||||
url = "https://github.com/bazelbuild/rules_webtesting/archive/0.2.1.zip",
|
||||
strip_prefix = "rules_webtesting-0.2.1",
|
||||
sha256 = "7d490aadff9b5262e5251fa69427ab2ffd1548422467cb9f9e1d110e2c36f0fa",
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "build_bazel_rules_typescript",
|
||||
url = "https://github.com/bazelbuild/rules_typescript/archive/0.16.0.zip",
|
||||
strip_prefix = "rules_typescript-0.16.0",
|
||||
sha256 = "e65c5639a42e2f6d3f9d2bda62487d6b42734830dda45be1620c3e2b1115070c",
|
||||
url = "https://github.com/bazelbuild/rules_typescript/archive/0.17.0.zip",
|
||||
strip_prefix = "rules_typescript-0.17.0",
|
||||
sha256 = "1626ee2cc9770af6950bfc77dffa027f9aedf330fe2ea2ee7e504428927bd95d",
|
||||
)
|
||||
load("@build_bazel_rules_typescript//:package.bzl", "rules_typescript_dependencies")
|
||||
rules_typescript_dependencies()
|
||||
|
||||
http_archive(
|
||||
name = "bazel_toolchains",
|
||||
urls = [
|
||||
"https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/5124557861ebf4c0b67f98180bff1f8551e0b421.tar.gz",
|
||||
"https://github.com/bazelbuild/bazel-toolchains/archive/5124557861ebf4c0b67f98180bff1f8551e0b421.tar.gz",
|
||||
],
|
||||
strip_prefix = "bazel-toolchains-5124557861ebf4c0b67f98180bff1f8551e0b421",
|
||||
sha256 = "c3b08805602cd1d2b67ebe96407c1e8c6ed3d4ce55236ae2efe2f1948f38168d",
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "io_bazel_rules_go",
|
||||
url = "https://github.com/bazelbuild/rules_go/releases/download/0.10.3/rules_go-0.10.3.tar.gz",
|
||||
sha256 = "feba3278c13cde8d67e341a837f69a029f698d7a27ddbb2a202be7a10b22142a",
|
||||
name = "io_bazel_rules_sass",
|
||||
url = "https://github.com/bazelbuild/rules_sass/archive/1.11.0.zip",
|
||||
strip_prefix = "rules_sass-1.11.0",
|
||||
sha256 = "dbe9fb97d5a7833b2a733eebc78c9c1e3880f676ac8af16e58ccf2139cbcad03",
|
||||
)
|
||||
|
||||
# This commit matches the version of buildifier in angular/ngcontainer
|
||||
# If you change this, also check if it matches the version in the angular/ngcontainer
|
||||
# version in /.circleci/config.yml
|
||||
BAZEL_BUILDTOOLS_VERSION = "82b21607e00913b16fe1c51bec80232d9d6de31c"
|
||||
BAZEL_BUILDTOOLS_VERSION = "49a6c199e3fbf5d94534b2771868677d3f9c6de9"
|
||||
|
||||
http_archive(
|
||||
name = "com_github_bazelbuild_buildtools",
|
||||
url = "https://github.com/bazelbuild/buildtools/archive/%s.zip" % BAZEL_BUILDTOOLS_VERSION,
|
||||
strip_prefix = "buildtools-%s" % BAZEL_BUILDTOOLS_VERSION,
|
||||
sha256 = "edb24c2f9c55b10a820ec74db0564415c0cf553fa55e9fc709a6332fb6685eff",
|
||||
sha256 = "edf39af5fc257521e4af4c40829fffe8fba6d0ebff9f4dd69a6f8f1223ae047b",
|
||||
)
|
||||
|
||||
# Fetching the Bazel source code allows us to compile the Skylark linter
|
||||
http_archive(
|
||||
name = "io_bazel",
|
||||
url = "https://github.com/bazelbuild/bazel/archive/968f87900dce45a7af749a965b72dbac51b176b3.zip",
|
||||
strip_prefix = "bazel-968f87900dce45a7af749a965b72dbac51b176b3",
|
||||
sha256 = "e373d2ae24955c1254c495c9c421c009d88966565c35e4e8444c082cb1f0f48f",
|
||||
url = "https://github.com/bazelbuild/bazel/archive/0.17.1.zip",
|
||||
strip_prefix = "bazel-0.17.1",
|
||||
sha256 = "ace8cced3b21e64a8fdad68508e9b0644201ec848ad583651719841d567fc66d",
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "io_bazel_skydoc",
|
||||
# TODO: switch to upstream when https://github.com/bazelbuild/skydoc/pull/103 is merged
|
||||
url = "https://github.com/alexeagle/skydoc/archive/fe2e9f888d28e567fef62ec9d4a93c425526d701.zip",
|
||||
strip_prefix = "skydoc-fe2e9f888d28e567fef62ec9d4a93c425526d701",
|
||||
sha256 = "7bfb5545f59792a2745f2523b9eef363f9c3e7274791c030885e7069f8116016",
|
||||
)
|
||||
|
||||
# We have a source dependency on the Devkit repository, because it's built with
|
||||
@ -73,9 +72,9 @@ http_archive(
|
||||
|
||||
http_archive(
|
||||
name = "org_brotli",
|
||||
url = "https://github.com/google/brotli/archive/f9b8c02673c576a3e807edbf3a9328e9e7af6d7c.zip",
|
||||
strip_prefix = "brotli-f9b8c02673c576a3e807edbf3a9328e9e7af6d7c",
|
||||
sha256 = "8a517806d2b7c8505ba5c53934e7d7c70d341b68ffd268e9044d35b564a48828",
|
||||
url = "https://github.com/google/brotli/archive/v1.0.5.zip",
|
||||
strip_prefix = "brotli-1.0.5",
|
||||
sha256 = "774b893a0700b0692a76e2e5b7e7610dbbe330ffbe3fe864b4b52ca718061d5a",
|
||||
)
|
||||
|
||||
#
|
||||
@ -98,12 +97,18 @@ local_repository(
|
||||
# Load and install our dependencies downloaded above.
|
||||
#
|
||||
|
||||
load("@build_bazel_rules_nodejs//:defs.bzl", "check_bazel_version", "node_repositories", "yarn_install")
|
||||
load("@build_bazel_rules_nodejs//:defs.bzl", "check_bazel_version", "node_repositories")
|
||||
|
||||
check_bazel_version("0.15.0")
|
||||
check_bazel_version("0.17.0", """
|
||||
If you are on a Mac and using Homebrew, there is a breaking change to the installation in Bazel 0.16
|
||||
See https://blog.bazel.build/2018/08/22/bazel-homebrew.html
|
||||
|
||||
""")
|
||||
node_repositories(
|
||||
package_json = ["//:package.json"],
|
||||
preserve_symlinks = True,
|
||||
node_version = "10.9.0",
|
||||
yarn_version = "1.9.2",
|
||||
)
|
||||
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_rules_dependencies", "go_register_toolchains")
|
||||
@ -127,20 +132,11 @@ load("@angular//:index.bzl", "ng_setup_workspace")
|
||||
|
||||
ng_setup_workspace()
|
||||
|
||||
#
|
||||
# Ask Bazel to manage these toolchain dependencies for us.
|
||||
# Bazel will run `yarn install` when one of these toolchains is requested during
|
||||
# a build.
|
||||
#
|
||||
##################################
|
||||
# Skylark documentation generation
|
||||
|
||||
yarn_install(
|
||||
name = "ts-api-guardian_runtime_deps",
|
||||
package_json = "//tools/ts-api-guardian:package.json",
|
||||
yarn_lock = "//tools/ts-api-guardian:yarn.lock",
|
||||
)
|
||||
load("@io_bazel_rules_sass//sass:sass_repositories.bzl", "sass_repositories")
|
||||
sass_repositories()
|
||||
|
||||
yarn_install(
|
||||
name = "http-server_runtime_deps",
|
||||
package_json = "//tools/http-server:package.json",
|
||||
yarn_lock = "//tools/http-server:yarn.lock",
|
||||
)
|
||||
load("@io_bazel_skydoc//skylark:skylark.bzl", "skydoc_repositories")
|
||||
skydoc_repositories()
|
||||
|
@ -22,8 +22,8 @@ Here are the most important tasks you might need to use:
|
||||
* `yarn start` - run a development web server that watches the files; then builds the doc-viewer and reloads the page, as necessary.
|
||||
* `yarn serve-and-sync` - run both the `docs-watch` and `start` in the same console.
|
||||
* `yarn lint` - check that the doc-viewer code follows our style rules.
|
||||
* `yarn test` - run all the unit tests once.
|
||||
* `yarn test --watch` - watch all the source files, for the doc-viewer, and run all the unit tests when any change.
|
||||
* `yarn test` - watch all the source files, for the doc-viewer, and run all the unit tests when any change.
|
||||
* `yarn test --watch=false` - run all the unit tests once.
|
||||
* `yarn e2e` - run all the e2e tests for the doc-viewer.
|
||||
|
||||
* `yarn docs` - generate all the docs from the source files.
|
||||
@ -56,14 +56,9 @@ It's necessary to remove the temporary files, because otherwise they're displaye
|
||||
|
||||
## Using ServiceWorker locally
|
||||
|
||||
Since abb36e3cb, running `yarn start --prod` will no longer set up the ServiceWorker, which
|
||||
would require manually running `yarn sw-manifest` and `yarn sw-copy` (something that is not possible
|
||||
with webpack serving the files from memory).
|
||||
|
||||
If you want to test ServiceWorker locally, you can use `yarn build` and serve the files in `dist/`
|
||||
with `yarn http-server dist -p 4200`.
|
||||
|
||||
For more details see #16745.
|
||||
Running `yarn start` (even when explicitly targeting production mode) does not set up the
|
||||
ServiceWorker. If you want to test the ServiceWorker locally, you can use `yarn build` and then
|
||||
serve the files in `dist/` with `yarn http-server dist -p 4200`.
|
||||
|
||||
|
||||
## Guide to authoring
|
||||
|
@ -8,17 +8,24 @@ LABEL name="angular.io PR preview" \
|
||||
|
||||
VOLUME /aio-secrets
|
||||
VOLUME /var/www/aio-builds
|
||||
VOLUME /dockerbuild
|
||||
|
||||
EXPOSE 80 443
|
||||
|
||||
|
||||
# Build-time args and env vars
|
||||
# The AIO_ARTIFACT_PATH path needs to be kept in synch with the value of
|
||||
# `aio_preview->steps->store_artifacts->destination` property in `.circleci/config.yml`
|
||||
ARG AIO_ARTIFACT_PATH=aio/dist/aio-snapshot.tgz
|
||||
ARG TEST_AIO_ARTIFACT_PATH=$AIO_ARTIFACT_PATH
|
||||
ARG AIO_BUILDS_DIR=/var/www/aio-builds
|
||||
ARG TEST_AIO_BUILDS_DIR=/tmp/aio-builds
|
||||
ARG AIO_DOMAIN_NAME=ngbuilds.io
|
||||
ARG TEST_AIO_DOMAIN_NAME=$AIO_DOMAIN_NAME.localhost
|
||||
ARG AIO_GITHUB_ORGANIZATION=angular
|
||||
ARG TEST_AIO_GITHUB_ORGANIZATION=angular
|
||||
ARG TEST_AIO_GITHUB_ORGANIZATION=test-org
|
||||
ARG AIO_GITHUB_REPO=angular
|
||||
ARG TEST_AIO_GITHUB_REPO=test-repo
|
||||
ARG AIO_GITHUB_TEAM_SLUGS=team,aio-contributors
|
||||
ARG TEST_AIO_GITHUB_TEAM_SLUGS=team,aio-contributors
|
||||
ARG AIO_NGINX_HOSTNAME=$AIO_DOMAIN_NAME
|
||||
@ -27,33 +34,35 @@ ARG AIO_NGINX_PORT_HTTP=80
|
||||
ARG TEST_AIO_NGINX_PORT_HTTP=8080
|
||||
ARG AIO_NGINX_PORT_HTTPS=443
|
||||
ARG TEST_AIO_NGINX_PORT_HTTPS=4433
|
||||
ARG AIO_REPO_SLUG=angular/angular
|
||||
ARG TEST_AIO_REPO_SLUG=test-repo/test-slug
|
||||
ARG AIO_SIGNIFICANT_FILES_PATTERN='^(?:aio|packages)/(?!.*[._]spec\\.[jt]s$)'
|
||||
ARG TEST_AIO_SIGNIFICANT_FILES_PATTERN=$AIO_SIGNIFICANT_FILES_PATTERN
|
||||
ARG AIO_TRUSTED_PR_LABEL="aio: preview"
|
||||
ARG TEST_AIO_TRUSTED_PR_LABEL="aio: preview"
|
||||
ARG AIO_UPLOAD_HOSTNAME=upload.localhost
|
||||
ARG TEST_AIO_UPLOAD_HOSTNAME=upload.localhost
|
||||
ARG AIO_UPLOAD_MAX_SIZE=20971520
|
||||
ARG TEST_AIO_UPLOAD_MAX_SIZE=20971520
|
||||
ARG AIO_UPLOAD_PORT=3000
|
||||
ARG TEST_AIO_UPLOAD_PORT=3001
|
||||
ARG AIO_PREVIEW_SERVER_HOSTNAME=preview.localhost
|
||||
ARG TEST_AIO_PREVIEW_SERVER_HOSTNAME=preview.localhost
|
||||
ARG AIO_ARTIFACT_MAX_SIZE=20971520
|
||||
ARG TEST_AIO_ARTIFACT_MAX_SIZE=200
|
||||
ARG AIO_PREVIEW_SERVER_PORT=3000
|
||||
ARG TEST_AIO_PREVIEW_SERVER_PORT=3001
|
||||
|
||||
ENV AIO_BUILDS_DIR=$AIO_BUILDS_DIR TEST_AIO_BUILDS_DIR=$TEST_AIO_BUILDS_DIR \
|
||||
ENV AIO_ARTIFACT_PATH=$AIO_ARTIFACT_PATH TEST_AIO_ARTIFACT_PATH=$TEST_AIO_ARTIFACT_PATH \
|
||||
AIO_BUILDS_DIR=$AIO_BUILDS_DIR TEST_AIO_BUILDS_DIR=$TEST_AIO_BUILDS_DIR \
|
||||
AIO_DOMAIN_NAME=$AIO_DOMAIN_NAME TEST_AIO_DOMAIN_NAME=$TEST_AIO_DOMAIN_NAME \
|
||||
AIO_GITHUB_ORGANIZATION=$AIO_GITHUB_ORGANIZATION TEST_AIO_GITHUB_ORGANIZATION=$TEST_AIO_GITHUB_ORGANIZATION \
|
||||
AIO_GITHUB_REPO=$AIO_GITHUB_REPO TEST_AIO_GITHUB_REPO=$TEST_AIO_GITHUB_REPO \
|
||||
AIO_GITHUB_TEAM_SLUGS=$AIO_GITHUB_TEAM_SLUGS TEST_AIO_GITHUB_TEAM_SLUGS=$TEST_AIO_GITHUB_TEAM_SLUGS \
|
||||
AIO_LOCALCERTS_DIR=/etc/ssl/localcerts TEST_AIO_LOCALCERTS_DIR=/etc/ssl/localcerts-test \
|
||||
AIO_NGINX_HOSTNAME=$AIO_NGINX_HOSTNAME TEST_AIO_NGINX_HOSTNAME=$TEST_AIO_NGINX_HOSTNAME \
|
||||
AIO_NGINX_LOGS_DIR=/var/log/aio/nginx TEST_AIO_NGINX_LOGS_DIR=/var/log/aio/nginx-test \
|
||||
AIO_NGINX_PORT_HTTP=$AIO_NGINX_PORT_HTTP TEST_AIO_NGINX_PORT_HTTP=$TEST_AIO_NGINX_PORT_HTTP \
|
||||
AIO_NGINX_PORT_HTTPS=$AIO_NGINX_PORT_HTTPS TEST_AIO_NGINX_PORT_HTTPS=$TEST_AIO_NGINX_PORT_HTTPS \
|
||||
AIO_REPO_SLUG=$AIO_REPO_SLUG TEST_AIO_REPO_SLUG=$TEST_AIO_REPO_SLUG \
|
||||
AIO_SCRIPTS_JS_DIR=/usr/share/aio-scripts-js \
|
||||
AIO_SCRIPTS_SH_DIR=/usr/share/aio-scripts-sh \
|
||||
AIO_SIGNIFICANT_FILES_PATTERN=$AIO_SIGNIFICANT_FILES_PATTERN TEST_AIO_SIGNIFICANT_FILES_PATTERN=$TEST_AIO_SIGNIFICANT_FILES_PATTERN \
|
||||
AIO_TRUSTED_PR_LABEL=$AIO_TRUSTED_PR_LABEL TEST_AIO_TRUSTED_PR_LABEL=$TEST_AIO_TRUSTED_PR_LABEL \
|
||||
AIO_UPLOAD_HOSTNAME=$AIO_UPLOAD_HOSTNAME TEST_AIO_UPLOAD_HOSTNAME=$TEST_AIO_UPLOAD_HOSTNAME \
|
||||
AIO_UPLOAD_MAX_SIZE=$AIO_UPLOAD_MAX_SIZE TEST_AIO_UPLOAD_MAX_SIZE=$TEST_AIO_UPLOAD_MAX_SIZE \
|
||||
AIO_UPLOAD_PORT=$AIO_UPLOAD_PORT TEST_AIO_UPLOAD_PORT=$TEST_AIO_UPLOAD_PORT \
|
||||
AIO_PREVIEW_SERVER_HOSTNAME=$AIO_PREVIEW_SERVER_HOSTNAME TEST_AIO_PREVIEW_SERVER_HOSTNAME=$TEST_AIO_PREVIEW_SERVER_HOSTNAME \
|
||||
AIO_ARTIFACT_MAX_SIZE=$AIO_ARTIFACT_MAX_SIZE TEST_AIO_ARTIFACT_MAX_SIZE=$TEST_AIO_ARTIFACT_MAX_SIZE \
|
||||
AIO_PREVIEW_SERVER_PORT=$AIO_PREVIEW_SERVER_PORT TEST_AIO_PREVIEW_SERVER_PORT=$TEST_AIO_PREVIEW_SERVER_PORT \
|
||||
AIO_WWW_USER=www-data \
|
||||
NODE_ENV=production
|
||||
|
||||
@ -64,7 +73,7 @@ RUN mkdir /var/log/aio
|
||||
|
||||
# Add extra package sources
|
||||
RUN apt-get update -y && apt-get install -y curl
|
||||
RUN curl --silent --show-error --location https://deb.nodesource.com/setup_6.x | bash -
|
||||
RUN curl --silent --show-error --location https://deb.nodesource.com/setup_10.x | bash -
|
||||
RUN curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
|
||||
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
|
||||
RUN echo "deb http://ftp.debian.org/debian jessie-backports main" | tee /etc/apt/sources.list.d/backports.list
|
||||
@ -99,9 +108,9 @@ RUN printenv | grep AIO_ >> /etc/environment
|
||||
# Set up dnsmasq
|
||||
COPY dnsmasq/dnsmasq.conf /etc/
|
||||
RUN sed -i "s|{{\$AIO_NGINX_HOSTNAME}}|$AIO_NGINX_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_HOSTNAME}}|$AIO_UPLOAD_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$AIO_PREVIEW_SERVER_HOSTNAME}}|$AIO_PREVIEW_SERVER_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$TEST_AIO_NGINX_HOSTNAME}}|$TEST_AIO_NGINX_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$TEST_AIO_UPLOAD_HOSTNAME}}|$TEST_AIO_UPLOAD_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$TEST_AIO_PREVIEW_SERVER_HOSTNAME}}|$TEST_AIO_PREVIEW_SERVER_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
|
||||
|
||||
# Set up SSL/TLS certificates
|
||||
@ -125,9 +134,9 @@ RUN sed -i "s|{{\$AIO_LOCALCERTS_DIR}}|$AIO_LOCALCERTS_DIR|g" /etc/nginx/conf.d/
|
||||
RUN sed -i "s|{{\$AIO_NGINX_LOGS_DIR}}|$AIO_NGINX_LOGS_DIR|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_NGINX_PORT_HTTP}}|$AIO_NGINX_PORT_HTTP|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_NGINX_PORT_HTTPS}}|$AIO_NGINX_PORT_HTTPS|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_HOSTNAME}}|$AIO_UPLOAD_HOSTNAME|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_MAX_SIZE}}|$AIO_UPLOAD_MAX_SIZE|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_PORT}}|$AIO_UPLOAD_PORT|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_PREVIEW_SERVER_HOSTNAME}}|$AIO_PREVIEW_SERVER_HOSTNAME|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_ARTIFACT_MAX_SIZE}}|$AIO_ARTIFACT_MAX_SIZE|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_PREVIEW_SERVER_PORT}}|$AIO_PREVIEW_SERVER_PORT|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
|
||||
COPY nginx/aio-builds.conf /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_BUILDS_DIR}}|$TEST_AIO_BUILDS_DIR|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
@ -136,9 +145,9 @@ RUN sed -i "s|{{\$AIO_LOCALCERTS_DIR}}|$TEST_AIO_LOCALCERTS_DIR|g" /etc/nginx/co
|
||||
RUN sed -i "s|{{\$AIO_NGINX_LOGS_DIR}}|$TEST_AIO_NGINX_LOGS_DIR|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_NGINX_PORT_HTTP}}|$TEST_AIO_NGINX_PORT_HTTP|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_NGINX_PORT_HTTPS}}|$TEST_AIO_NGINX_PORT_HTTPS|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_HOSTNAME}}|$TEST_AIO_UPLOAD_HOSTNAME|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_MAX_SIZE}}|$TEST_AIO_UPLOAD_MAX_SIZE|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_PORT}}|$TEST_AIO_UPLOAD_PORT|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_PREVIEW_SERVER_HOSTNAME}}|$TEST_AIO_PREVIEW_SERVER_HOSTNAME|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_ARTIFACT_MAX_SIZE}}|$TEST_AIO_ARTIFACT_MAX_SIZE|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_PREVIEW_SERVER_PORT}}|$TEST_AIO_PREVIEW_SERVER_PORT|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
|
||||
|
||||
# Set up pm2
|
||||
|
@ -1,2 +1,2 @@
|
||||
# Periodically clean up builds that do not correspond to currently open PRs
|
||||
0 12 * * * root /usr/local/bin/aio-clean-up >> /var/log/cron.log 2>&1
|
||||
0 12 * * * /usr/local/bin/aio-clean-up >> /var/log/cron.log 2>&1
|
||||
|
@ -8,9 +8,9 @@ listen-address=127.0.0.1
|
||||
|
||||
# Force an IP address for these domains.
|
||||
address=/{{$AIO_NGINX_HOSTNAME}}/127.0.0.1
|
||||
address=/{{$AIO_UPLOAD_HOSTNAME}}/127.0.0.1
|
||||
address=/{{$AIO_PREVIEW_SERVER_HOSTNAME}}/127.0.0.1
|
||||
address=/{{$TEST_AIO_NGINX_HOSTNAME}}/127.0.0.1
|
||||
address=/{{$TEST_AIO_UPLOAD_HOSTNAME}}/127.0.0.1
|
||||
address=/{{$TEST_AIO_PREVIEW_SERVER_HOSTNAME}}/127.0.0.1
|
||||
|
||||
# Run as root (required from inside docker container).
|
||||
user=root
|
||||
|
@ -1,4 +1,4 @@
|
||||
/var/log/aio/upload-server-*.log {
|
||||
/var/log/aio/preview-server-*.log {
|
||||
compress
|
||||
copytruncate
|
||||
delaycompress
|
@ -36,6 +36,11 @@ server {
|
||||
access_log {{$AIO_NGINX_LOGS_DIR}}/access.log;
|
||||
error_log {{$AIO_NGINX_LOGS_DIR}}/error.log;
|
||||
|
||||
error_page 404 /404.html;
|
||||
location "=/404.html" {
|
||||
internal;
|
||||
}
|
||||
|
||||
location "~/[^/]+\.[^/]+$" {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
@ -66,24 +71,32 @@ server {
|
||||
return 200 '';
|
||||
}
|
||||
|
||||
# Upload builds
|
||||
location "~^/create-build/(?<pr>[1-9][0-9]*)/(?<sha>[0-9a-f]{40})/?$" {
|
||||
# Check PRs previewability
|
||||
location "~^/can-have-public-preview/\d+/?$" {
|
||||
if ($request_method != "GET") {
|
||||
add_header Allow "GET";
|
||||
return 405;
|
||||
}
|
||||
|
||||
proxy_pass_request_headers on;
|
||||
proxy_redirect off;
|
||||
proxy_method GET;
|
||||
proxy_pass http://{{$AIO_PREVIEW_SERVER_HOSTNAME}}:{{$AIO_PREVIEW_SERVER_PORT}}$request_uri;
|
||||
|
||||
resolver 127.0.0.1;
|
||||
}
|
||||
|
||||
# Notify about CircleCI builds
|
||||
location "~^/circle-build/?$" {
|
||||
if ($request_method != "POST") {
|
||||
add_header Allow "POST";
|
||||
return 405;
|
||||
}
|
||||
|
||||
client_body_temp_path /tmp/aio-create-builds;
|
||||
client_body_buffer_size 128K;
|
||||
client_max_body_size {{$AIO_UPLOAD_MAX_SIZE}};
|
||||
client_body_in_file_only on;
|
||||
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header X-FILE $request_body_file;
|
||||
proxy_set_body off;
|
||||
proxy_redirect off;
|
||||
proxy_method GET;
|
||||
proxy_pass http://{{$AIO_UPLOAD_HOSTNAME}}:{{$AIO_UPLOAD_PORT}}$request_uri;
|
||||
proxy_method POST;
|
||||
proxy_pass http://{{$AIO_PREVIEW_SERVER_HOSTNAME}}:{{$AIO_PREVIEW_SERVER_PORT}}$request_uri;
|
||||
|
||||
resolver 127.0.0.1;
|
||||
}
|
||||
@ -98,7 +111,7 @@ server {
|
||||
proxy_pass_request_headers on;
|
||||
proxy_redirect off;
|
||||
proxy_method POST;
|
||||
proxy_pass http://{{$AIO_UPLOAD_HOSTNAME}}:{{$AIO_UPLOAD_PORT}}$request_uri;
|
||||
proxy_pass http://{{$AIO_PREVIEW_SERVER_HOSTNAME}}:{{$AIO_PREVIEW_SERVER_PORT}}$request_uri;
|
||||
|
||||
resolver 127.0.0.1;
|
||||
}
|
||||
|
@ -3,29 +3,53 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {HIDDEN_DIR_PREFIX} from '../common/constants';
|
||||
import {GithubApi} from '../common/github-api';
|
||||
import {GithubPullRequests} from '../common/github-pull-requests';
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
import {assertNotMissingOrEmpty, getPrInfoFromDownloadPath, Logger} from '../common/utils';
|
||||
|
||||
// Classes
|
||||
export class BuildCleaner {
|
||||
|
||||
private logger = new Logger('BuildCleaner');
|
||||
|
||||
// Constructor
|
||||
constructor(protected buildsDir: string, protected repoSlug: string, protected githubToken: string) {
|
||||
constructor(protected buildsDir: string, protected githubOrg: string, protected githubRepo: string,
|
||||
protected githubToken: string, protected downloadsDir: string, protected artifactPath: string) {
|
||||
assertNotMissingOrEmpty('buildsDir', buildsDir);
|
||||
assertNotMissingOrEmpty('repoSlug', repoSlug);
|
||||
assertNotMissingOrEmpty('githubOrg', githubOrg);
|
||||
assertNotMissingOrEmpty('githubRepo', githubRepo);
|
||||
assertNotMissingOrEmpty('githubToken', githubToken);
|
||||
assertNotMissingOrEmpty('downloadsDir', downloadsDir);
|
||||
assertNotMissingOrEmpty('artifactPath', artifactPath);
|
||||
}
|
||||
|
||||
// Methods - Public
|
||||
public cleanUp(): Promise<void> {
|
||||
return Promise.all([
|
||||
this.getExistingBuildNumbers(),
|
||||
this.getOpenPrNumbers(),
|
||||
]).then(([existingBuilds, openPrs]) => this.removeUnnecessaryBuilds(existingBuilds, openPrs));
|
||||
public async cleanUp(): Promise<void> {
|
||||
try {
|
||||
this.logger.log('Cleaning up builds and downloads');
|
||||
const openPrs = await this.getOpenPrNumbers();
|
||||
this.logger.log(`Open pull requests: ${openPrs.length}`);
|
||||
await Promise.all([
|
||||
this.cleanBuilds(openPrs),
|
||||
this.cleanDownloads(openPrs),
|
||||
]);
|
||||
} catch (error) {
|
||||
this.logger.error('ERROR:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Methods - Protected
|
||||
protected getExistingBuildNumbers(): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
public async cleanBuilds(openPrs: number[]): Promise<void> {
|
||||
const existingBuilds = await this.getExistingBuildNumbers();
|
||||
await this.removeUnnecessaryBuilds(existingBuilds, openPrs);
|
||||
}
|
||||
|
||||
public async cleanDownloads(openPrs: number[]): Promise<void> {
|
||||
const existingDownloads = await this.getExistingDownloads();
|
||||
await this.removeUnnecessaryDownloads(existingDownloads, openPrs);
|
||||
}
|
||||
|
||||
public getExistingBuildNumbers(): Promise<number[]> {
|
||||
return new Promise<number[]>((resolve, reject) => {
|
||||
fs.readdir(this.buildsDir, (err, files) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
@ -41,31 +65,29 @@ export class BuildCleaner {
|
||||
});
|
||||
}
|
||||
|
||||
protected getOpenPrNumbers(): Promise<number[]> {
|
||||
const githubPullRequests = new GithubPullRequests(this.githubToken, this.repoSlug);
|
||||
|
||||
return githubPullRequests.
|
||||
fetchAll('open').
|
||||
then(prs => prs.map(pr => pr.number));
|
||||
public async getOpenPrNumbers(): Promise<number[]> {
|
||||
const api = new GithubApi(this.githubToken);
|
||||
const githubPullRequests = new GithubPullRequests(api, this.githubOrg, this.githubRepo);
|
||||
const prs = await githubPullRequests.fetchAll('open');
|
||||
return prs.map(pr => pr.number);
|
||||
}
|
||||
|
||||
protected removeDir(dir: string) {
|
||||
public removeDir(dir: string): void {
|
||||
try {
|
||||
if (shell.test('-d', dir)) {
|
||||
shell.chmod('-R', 'a+w', dir);
|
||||
shell.rm('-rf', dir);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`ERROR: Unable to remove '${dir}' due to:`, err);
|
||||
this.logger.error(`ERROR: Unable to remove '${dir}' due to:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
protected removeUnnecessaryBuilds(existingBuildNumbers: number[], openPrNumbers: number[]) {
|
||||
public removeUnnecessaryBuilds(existingBuildNumbers: number[], openPrNumbers: number[]): void {
|
||||
const toRemove = existingBuildNumbers.filter(num => !openPrNumbers.includes(num));
|
||||
|
||||
console.log(`Existing builds: ${existingBuildNumbers.length}`);
|
||||
console.log(`Open pull requests: ${openPrNumbers.length}`);
|
||||
console.log(`Removing ${toRemove.length} build(s): ${toRemove.join(', ')}`);
|
||||
this.logger.log(`Existing builds: ${existingBuildNumbers.length}`);
|
||||
this.logger.log(`Removing ${toRemove.length} build(s): ${toRemove.join(', ')}`);
|
||||
|
||||
// Try removing public dirs.
|
||||
toRemove.
|
||||
@ -77,4 +99,29 @@ export class BuildCleaner {
|
||||
map(num => path.join(this.buildsDir, HIDDEN_DIR_PREFIX + String(num))).
|
||||
forEach(dir => this.removeDir(dir));
|
||||
}
|
||||
|
||||
public getExistingDownloads(): Promise<string[]> {
|
||||
const artifactFile = path.basename(this.artifactPath);
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
fs.readdir(this.downloadsDir, (err, files) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
files = files.filter(file => file.endsWith(artifactFile));
|
||||
resolve(files);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public removeUnnecessaryDownloads(existingDownloads: string[], openPrNumbers: number[]): void {
|
||||
const toRemove = existingDownloads.filter(filePath => {
|
||||
const {pr} = getPrInfoFromDownloadPath(filePath);
|
||||
return !openPrNumbers.includes(pr);
|
||||
});
|
||||
|
||||
this.logger.log(`Existing downloads: ${existingDownloads.length}`);
|
||||
this.logger.log(`Removing ${toRemove.length} download(s): ${toRemove.join(', ')}`);
|
||||
|
||||
toRemove.forEach(filePath => shell.rm(path.join(this.downloadsDir, filePath)));
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +1,26 @@
|
||||
// Imports
|
||||
import {getEnvVar} from '../common/utils';
|
||||
import {AIO_DOWNLOADS_DIR} from '../common/constants';
|
||||
import {
|
||||
AIO_ARTIFACT_PATH,
|
||||
AIO_BUILDS_DIR,
|
||||
AIO_GITHUB_ORGANIZATION,
|
||||
AIO_GITHUB_REPO,
|
||||
AIO_GITHUB_TOKEN,
|
||||
} from '../common/env-variables';
|
||||
import {BuildCleaner} from './build-cleaner';
|
||||
|
||||
// Constants
|
||||
const AIO_BUILDS_DIR = getEnvVar('AIO_BUILDS_DIR');
|
||||
const AIO_GITHUB_TOKEN = getEnvVar('AIO_GITHUB_TOKEN', true);
|
||||
const AIO_REPO_SLUG = getEnvVar('AIO_REPO_SLUG');
|
||||
|
||||
// Run
|
||||
_main();
|
||||
|
||||
// Functions
|
||||
function _main() {
|
||||
console.log(`[${new Date()}] - Cleaning up builds...`);
|
||||
function _main(): void {
|
||||
const buildCleaner = new BuildCleaner(
|
||||
AIO_BUILDS_DIR,
|
||||
AIO_GITHUB_ORGANIZATION,
|
||||
AIO_GITHUB_REPO,
|
||||
AIO_GITHUB_TOKEN,
|
||||
AIO_DOWNLOADS_DIR,
|
||||
AIO_ARTIFACT_PATH);
|
||||
|
||||
const buildCleaner = new BuildCleaner(AIO_BUILDS_DIR, AIO_REPO_SLUG, AIO_GITHUB_TOKEN);
|
||||
|
||||
buildCleaner.cleanUp().catch(err => {
|
||||
console.error('ERROR:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
buildCleaner.cleanUp().catch(() => process.exit(1));
|
||||
}
|
||||
|
@ -0,0 +1,90 @@
|
||||
// Imports
|
||||
import fetch from 'node-fetch';
|
||||
import {assertNotMissingOrEmpty} from './utils';
|
||||
|
||||
// Constants
|
||||
const CIRCLE_CI_API_URL = 'https://circleci.com/api/v1.1/project/github';
|
||||
|
||||
// Interfaces - Types
|
||||
export interface ArtifactInfo {
|
||||
path: string;
|
||||
pretty_path: string;
|
||||
node_index: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type ArtifactResponse = ArtifactInfo[];
|
||||
|
||||
export interface BuildInfo {
|
||||
reponame: string;
|
||||
failed: boolean;
|
||||
branch: string;
|
||||
username: string;
|
||||
build_num: number;
|
||||
has_artifacts: boolean;
|
||||
outcome: string; // e.g. 'success'
|
||||
vcs_revision: string; // HEAD SHA
|
||||
// there are other fields but they are not used in this code
|
||||
}
|
||||
|
||||
/**
|
||||
* A Helper that can interact with the CircleCI API.
|
||||
*/
|
||||
export class CircleCiApi {
|
||||
|
||||
private tokenParam = `circle-token=${this.circleCiToken}`;
|
||||
|
||||
/**
|
||||
* Construct a helper that can interact with the CircleCI REST API.
|
||||
* @param githubOrg The Github organisation whose repos we want to access in CircleCI (e.g. angular).
|
||||
* @param githubRepo The Github repo whose builds we want to access in CircleCI (e.g. angular).
|
||||
* @param circleCiToken The CircleCI API access token (secret).
|
||||
*/
|
||||
constructor(
|
||||
private githubOrg: string,
|
||||
private githubRepo: string,
|
||||
private circleCiToken: string,
|
||||
) {
|
||||
assertNotMissingOrEmpty('githubOrg', githubOrg);
|
||||
assertNotMissingOrEmpty('githubRepo', githubRepo);
|
||||
assertNotMissingOrEmpty('circleCiToken', circleCiToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the info for a build from the CircleCI API
|
||||
* @param buildNumber The CircleCI build number that generated the artifact.
|
||||
* @returns A promise to the info about the build
|
||||
*/
|
||||
public async getBuildInfo(buildNumber: number): Promise<BuildInfo> {
|
||||
try {
|
||||
const baseUrl = `${CIRCLE_CI_API_URL}/${this.githubOrg}/${this.githubRepo}/${buildNumber}`;
|
||||
const response = await fetch(`${baseUrl}?${this.tokenParam}`);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`${baseUrl}: ${response.status} - ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`CircleCI build info request failed (${error.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the CircleCI API to get a URL for a specified artifact from a specified build.
|
||||
* @param artifactPath The path, within the build to the artifact.
|
||||
* @returns A promise to the URL that can be requested to download the actual build artifact file.
|
||||
*/
|
||||
public async getBuildArtifactUrl(buildNumber: number, artifactPath: string): Promise<string> {
|
||||
const baseUrl = `${CIRCLE_CI_API_URL}/${this.githubOrg}/${this.githubRepo}/${buildNumber}`;
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/artifacts?${this.tokenParam}`);
|
||||
const artifacts = await response.json() as ArtifactResponse;
|
||||
const artifact = artifacts.find(item => item.path === artifactPath);
|
||||
if (!artifact) {
|
||||
throw new Error(`Missing artifact (${artifactPath}) for CircleCI build: ${buildNumber}`);
|
||||
}
|
||||
return artifact.url;
|
||||
} catch (error) {
|
||||
throw new Error(`CircleCI artifact URL request failed (${error.message})`);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
// Constants
|
||||
export const AIO_DOWNLOADS_DIR = '/tmp/aio-downloads';
|
||||
export const HIDDEN_DIR_PREFIX = 'hidden--';
|
||||
export const SHORT_SHA_LEN = 7;
|
||||
|
@ -0,0 +1,19 @@
|
||||
import {getEnvVar} from './utils';
|
||||
|
||||
export const AIO_ARTIFACT_PATH = getEnvVar('AIO_ARTIFACT_PATH');
|
||||
export const AIO_BUILDS_DIR = getEnvVar('AIO_BUILDS_DIR');
|
||||
export const AIO_GITHUB_TOKEN = getEnvVar('AIO_GITHUB_TOKEN');
|
||||
export const AIO_CIRCLE_CI_TOKEN = getEnvVar('AIO_CIRCLE_CI_TOKEN');
|
||||
export const AIO_DOMAIN_NAME = getEnvVar('AIO_DOMAIN_NAME');
|
||||
export const AIO_GITHUB_ORGANIZATION = getEnvVar('AIO_GITHUB_ORGANIZATION');
|
||||
export const AIO_GITHUB_REPO = getEnvVar('AIO_GITHUB_REPO');
|
||||
export const AIO_GITHUB_TEAM_SLUGS = getEnvVar('AIO_GITHUB_TEAM_SLUGS');
|
||||
export const AIO_NGINX_HOSTNAME = getEnvVar('AIO_NGINX_HOSTNAME');
|
||||
export const AIO_NGINX_PORT_HTTP = +getEnvVar('AIO_NGINX_PORT_HTTP');
|
||||
export const AIO_NGINX_PORT_HTTPS = +getEnvVar('AIO_NGINX_PORT_HTTPS');
|
||||
export const AIO_SIGNIFICANT_FILES_PATTERN = getEnvVar('AIO_SIGNIFICANT_FILES_PATTERN');
|
||||
export const AIO_TRUSTED_PR_LABEL = getEnvVar('AIO_TRUSTED_PR_LABEL');
|
||||
export const AIO_PREVIEW_SERVER_HOSTNAME = getEnvVar('AIO_PREVIEW_SERVER_HOSTNAME');
|
||||
export const AIO_PREVIEW_SERVER_PORT = +getEnvVar('AIO_PREVIEW_SERVER_PORT');
|
||||
export const AIO_ARTIFACT_MAX_SIZE = +getEnvVar('AIO_ARTIFACT_MAX_SIZE');
|
||||
export const AIO_WWW_USER = getEnvVar('AIO_WWW_USER');
|
@ -28,29 +28,18 @@ export class GithubApi {
|
||||
}
|
||||
|
||||
// Methods - Public
|
||||
public get<T>(pathname: string, params?: RequestParamsOrNull): Promise<T> {
|
||||
public get<T = any>(pathname: string, params?: RequestParamsOrNull): Promise<T> {
|
||||
const path = this.buildPath(pathname, params);
|
||||
return this.request<T>('get', path);
|
||||
}
|
||||
|
||||
public post<T>(pathname: string, params?: RequestParamsOrNull, data?: any): Promise<T> {
|
||||
public post<T = any>(pathname: string, params?: RequestParamsOrNull, data?: any): Promise<T> {
|
||||
const path = this.buildPath(pathname, params);
|
||||
return this.request<T>('post', path, data);
|
||||
}
|
||||
|
||||
// Methods - Protected
|
||||
protected buildPath(pathname: string, params?: RequestParamsOrNull): string {
|
||||
if (params == null) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
const search = (params === null) ? '' : this.serializeSearchParams(params);
|
||||
const joiner = search && '?';
|
||||
|
||||
return `${pathname}${joiner}${search}`;
|
||||
}
|
||||
|
||||
protected getPaginated<T>(pathname: string, baseParams: RequestParams = {}, currentPage: number = 0): Promise<T[]> {
|
||||
// In GitHub API paginated requests, page numbering is 1-based. (https://developer.github.com/v3/#pagination)
|
||||
public getPaginated<T>(pathname: string, baseParams: RequestParams = {}, currentPage: number = 1): Promise<T[]> {
|
||||
const perPage = 100;
|
||||
const params = {
|
||||
...baseParams,
|
||||
@ -67,6 +56,18 @@ export class GithubApi {
|
||||
});
|
||||
}
|
||||
|
||||
// Methods - Protected
|
||||
protected buildPath(pathname: string, params?: RequestParamsOrNull): string {
|
||||
if (params == null) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
const search = (params === null) ? '' : this.serializeSearchParams(params);
|
||||
const joiner = search && '?';
|
||||
|
||||
return `${pathname}${joiner}${search}`;
|
||||
}
|
||||
|
||||
protected request<T>(method: string, path: string, data: any = null): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const options = {
|
||||
@ -81,7 +82,7 @@ export class GithubApi {
|
||||
reject(`Request to '${url}' failed (status: ${statusCode}): ${responseText}`);
|
||||
};
|
||||
const onSuccess = (responseText: string) => {
|
||||
try { resolve(JSON.parse(responseText)); } catch (err) { reject(err); }
|
||||
try { resolve(responseText && JSON.parse(responseText)); } catch (err) { reject(err); }
|
||||
};
|
||||
const onResponse = (res: IncomingMessage) => {
|
||||
const statusCode = res.statusCode || -1;
|
||||
|
@ -1,46 +1,79 @@
|
||||
// Imports
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
import {GithubApi} from './github-api';
|
||||
import {assert, assertNotMissingOrEmpty} from './utils';
|
||||
|
||||
// Interfaces - Types
|
||||
export interface PullRequest {
|
||||
number: number;
|
||||
user: {login: string};
|
||||
labels: {name: string}[];
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
sha: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type PullRequestState = 'all' | 'closed' | 'open';
|
||||
|
||||
// Classes
|
||||
export class GithubPullRequests extends GithubApi {
|
||||
// Constructor
|
||||
constructor(githubToken: string, protected repoSlug: string) {
|
||||
super(githubToken);
|
||||
assertNotMissingOrEmpty('repoSlug', repoSlug);
|
||||
/**
|
||||
* Access pull requests on GitHub.
|
||||
*/
|
||||
export class GithubPullRequests {
|
||||
public repoSlug: string;
|
||||
|
||||
/**
|
||||
* Create an instance of this helper
|
||||
* @param api An instance of the Github API helper.
|
||||
* @param githubOrg The organisation on GitHub whose repo we will interrogate.
|
||||
* @param githubRepo The repository on Github with whose PRs we will interact.
|
||||
*/
|
||||
constructor(private api: GithubApi, githubOrg: string, githubRepo: string) {
|
||||
assertNotMissingOrEmpty('githubOrg', githubOrg);
|
||||
assertNotMissingOrEmpty('githubRepo', githubRepo);
|
||||
this.repoSlug = `${githubOrg}/${githubRepo}`;
|
||||
}
|
||||
|
||||
// Methods - Public
|
||||
public addComment(pr: number, body: string): Promise<void> {
|
||||
if (!(pr > 0)) {
|
||||
throw new Error(`Invalid PR number: ${pr}`);
|
||||
} else if (!body) {
|
||||
throw new Error(`Invalid or empty comment body: ${body}`);
|
||||
}
|
||||
|
||||
return this.post<void>(`/repos/${this.repoSlug}/issues/${pr}/comments`, null, {body});
|
||||
/**
|
||||
* Post a comment on a PR.
|
||||
* @param pr The number of the PR on which to comment.
|
||||
* @param body The body of the comment to post.
|
||||
* @returns A promise that resolves when the comment has been posted.
|
||||
*/
|
||||
public addComment(pr: number, body: string): Promise<any> {
|
||||
assert(pr > 0, `Invalid PR number: ${pr}`);
|
||||
assert(!!body, `Invalid or empty comment body: ${body}`);
|
||||
return this.api.post<any>(`/repos/${this.repoSlug}/issues/${pr}/comments`, null, {body});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request information about a PR.
|
||||
* @param pr The number of the PR for which to request info.
|
||||
* @returns A promise that is resolves with information about the specified PR.
|
||||
*/
|
||||
public fetch(pr: number): Promise<PullRequest> {
|
||||
assert(pr > 0, `Invalid PR number: ${pr}`);
|
||||
// Using the `/issues/` URL, because the `/pulls/` one does not provide labels.
|
||||
return this.get<PullRequest>(`/repos/${this.repoSlug}/issues/${pr}`);
|
||||
return this.api.get<PullRequest>(`/repos/${this.repoSlug}/issues/${pr}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request information about all PRs that match the given state.
|
||||
* @param state Only retrieve PRs that have this state.
|
||||
* @returns A promise that is resolved with information about the requested PRs.
|
||||
*/
|
||||
public fetchAll(state: PullRequestState = 'all'): Promise<PullRequest[]> {
|
||||
console.log(`Fetching ${state} pull requests...`);
|
||||
|
||||
const pathname = `/repos/${this.repoSlug}/pulls`;
|
||||
const params = {state};
|
||||
|
||||
return this.getPaginated<PullRequest>(pathname, params);
|
||||
return this.api.getPaginated<PullRequest>(pathname, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of files for the given PR.
|
||||
* @param pr The number of the PR for which to request files.
|
||||
* @returns A promise that resolves to an array of file information
|
||||
*/
|
||||
public fetchFiles(pr: number): Promise<FileInfo[]> {
|
||||
assert(pr > 0, `Invalid PR number: ${pr}`);
|
||||
return this.api.getPaginated<FileInfo>(`/repos/${this.repoSlug}/pulls/${pr}/files`);
|
||||
}
|
||||
}
|
||||
|
@ -1,45 +1,72 @@
|
||||
// Imports
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
import {GithubApi} from './github-api';
|
||||
import {assertNotMissingOrEmpty} from './utils';
|
||||
|
||||
// Interfaces - Types
|
||||
interface Team {
|
||||
export interface Team {
|
||||
id: number;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface TeamMembership {
|
||||
export interface TeamMembership {
|
||||
state: string;
|
||||
}
|
||||
|
||||
// Classes
|
||||
export class GithubTeams extends GithubApi {
|
||||
// Constructor
|
||||
constructor(githubToken: string, protected organization: string) {
|
||||
super(githubToken);
|
||||
assertNotMissingOrEmpty('organization', organization);
|
||||
export class GithubTeams {
|
||||
/**
|
||||
* Create an instance of this helper
|
||||
* @param api An instance of the Github API helper.
|
||||
* @param githubOrg The organisation on GitHub whose repo we will interrogate.
|
||||
*/
|
||||
constructor(private api: GithubApi, protected githubOrg: string) {
|
||||
assertNotMissingOrEmpty('githubOrg', githubOrg);
|
||||
}
|
||||
|
||||
// Methods - Public
|
||||
/**
|
||||
* Request information about all the organisation's teams in GitHub.
|
||||
* @returns A promise that is resolved with information about the teams.
|
||||
*/
|
||||
public fetchAll(): Promise<Team[]> {
|
||||
return this.getPaginated<Team>(`/orgs/${this.organization}/teams`);
|
||||
return this.api.getPaginated<Team>(`/orgs/${this.githubOrg}/teams`);
|
||||
}
|
||||
|
||||
public isMemberById(username: string, teamIds: number[]): Promise<boolean> {
|
||||
const getMembership = (teamId: number) =>
|
||||
this.get<TeamMembership>(`/teams/${teamId}/memberships/${username}`).
|
||||
then(membership => membership.state === 'active').
|
||||
catch(() => false);
|
||||
const reduceFn = (promise: Promise<boolean>, teamId: number) =>
|
||||
promise.then(isMember => isMember || getMembership(teamId));
|
||||
/**
|
||||
* Check whether the specified username is a member of the specified team.
|
||||
* @param username The usernane to check for in the team.
|
||||
* @param teamIds The team to check for the username.
|
||||
* @returns a Promise that resolves to `true` if the username is a member of the team.
|
||||
*/
|
||||
public async isMemberById(username: string, teamIds: number[]): Promise<boolean> {
|
||||
|
||||
return teamIds.reduce(reduceFn, Promise.resolve(false));
|
||||
const getMembership = async (teamId: number) => {
|
||||
try {
|
||||
const {state} = await this.api.get<TeamMembership>(`/teams/${teamId}/memberships/${username}`);
|
||||
return state === 'active';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
if (await getMembership(teamId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public isMemberBySlug(username: string, teamSlugs: string[]): Promise<boolean> {
|
||||
return this.fetchAll().
|
||||
then(teams => teams.filter(team => teamSlugs.includes(team.slug)).map(team => team.id)).
|
||||
then(teamIds => this.isMemberById(username, teamIds)).
|
||||
catch(() => false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given username is a member of the teams specified by the team slugs.
|
||||
* @param username The username to check for in the teams.
|
||||
* @param teamSlugs A collection of slugs that represent the teams to check for the the username.
|
||||
* @returns a Promise that resolves to `true` if the usernane is a member of at least one of the specified teams.
|
||||
*/
|
||||
public async isMemberBySlug(username: string, teamSlugs: string[]): Promise<boolean> {
|
||||
try {
|
||||
const teams = await this.fetchAll();
|
||||
const teamIds = teams.filter(team => teamSlugs.includes(team.slug)).map(team => team.id);
|
||||
return await this.isMemberById(username, teamIds);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
export const runTests = (specFiles: string[], helpers?: string[]) => {
|
||||
// We can't use `import` here, because of the following mess:
|
||||
// - GitHub project `jasmine/jasmine` is `jasmine-core` on npm and its typings `@types/jasmine`.
|
||||
// - GitHub project `jasmine/jasmine-npm` is `jasmine` on npm and has no typings.
|
||||
//
|
||||
// Using `import...from 'jasmine'` here, would import from `@types/jasmine` (which refers to the
|
||||
// `jasmine-core` module and the `jasmine` module).
|
||||
// tslint:disable-next-line: no-var-requires variable-name
|
||||
const Jasmine = require('jasmine');
|
||||
// We can't use `import...from` here, because of the following mess:
|
||||
// - GitHub project `jasmine/jasmine` is `jasmine-core` on npm and its typings `@types/jasmine`.
|
||||
// - GitHub project `jasmine/jasmine-npm` is `jasmine` on npm and has no typings.
|
||||
//
|
||||
// Using `import...from 'jasmine'` here, would import from `@types/jasmine` (which refers to the
|
||||
// `jasmine-core` module and the `jasmine` module).
|
||||
import Jasmine = require('jasmine');
|
||||
import 'source-map-support/register';
|
||||
|
||||
export const runTests = (specFiles: string[]) => {
|
||||
const config = {
|
||||
helpers,
|
||||
random: true,
|
||||
spec_files: specFiles,
|
||||
stopSpecOnExpectationFailure: true,
|
||||
@ -16,7 +16,7 @@ export const runTests = (specFiles: string[], helpers?: string[]) => {
|
||||
|
||||
process.on('unhandledRejection', (reason: any) => console.log('Unhandled rejection:', reason));
|
||||
|
||||
const runner = new Jasmine();
|
||||
const runner = new Jasmine({});
|
||||
runner.loadConfig(config);
|
||||
runner.onComplete((passed: boolean) => process.exit(passed ? 0 : 1));
|
||||
runner.execute();
|
||||
|
@ -1,17 +1,98 @@
|
||||
// Functions
|
||||
export const assertNotMissingOrEmpty = (name: string, value: string | null | undefined) => {
|
||||
import {basename, resolve as resolvePath} from 'path';
|
||||
import {SHORT_SHA_LEN} from './constants';
|
||||
|
||||
/**
|
||||
* Shorten a SHA to make it more readable
|
||||
* @param sha The SHA to shorten.
|
||||
*/
|
||||
export function computeShortSha(sha: string) {
|
||||
return sha.substr(0, SHORT_SHA_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the path for a downloaded artifact file.
|
||||
* @param downloadsDir The directory where artifacts are downloaded
|
||||
* @param pr The PR associated with this artifact.
|
||||
* @param sha The SHA associated with the build for this artifact.
|
||||
* @param artifactPath The path to the artifact on CircleCI.
|
||||
* @returns The fully resolved location for the specified downloaded artifact.
|
||||
*/
|
||||
export function computeArtifactDownloadPath(downloadsDir: string, pr: number, sha: string, artifactPath: string) {
|
||||
return resolvePath(downloadsDir, `${pr}-${computeShortSha(sha)}-${basename(artifactPath)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the PR number and latest commit SHA from a downloaded file path.
|
||||
* @param downloadPath the path to the downloaded file.
|
||||
* @returns An object whose keys are the PR and SHA extracted from the file path.
|
||||
*/
|
||||
export function getPrInfoFromDownloadPath(downloadPath: string) {
|
||||
const file = basename(downloadPath);
|
||||
const [pr, sha] = file.split('-');
|
||||
return {pr: +pr, sha};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a value is true.
|
||||
* @param value The value to assert.
|
||||
* @param message The message if the value is not true.
|
||||
*/
|
||||
export function assert(value: boolean, message: string) {
|
||||
if (!value) {
|
||||
throw new Error(`Missing or empty required parameter '${name}'!`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a parameter is not equal to "".
|
||||
* @param name The name of the parameter.
|
||||
* @param value The value of the parameter.
|
||||
*/
|
||||
export const assertNotMissingOrEmpty = (name: string, value: string | null | undefined) => {
|
||||
assert(!!value, `Missing or empty required parameter '${name}'!`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an environment variable.
|
||||
* @param name The name of the environment variable.
|
||||
* @param isOptional True if the variable is optional.
|
||||
* @returns The value of the variable or "" if it is optional and falsy.
|
||||
* @throws `Error` if the variable is falsy and not optional.
|
||||
*/
|
||||
export const getEnvVar = (name: string, isOptional = false): string => {
|
||||
const value = process.env[name];
|
||||
|
||||
if (!isOptional && !value) {
|
||||
console.error(`ERROR: Missing required environment variable '${name}'!`);
|
||||
try {
|
||||
throw new Error(`ERROR: Missing required environment variable '${name}'!`);
|
||||
} catch (error) {
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return value || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* A basic logger implementation.
|
||||
* Delegates to `console`, but prepends each message with the current date and specified scope (i.e caller).
|
||||
*/
|
||||
export class Logger {
|
||||
private padding = ' '.repeat(20 - this.scope.length);
|
||||
|
||||
/**
|
||||
* Create a new `Logger` instance for the specified `scope`.
|
||||
* @param scope The logger's scope (added to all messages).
|
||||
*/
|
||||
constructor(private scope: string) {}
|
||||
|
||||
public error(...args: any[]) { this.callMethod('error', args); }
|
||||
public info(...args: any[]) { this.callMethod('info', args); }
|
||||
public log(...args: any[]) { this.callMethod('log', args); }
|
||||
public warn(...args: any[]) { this.callMethod('warn', args); }
|
||||
|
||||
private callMethod(method: 'error' | 'info' | 'log' | 'warn', args: any[]) {
|
||||
console[method](`[${new Date()}]`, `${this.scope}:${this.padding}`, ...args);
|
||||
}
|
||||
}
|
||||
|
@ -4,13 +4,16 @@ import {EventEmitter} from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {HIDDEN_DIR_PREFIX, SHORT_SHA_LEN} from '../common/constants';
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
import {HIDDEN_DIR_PREFIX} from '../common/constants';
|
||||
import {assertNotMissingOrEmpty, computeShortSha, Logger} from '../common/utils';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from './build-events';
|
||||
import {UploadError} from './upload-error';
|
||||
import {PreviewServerError} from './preview-error';
|
||||
|
||||
// Classes
|
||||
export class BuildCreator extends EventEmitter {
|
||||
|
||||
private logger = new Logger('BuildCreator');
|
||||
|
||||
// Constructor
|
||||
constructor(protected buildsDir: string) {
|
||||
super();
|
||||
@ -18,9 +21,9 @@ export class BuildCreator extends EventEmitter {
|
||||
}
|
||||
|
||||
// Methods - Public
|
||||
public create(pr: string, sha: string, archivePath: string, isPublic: boolean): Promise<void> {
|
||||
public create(pr: number, sha: string, archivePath: string, isPublic: boolean): Promise<void> {
|
||||
// Use only part of the SHA for more readable URLs.
|
||||
sha = sha.substr(0, SHORT_SHA_LEN);
|
||||
sha = computeShortSha(sha);
|
||||
|
||||
const {newPrDir: prDir} = this.getCandidatePrDirs(pr, isPublic);
|
||||
const shaDir = path.join(prDir, sha);
|
||||
@ -33,7 +36,7 @@ export class BuildCreator extends EventEmitter {
|
||||
then(([prDirExisted, shaDirExisted]) => {
|
||||
if (shaDirExisted) {
|
||||
const publicOrNot = isPublic ? 'public' : 'non-public';
|
||||
throw new UploadError(409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`);
|
||||
throw new PreviewServerError(409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`);
|
||||
}
|
||||
|
||||
dirToRemoveOnError = prDirExisted ? shaDir : prDir;
|
||||
@ -49,15 +52,15 @@ export class BuildCreator extends EventEmitter {
|
||||
shell.rm('-rf', dirToRemoveOnError);
|
||||
}
|
||||
|
||||
if (!(err instanceof UploadError)) {
|
||||
err = new UploadError(500, `Error while uploading to directory: ${shaDir}\n${err}`);
|
||||
if (!(err instanceof PreviewServerError)) {
|
||||
err = new PreviewServerError(500, `Error while creating preview at: ${shaDir}\n${err}`);
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
public updatePrVisibility(pr: string, makePublic: boolean): Promise<boolean> {
|
||||
public updatePrVisibility(pr: number, makePublic: boolean): Promise<boolean> {
|
||||
const {oldPrDir: otherVisPrDir, newPrDir: targetVisPrDir} = this.getCandidatePrDirs(pr, makePublic);
|
||||
|
||||
return Promise.
|
||||
@ -68,7 +71,8 @@ export class BuildCreator extends EventEmitter {
|
||||
return false;
|
||||
} else if (targetVisPrDirExisted) {
|
||||
// Error: Directories for both visibilities exist.
|
||||
throw new UploadError(409, `Request to move '${otherVisPrDir}' to existing directory '${targetVisPrDir}'.`);
|
||||
throw new PreviewServerError(409,
|
||||
`Request to move '${otherVisPrDir}' to existing directory '${targetVisPrDir}'.`);
|
||||
}
|
||||
|
||||
// Visibility change: Moving `otherVisPrDir` to `targetVisPrDir`.
|
||||
@ -79,8 +83,8 @@ export class BuildCreator extends EventEmitter {
|
||||
then(() => true);
|
||||
}).
|
||||
catch(err => {
|
||||
if (!(err instanceof UploadError)) {
|
||||
err = new UploadError(500, `Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\n${err}`);
|
||||
if (!(err instanceof PreviewServerError)) {
|
||||
err = new PreviewServerError(500, `Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\n${err}`);
|
||||
}
|
||||
|
||||
throw err;
|
||||
@ -102,7 +106,7 @@ export class BuildCreator extends EventEmitter {
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.warn(stderr);
|
||||
this.logger.warn(stderr);
|
||||
}
|
||||
|
||||
try {
|
||||
@ -116,9 +120,9 @@ export class BuildCreator extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
protected getCandidatePrDirs(pr: string, isPublic: boolean) {
|
||||
protected getCandidatePrDirs(pr: number, isPublic: boolean): {oldPrDir: string, newPrDir: string} {
|
||||
const hiddenPrDir = path.join(this.buildsDir, HIDDEN_DIR_PREFIX + pr);
|
||||
const publicPrDir = path.join(this.buildsDir, pr);
|
||||
const publicPrDir = path.join(this.buildsDir, `${pr}`);
|
||||
|
||||
const oldPrDir = isPublic ? hiddenPrDir : publicPrDir;
|
||||
const newPrDir = isPublic ? publicPrDir : hiddenPrDir;
|
@ -0,0 +1,83 @@
|
||||
import * as fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import {dirname} from 'path';
|
||||
import {mkdir} from 'shelljs';
|
||||
import {promisify} from 'util';
|
||||
import {CircleCiApi} from '../common/circle-ci-api';
|
||||
import {assert, assertNotMissingOrEmpty, computeArtifactDownloadPath, Logger} from '../common/utils';
|
||||
import {PreviewServerError} from './preview-error';
|
||||
|
||||
export interface GithubInfo {
|
||||
org: string;
|
||||
pr: number;
|
||||
repo: string;
|
||||
sha: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper that can get information about builds and download build artifacts.
|
||||
*/
|
||||
export class BuildRetriever {
|
||||
private logger = new Logger('BuildRetriever');
|
||||
constructor(private api: CircleCiApi, private downloadSizeLimit: number, private downloadDir: string) {
|
||||
assert(downloadSizeLimit > 0, 'Invalid parameter "downloadSizeLimit" should be a number greater than 0.');
|
||||
assertNotMissingOrEmpty('downloadDir', downloadDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub information about a build
|
||||
* @param buildNum The number of the build for which to retrieve the info.
|
||||
* @returns The Github org, repo, PR and latest SHA for the specified build.
|
||||
*/
|
||||
public async getGithubInfo(buildNum: number): Promise<GithubInfo> {
|
||||
const buildInfo = await this.api.getBuildInfo(buildNum);
|
||||
const githubInfo: GithubInfo = {
|
||||
org: buildInfo.username,
|
||||
pr: getPrFromBranch(buildInfo.branch),
|
||||
repo: buildInfo.reponame,
|
||||
sha: buildInfo.vcs_revision,
|
||||
success: !buildInfo.failed,
|
||||
};
|
||||
return githubInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the given URL for a build artifact and store it locally.
|
||||
* @param buildNum the number of the CircleCI build whose artifact we want to download.
|
||||
* @param pr the number of the PR that triggered the CircleCI build.
|
||||
* @param sha the commit in the PR that triggered the CircleCI build.
|
||||
* @param artifactPath the path on CircleCI where the artifact was stored.
|
||||
* @returns A promise to the file path where the downloaded file was stored.
|
||||
*/
|
||||
public async downloadBuildArtifact(buildNum: number, pr: number, sha: string, artifactPath: string): Promise<string> {
|
||||
try {
|
||||
const outPath = computeArtifactDownloadPath(this.downloadDir, pr, sha, artifactPath);
|
||||
const downloadExists = await new Promise(resolve => fs.exists(outPath, exists => resolve(exists)));
|
||||
if (!downloadExists) {
|
||||
const url = await this.api.getBuildArtifactUrl(buildNum, artifactPath);
|
||||
const response = await fetch(url, {size: this.downloadSizeLimit});
|
||||
if (response.status !== 200) {
|
||||
throw new PreviewServerError(response.status, `Error ${response.status} - ${response.statusText}`);
|
||||
}
|
||||
const buffer = await response.buffer();
|
||||
mkdir('-p', dirname(outPath));
|
||||
await promisify(fs.writeFile)(outPath, buffer);
|
||||
}
|
||||
return outPath;
|
||||
} catch (error) {
|
||||
this.logger.warn(error);
|
||||
const status = (error.type === 'max-size') ? 413 : 500;
|
||||
throw new PreviewServerError(status, `CircleCI artifact download failed (${error.message || error})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPrFromBranch(branch: string): number {
|
||||
// CircleCI only exposes PR numbers via the `branch` field :-(
|
||||
const match = /^pull\/(\d+)$/.exec(branch);
|
||||
if (!match) {
|
||||
throw new Error(`No PR found in branch field: ${branch}`);
|
||||
}
|
||||
return +match[1];
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import {GithubPullRequests, PullRequest} from '../common/github-pull-requests';
|
||||
import {GithubTeams} from '../common/github-teams';
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
|
||||
/**
|
||||
* A helper to verify whether builds are trusted.
|
||||
*/
|
||||
export class BuildVerifier {
|
||||
/**
|
||||
* Construct a new BuildVerifier instance.
|
||||
* @param prs A helper to access PR information.
|
||||
* @param teams A helper to access Github team information.
|
||||
* @param allowedTeamSlugs The teams that are trusted.
|
||||
* @param trustedPrLabel The github label that indicates that a PR is trusted.
|
||||
*/
|
||||
constructor(protected prs: GithubPullRequests, protected teams: GithubTeams,
|
||||
protected allowedTeamSlugs: string[], protected trustedPrLabel: string) {
|
||||
assertNotMissingOrEmpty('allowedTeamSlugs', allowedTeamSlugs && allowedTeamSlugs.join(''));
|
||||
assertNotMissingOrEmpty('trustedPrLabel', trustedPrLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a PR contains files that are significant to the build.
|
||||
* @param pr The number of the PR to check
|
||||
* @param significantFilePattern A regex that selects files that are significant.
|
||||
*/
|
||||
public async getSignificantFilesChanged(pr: number, significantFilePattern: RegExp): Promise<boolean> {
|
||||
const files = await this.prs.fetchFiles(pr);
|
||||
return files.some(file => significantFilePattern.test(file.filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a PR is trusted.
|
||||
* @param pr The number of the PR to check.
|
||||
* @returns true if the PR is trusted.
|
||||
*/
|
||||
public async getPrIsTrusted(pr: number): Promise<boolean> {
|
||||
const prInfo = await this.prs.fetch(pr);
|
||||
return this.hasLabel(prInfo, this.trustedPrLabel) ||
|
||||
(await this.teams.isMemberBySlug(prInfo.user.login, this.allowedTeamSlugs));
|
||||
}
|
||||
|
||||
protected hasLabel(prInfo: PullRequest, label: string): boolean {
|
||||
return prInfo.labels.some(labelObj => labelObj.name === label);
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
// Imports
|
||||
import {AIO_DOWNLOADS_DIR} from '../common/constants';
|
||||
import {
|
||||
AIO_ARTIFACT_MAX_SIZE,
|
||||
AIO_ARTIFACT_PATH,
|
||||
AIO_BUILDS_DIR,
|
||||
AIO_CIRCLE_CI_TOKEN,
|
||||
AIO_DOMAIN_NAME,
|
||||
AIO_GITHUB_ORGANIZATION,
|
||||
AIO_GITHUB_REPO,
|
||||
AIO_GITHUB_TEAM_SLUGS,
|
||||
AIO_GITHUB_TOKEN,
|
||||
AIO_PREVIEW_SERVER_HOSTNAME,
|
||||
AIO_PREVIEW_SERVER_PORT,
|
||||
AIO_SIGNIFICANT_FILES_PATTERN,
|
||||
AIO_TRUSTED_PR_LABEL,
|
||||
} from '../common/env-variables';
|
||||
import {PreviewServerFactory} from './preview-server-factory';
|
||||
|
||||
// Run
|
||||
_main();
|
||||
|
||||
// Functions
|
||||
function _main(): void {
|
||||
PreviewServerFactory
|
||||
.create({
|
||||
buildArtifactPath: AIO_ARTIFACT_PATH,
|
||||
buildsDir: AIO_BUILDS_DIR,
|
||||
circleCiToken: AIO_CIRCLE_CI_TOKEN,
|
||||
domainName: AIO_DOMAIN_NAME,
|
||||
downloadSizeLimit: AIO_ARTIFACT_MAX_SIZE,
|
||||
downloadsDir: AIO_DOWNLOADS_DIR,
|
||||
githubOrg: AIO_GITHUB_ORGANIZATION,
|
||||
githubRepo: AIO_GITHUB_REPO,
|
||||
githubTeamSlugs: AIO_GITHUB_TEAM_SLUGS.split(','),
|
||||
githubToken: AIO_GITHUB_TOKEN,
|
||||
significantFilesPattern: AIO_SIGNIFICANT_FILES_PATTERN,
|
||||
trustedPrLabel: AIO_TRUSTED_PR_LABEL,
|
||||
})
|
||||
.listen(AIO_PREVIEW_SERVER_PORT, AIO_PREVIEW_SERVER_HOSTNAME);
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
// Classes
|
||||
export class UploadError extends Error {
|
||||
export class PreviewServerError extends Error {
|
||||
// Constructor
|
||||
constructor(public status: number = 500, message?: string) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, UploadError.prototype);
|
||||
Object.setPrototypeOf(this, PreviewServerError.prototype);
|
||||
}
|
||||
}
|
@ -0,0 +1,210 @@
|
||||
// Imports
|
||||
import * as bodyParser from 'body-parser';
|
||||
import * as express from 'express';
|
||||
import * as http from 'http';
|
||||
import {AddressInfo} from 'net';
|
||||
import {CircleCiApi} from '../common/circle-ci-api';
|
||||
import {GithubApi} from '../common/github-api';
|
||||
import {GithubPullRequests} from '../common/github-pull-requests';
|
||||
import {GithubTeams} from '../common/github-teams';
|
||||
import {assert, assertNotMissingOrEmpty, Logger} from '../common/utils';
|
||||
import {BuildCreator} from './build-creator';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from './build-events';
|
||||
import {BuildRetriever} from './build-retriever';
|
||||
import {BuildVerifier} from './build-verifier';
|
||||
import {respondWithError, throwRequestError} from './utils';
|
||||
|
||||
const AIO_PREVIEW_JOB = 'aio_preview';
|
||||
|
||||
// Interfaces - Types
|
||||
export interface PreviewServerConfig {
|
||||
downloadsDir: string;
|
||||
downloadSizeLimit: number;
|
||||
buildArtifactPath: string;
|
||||
buildsDir: string;
|
||||
domainName: string;
|
||||
githubOrg: string;
|
||||
githubRepo: string;
|
||||
githubTeamSlugs: string[];
|
||||
circleCiToken: string;
|
||||
githubToken: string;
|
||||
significantFilesPattern: string;
|
||||
trustedPrLabel: string;
|
||||
}
|
||||
|
||||
const logger = new Logger('PreviewServer');
|
||||
|
||||
// Classes
|
||||
export class PreviewServerFactory {
|
||||
// Methods - Public
|
||||
public static create(cfg: PreviewServerConfig): http.Server {
|
||||
assertNotMissingOrEmpty('domainName', cfg.domainName);
|
||||
|
||||
const circleCiApi = new CircleCiApi(cfg.githubOrg, cfg.githubRepo, cfg.circleCiToken);
|
||||
const githubApi = new GithubApi(cfg.githubToken);
|
||||
const prs = new GithubPullRequests(githubApi, cfg.githubOrg, cfg.githubRepo);
|
||||
const teams = new GithubTeams(githubApi, cfg.githubOrg);
|
||||
|
||||
const buildRetriever = new BuildRetriever(circleCiApi, cfg.downloadSizeLimit, cfg.downloadsDir);
|
||||
const buildVerifier = new BuildVerifier(prs, teams, cfg.githubTeamSlugs, cfg.trustedPrLabel);
|
||||
const buildCreator = PreviewServerFactory.createBuildCreator(prs, cfg.buildsDir, cfg.domainName);
|
||||
|
||||
const middleware = PreviewServerFactory.createMiddleware(buildRetriever, buildVerifier, buildCreator, cfg);
|
||||
const httpServer = http.createServer(middleware as any);
|
||||
|
||||
httpServer.on('listening', () => {
|
||||
const info = httpServer.address() as AddressInfo;
|
||||
logger.info(`Up and running (and listening on ${info.address}:${info.port})...`);
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
public static createMiddleware(buildRetriever: BuildRetriever, buildVerifier: BuildVerifier,
|
||||
buildCreator: BuildCreator, cfg: PreviewServerConfig): express.Express {
|
||||
const middleware = express();
|
||||
const jsonParser = bodyParser.json();
|
||||
const significantFilesRe = new RegExp(cfg.significantFilesPattern);
|
||||
|
||||
// RESPOND TO IS-ALIVE PING
|
||||
middleware.get(/^\/health-check\/?$/, (_req, res) => res.sendStatus(200));
|
||||
|
||||
// RESPOND TO CAN-HAVE-PUBLIC-PREVIEW CHECK
|
||||
const canHavePublicPreviewRe = /^\/can-have-public-preview\/(\d+)\/?$/;
|
||||
middleware.get(canHavePublicPreviewRe, async (req, res) => {
|
||||
try {
|
||||
const pr = +canHavePublicPreviewRe.exec(req.url)![1];
|
||||
|
||||
if (!await buildVerifier.getSignificantFilesChanged(pr, significantFilesRe)) {
|
||||
// Cannot have preview: PR did not touch relevant files: `aio/` or `packages/` (except for spec files).
|
||||
res.send({canHavePublicPreview: false, reason: 'No significant files touched.'});
|
||||
logger.log(`PR:${pr} - Cannot have a public preview, because it did not touch any significant files.`);
|
||||
} else if (!await buildVerifier.getPrIsTrusted(pr)) {
|
||||
// Cannot have preview: PR not automatically verifiable as "trusted".
|
||||
res.send({canHavePublicPreview: false, reason: 'Not automatically verifiable as "trusted".'});
|
||||
logger.log(`PR:${pr} - Cannot have a public preview, because not automatically verifiable as "trusted".`);
|
||||
} else {
|
||||
// Can have preview.
|
||||
res.send({canHavePublicPreview: true, reason: null});
|
||||
logger.log(`PR:${pr} - Can have a public preview.`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Previewability check error', err);
|
||||
respondWithError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// CIRCLE_CI BUILD COMPLETE WEBHOOK
|
||||
middleware.post(/^\/circle-build\/?$/, jsonParser, async (req, res) => {
|
||||
try {
|
||||
if (!(
|
||||
req.is('json') &&
|
||||
req.body &&
|
||||
req.body.payload &&
|
||||
req.body.payload.build_num > 0 &&
|
||||
req.body.payload.build_parameters &&
|
||||
req.body.payload.build_parameters.CIRCLE_JOB
|
||||
)) {
|
||||
throwRequestError(400, `Incorrect body content. Expected JSON`, req);
|
||||
}
|
||||
|
||||
const job = req.body.payload.build_parameters.CIRCLE_JOB;
|
||||
const buildNum = req.body.payload.build_num;
|
||||
|
||||
logger.log(`Build:${buildNum}, Job:${job} - processing web-hook trigger`);
|
||||
|
||||
if (job !== AIO_PREVIEW_JOB) {
|
||||
res.sendStatus(204);
|
||||
logger.log(`Build:${buildNum}, Job:${job} -`,
|
||||
`Skipping preview processing because this is not the "${AIO_PREVIEW_JOB}" job.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { pr, sha, org, repo, success } = await buildRetriever.getGithubInfo(buildNum);
|
||||
|
||||
if (!success) {
|
||||
res.sendStatus(204);
|
||||
logger.log(`PR:${pr}, Build:${buildNum} - Skipping preview processing because this build did not succeed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(cfg.githubOrg === org,
|
||||
`Invalid webhook: expected "githubOrg" property to equal "${cfg.githubOrg}" but got "${org}".`);
|
||||
assert(cfg.githubRepo === repo,
|
||||
`Invalid webhook: expected "githubRepo" property to equal "${cfg.githubRepo}" but got "${repo}".`);
|
||||
|
||||
// Do not deploy unless this PR has touched relevant files: `aio/` or `packages/` (except for spec files)
|
||||
if (!await buildVerifier.getSignificantFilesChanged(pr, significantFilesRe)) {
|
||||
res.sendStatus(204);
|
||||
logger.log(`PR:${pr}, Build:${buildNum} - ` +
|
||||
`Skipping preview processing because this PR did not touch any significant files.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactPath = await buildRetriever.downloadBuildArtifact(buildNum, pr, sha, cfg.buildArtifactPath);
|
||||
const isPublic = await buildVerifier.getPrIsTrusted(pr);
|
||||
await buildCreator.create(pr, sha, artifactPath, isPublic);
|
||||
res.sendStatus(isPublic ? 201 : 202);
|
||||
} catch (err) {
|
||||
logger.error('CircleCI webhook error', err);
|
||||
respondWithError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// GITHUB PR UPDATED WEBHOOK
|
||||
middleware.post(/^\/pr-updated\/?$/, jsonParser, async (req, res) => {
|
||||
const { action, number: prNo }: { action?: string, number?: number } = req.body;
|
||||
const visMayHaveChanged = !action || (action === 'labeled') || (action === 'unlabeled');
|
||||
|
||||
try {
|
||||
if (!visMayHaveChanged) {
|
||||
res.sendStatus(200);
|
||||
} else if (!prNo) {
|
||||
throwRequestError(400, `Missing or empty 'number' field`, req);
|
||||
} else {
|
||||
const isPublic = await buildVerifier.getPrIsTrusted(prNo);
|
||||
await buildCreator.updatePrVisibility(prNo, isPublic);
|
||||
res.sendStatus(200);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('PR update hook error', err);
|
||||
respondWithError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ALL OTHER REQUESTS
|
||||
middleware.all('*', req => throwRequestError(404, 'Unknown resource', req));
|
||||
middleware.use((err: any, _req: any, res: express.Response, _next: any) => {
|
||||
const statusText = http.STATUS_CODES[err.status] || '???';
|
||||
logger.error(`Preview server error: ${err.status} - ${statusText}:`, err.message);
|
||||
respondWithError(res, err);
|
||||
});
|
||||
|
||||
return middleware;
|
||||
}
|
||||
|
||||
public static createBuildCreator(prs: GithubPullRequests, buildsDir: string, domainName: string): BuildCreator {
|
||||
const buildCreator = new BuildCreator(buildsDir);
|
||||
const postPreviewsComment = (pr: number, shas: string[]) => {
|
||||
const body = shas.
|
||||
map(sha => `You can preview ${sha} at https://pr${pr}-${sha}.${domainName}/.`).
|
||||
join('\n');
|
||||
|
||||
return prs.addComment(pr, body);
|
||||
};
|
||||
|
||||
buildCreator.on(CreatedBuildEvent.type, ({pr, sha, isPublic}: CreatedBuildEvent) => {
|
||||
if (isPublic) {
|
||||
postPreviewsComment(pr, [sha]);
|
||||
}
|
||||
});
|
||||
|
||||
buildCreator.on(ChangedPrVisibilityEvent.type, ({pr, shas, isPublic}: ChangedPrVisibilityEvent) => {
|
||||
if (isPublic && shas.length) {
|
||||
postPreviewsComment(pr, shas);
|
||||
}
|
||||
});
|
||||
|
||||
return buildCreator;
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import * as express from 'express';
|
||||
import {promisify} from 'util';
|
||||
import {PreviewServerError} from './preview-error';
|
||||
|
||||
/**
|
||||
* Update the response to report that an error has occurred.
|
||||
* @param res The response to configure as an error.
|
||||
* @param err The error that needs to be reported.
|
||||
*/
|
||||
export async function respondWithError(res: express.Response, err: any): Promise<void> {
|
||||
if (!(err instanceof PreviewServerError)) {
|
||||
err = new PreviewServerError(500, String((err && err.message) || err));
|
||||
}
|
||||
|
||||
res.status(err.status);
|
||||
await promisify(res.end.bind(res))(err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an exception that describes the given error information.
|
||||
* @param status The HTTP status code include in the error.
|
||||
* @param error The error message to include in the error.
|
||||
* @param req The request that triggered this error.
|
||||
*/
|
||||
export function throwRequestError(status: number, error: string, req: express.Request): never {
|
||||
const message = `${error} in request: ${req.method} ${req.originalUrl}` +
|
||||
(!req.body ? '' : ` ${JSON.stringify(req.body)}`);
|
||||
throw new PreviewServerError(status, message);
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
// Imports
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import {GithubPullRequests, PullRequest} from '../common/github-pull-requests';
|
||||
import {GithubTeams} from '../common/github-teams';
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
import {UploadError} from './upload-error';
|
||||
|
||||
// Interfaces - Types
|
||||
interface JwtPayload {
|
||||
slug: string;
|
||||
'pull-request': number;
|
||||
}
|
||||
|
||||
// Enums
|
||||
export enum BUILD_VERIFICATION_STATUS {
|
||||
verifiedAndTrusted,
|
||||
verifiedNotTrusted,
|
||||
}
|
||||
|
||||
// Classes
|
||||
export class BuildVerifier {
|
||||
// Properties - Protected
|
||||
protected githubPullRequests: GithubPullRequests;
|
||||
protected githubTeams: GithubTeams;
|
||||
|
||||
// Constructor
|
||||
constructor(protected secret: string, githubToken: string, protected repoSlug: string, organization: string,
|
||||
protected allowedTeamSlugs: string[], protected trustedPrLabel: string) {
|
||||
assertNotMissingOrEmpty('secret', secret);
|
||||
assertNotMissingOrEmpty('githubToken', githubToken);
|
||||
assertNotMissingOrEmpty('repoSlug', repoSlug);
|
||||
assertNotMissingOrEmpty('organization', organization);
|
||||
assertNotMissingOrEmpty('allowedTeamSlugs', allowedTeamSlugs && allowedTeamSlugs.join(''));
|
||||
assertNotMissingOrEmpty('trustedPrLabel', trustedPrLabel);
|
||||
|
||||
this.githubPullRequests = new GithubPullRequests(githubToken, repoSlug);
|
||||
this.githubTeams = new GithubTeams(githubToken, organization);
|
||||
}
|
||||
|
||||
// Methods - Public
|
||||
public getPrIsTrusted(pr: number): Promise<boolean> {
|
||||
return Promise.resolve().
|
||||
then(() => this.githubPullRequests.fetch(pr)).
|
||||
then(prInfo => this.hasLabel(prInfo, this.trustedPrLabel) ||
|
||||
this.githubTeams.isMemberBySlug(prInfo.user.login, this.allowedTeamSlugs));
|
||||
}
|
||||
|
||||
public verify(expectedPr: number, authHeader: string): Promise<BUILD_VERIFICATION_STATUS> {
|
||||
return Promise.resolve().
|
||||
then(() => this.extractJwtString(authHeader)).
|
||||
then(jwtString => this.verifyJwt(expectedPr, jwtString)).
|
||||
then(jwtPayload => this.verifyPr(jwtPayload['pull-request'])).
|
||||
catch(err => { throw new UploadError(403, `Error while verifying upload for PR ${expectedPr}: ${err}`); });
|
||||
}
|
||||
|
||||
// Methods - Protected
|
||||
protected extractJwtString(input: string): string {
|
||||
return input.replace(/^token +/i, '');
|
||||
}
|
||||
|
||||
protected hasLabel(prInfo: PullRequest, label: string) {
|
||||
return prInfo.labels.some(labelObj => labelObj.name === label);
|
||||
}
|
||||
|
||||
protected verifyJwt(expectedPr: number, token: string): Promise<JwtPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(token, this.secret, {issuer: 'Travis CI, GmbH'}, (err, payload: JwtPayload) => {
|
||||
if (err) {
|
||||
reject(err.message || err);
|
||||
} else if (payload.slug !== this.repoSlug) {
|
||||
reject(`jwt slug invalid. expected: ${this.repoSlug}`);
|
||||
} else if (payload['pull-request'] !== expectedPr) {
|
||||
reject(`jwt pull-request invalid. expected: ${expectedPr}`);
|
||||
} else {
|
||||
resolve(payload);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected verifyPr(pr: number): Promise<BUILD_VERIFICATION_STATUS> {
|
||||
return this.getPrIsTrusted(pr).
|
||||
then(isTrusted => Promise.resolve(isTrusted ?
|
||||
BUILD_VERIFICATION_STATUS.verifiedAndTrusted :
|
||||
BUILD_VERIFICATION_STATUS.verifiedNotTrusted));
|
||||
}
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
// Imports
|
||||
import {getEnvVar} from '../common/utils';
|
||||
import {BuildVerifier} from './build-verifier';
|
||||
|
||||
// Run
|
||||
_main();
|
||||
|
||||
// Functions
|
||||
function _main() {
|
||||
const secret = 'unused';
|
||||
const githubToken = getEnvVar('AIO_GITHUB_TOKEN');
|
||||
const repoSlug = getEnvVar('AIO_REPO_SLUG');
|
||||
const organization = getEnvVar('AIO_GITHUB_ORGANIZATION');
|
||||
const allowedTeamSlugs = getEnvVar('AIO_GITHUB_TEAM_SLUGS').split(',');
|
||||
const trustedPrLabel = getEnvVar('AIO_TRUSTED_PR_LABEL');
|
||||
const pr = +getEnvVar('AIO_PREVERIFY_PR');
|
||||
|
||||
const buildVerifier = new BuildVerifier(secret, githubToken, repoSlug, organization, allowedTeamSlugs,
|
||||
trustedPrLabel);
|
||||
|
||||
// Exit codes:
|
||||
// - 0: The PR can be automatically trusted (i.e. author belongs to trusted team or PR has the "trusted PR" label).
|
||||
// - 1: An error occurred.
|
||||
// - 2: The PR cannot be automatically trusted.
|
||||
buildVerifier.getPrIsTrusted(pr).
|
||||
then(isTrusted => {
|
||||
if (!isTrusted) {
|
||||
console.warn(
|
||||
`The PR cannot be automatically verified, because it doesn't have the "${trustedPrLabel}" label and the ` +
|
||||
`the author is not an active member of any of the following teams: ${allowedTeamSlugs.join(', ')}`);
|
||||
}
|
||||
|
||||
process.exit(isTrusted ? 0 : 2);
|
||||
}).
|
||||
catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
// Imports
|
||||
import {getEnvVar} from '../common/utils';
|
||||
import {uploadServerFactory} from './upload-server-factory';
|
||||
|
||||
// Constants
|
||||
const AIO_BUILDS_DIR = getEnvVar('AIO_BUILDS_DIR');
|
||||
const AIO_DOMAIN_NAME = getEnvVar('AIO_DOMAIN_NAME');
|
||||
const AIO_GITHUB_ORGANIZATION = getEnvVar('AIO_GITHUB_ORGANIZATION');
|
||||
const AIO_GITHUB_TEAM_SLUGS = getEnvVar('AIO_GITHUB_TEAM_SLUGS');
|
||||
const AIO_GITHUB_TOKEN = getEnvVar('AIO_GITHUB_TOKEN');
|
||||
const AIO_PREVIEW_DEPLOYMENT_TOKEN = getEnvVar('AIO_PREVIEW_DEPLOYMENT_TOKEN');
|
||||
const AIO_REPO_SLUG = getEnvVar('AIO_REPO_SLUG');
|
||||
const AIO_TRUSTED_PR_LABEL = getEnvVar('AIO_TRUSTED_PR_LABEL');
|
||||
const AIO_UPLOAD_HOSTNAME = getEnvVar('AIO_UPLOAD_HOSTNAME');
|
||||
const AIO_UPLOAD_PORT = +getEnvVar('AIO_UPLOAD_PORT');
|
||||
|
||||
// Run
|
||||
_main();
|
||||
|
||||
// Functions
|
||||
function _main() {
|
||||
uploadServerFactory.
|
||||
create({
|
||||
buildsDir: AIO_BUILDS_DIR,
|
||||
domainName: AIO_DOMAIN_NAME,
|
||||
githubOrganization: AIO_GITHUB_ORGANIZATION,
|
||||
githubTeamSlugs: AIO_GITHUB_TEAM_SLUGS.split(','),
|
||||
githubToken: AIO_GITHUB_TOKEN,
|
||||
repoSlug: AIO_REPO_SLUG,
|
||||
secret: AIO_PREVIEW_DEPLOYMENT_TOKEN,
|
||||
trustedPrLabel: AIO_TRUSTED_PR_LABEL,
|
||||
}).
|
||||
listen(AIO_UPLOAD_PORT, AIO_UPLOAD_HOSTNAME);
|
||||
}
|
@ -1,153 +0,0 @@
|
||||
// Imports
|
||||
import * as bodyParser from 'body-parser';
|
||||
import * as express from 'express';
|
||||
import * as http from 'http';
|
||||
import {GithubPullRequests} from '../common/github-pull-requests';
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
import {BuildCreator} from './build-creator';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from './build-events';
|
||||
import {BUILD_VERIFICATION_STATUS, BuildVerifier} from './build-verifier';
|
||||
import {UploadError} from './upload-error';
|
||||
|
||||
// Constants
|
||||
const AUTHORIZATION_HEADER = 'AUTHORIZATION';
|
||||
const X_FILE_HEADER = 'X-FILE';
|
||||
|
||||
// Interfaces - Types
|
||||
interface UploadServerConfig {
|
||||
buildsDir: string;
|
||||
domainName: string;
|
||||
githubOrganization: string;
|
||||
githubTeamSlugs: string[];
|
||||
githubToken: string;
|
||||
repoSlug: string;
|
||||
secret: string;
|
||||
trustedPrLabel: string;
|
||||
}
|
||||
|
||||
// Classes
|
||||
class UploadServerFactory {
|
||||
// Methods - Public
|
||||
public create({
|
||||
buildsDir,
|
||||
domainName,
|
||||
githubOrganization,
|
||||
githubTeamSlugs,
|
||||
githubToken,
|
||||
repoSlug,
|
||||
secret,
|
||||
trustedPrLabel,
|
||||
}: UploadServerConfig): http.Server {
|
||||
assertNotMissingOrEmpty('domainName', domainName);
|
||||
|
||||
const buildVerifier = new BuildVerifier(secret, githubToken, repoSlug, githubOrganization, githubTeamSlugs,
|
||||
trustedPrLabel);
|
||||
const buildCreator = this.createBuildCreator(buildsDir, githubToken, repoSlug, domainName);
|
||||
|
||||
const middleware = this.createMiddleware(buildVerifier, buildCreator);
|
||||
const httpServer = http.createServer(middleware as any);
|
||||
|
||||
httpServer.on('listening', () => {
|
||||
const info = httpServer.address();
|
||||
console.info(`Up and running (and listening on ${info.address}:${info.port})...`);
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
// Methods - Protected
|
||||
protected createBuildCreator(buildsDir: string, githubToken: string, repoSlug: string,
|
||||
domainName: string): BuildCreator {
|
||||
const buildCreator = new BuildCreator(buildsDir);
|
||||
const githubPullRequests = new GithubPullRequests(githubToken, repoSlug);
|
||||
const postPreviewsComment = (pr: number, shas: string[]) => {
|
||||
const body = shas.
|
||||
map(sha => `You can preview ${sha} at https://pr${pr}-${sha}.${domainName}/.`).
|
||||
join('\n');
|
||||
|
||||
return githubPullRequests.addComment(pr, body);
|
||||
};
|
||||
|
||||
buildCreator.on(CreatedBuildEvent.type, ({pr, sha, isPublic}: CreatedBuildEvent) => {
|
||||
if (isPublic) {
|
||||
postPreviewsComment(pr, [sha]);
|
||||
}
|
||||
});
|
||||
|
||||
buildCreator.on(ChangedPrVisibilityEvent.type, ({pr, shas, isPublic}: ChangedPrVisibilityEvent) => {
|
||||
if (isPublic && shas.length) {
|
||||
postPreviewsComment(pr, shas);
|
||||
}
|
||||
});
|
||||
|
||||
return buildCreator;
|
||||
}
|
||||
|
||||
protected createMiddleware(buildVerifier: BuildVerifier, buildCreator: BuildCreator): express.Express {
|
||||
const middleware = express();
|
||||
const jsonParser = bodyParser.json();
|
||||
|
||||
middleware.get(/^\/create-build\/([1-9][0-9]*)\/([0-9a-f]{40})\/?$/, (req, res) => {
|
||||
const pr = req.params[0];
|
||||
const sha = req.params[1];
|
||||
const archive = req.header(X_FILE_HEADER);
|
||||
const authHeader = req.header(AUTHORIZATION_HEADER);
|
||||
|
||||
if (!authHeader) {
|
||||
this.throwRequestError(401, `Missing or empty '${AUTHORIZATION_HEADER}' header`, req);
|
||||
} else if (!archive) {
|
||||
this.throwRequestError(400, `Missing or empty '${X_FILE_HEADER}' header`, req);
|
||||
} else {
|
||||
Promise.resolve().
|
||||
then(() => buildVerifier.verify(+pr, authHeader)).
|
||||
then(verStatus => verStatus === BUILD_VERIFICATION_STATUS.verifiedAndTrusted).
|
||||
then(isPublic => buildCreator.create(pr, sha, archive, isPublic).
|
||||
then(() => res.sendStatus(isPublic ? 201 : 202))).
|
||||
catch(err => this.respondWithError(res, err));
|
||||
}
|
||||
});
|
||||
middleware.get(/^\/health-check\/?$/, (_req, res) => res.sendStatus(200));
|
||||
middleware.post(/^\/pr-updated\/?$/, jsonParser, (req, res) => {
|
||||
const {action, number: prNo}: {action?: string, number?: number} = req.body;
|
||||
const visMayHaveChanged = !action || (action === 'labeled') || (action === 'unlabeled');
|
||||
|
||||
if (!visMayHaveChanged) {
|
||||
res.sendStatus(200);
|
||||
} else if (!prNo) {
|
||||
this.throwRequestError(400, `Missing or empty 'number' field`, req);
|
||||
} else {
|
||||
Promise.resolve().
|
||||
then(() => buildVerifier.getPrIsTrusted(prNo)).
|
||||
then(isPublic => buildCreator.updatePrVisibility(String(prNo), isPublic)).
|
||||
then(() => res.sendStatus(200)).
|
||||
catch(err => this.respondWithError(res, err));
|
||||
}
|
||||
});
|
||||
middleware.all('*', req => this.throwRequestError(404, 'Unknown resource', req));
|
||||
middleware.use((err: any, _req: any, res: express.Response, _next: any) => this.respondWithError(res, err));
|
||||
|
||||
return middleware;
|
||||
}
|
||||
|
||||
protected respondWithError(res: express.Response, err: any) {
|
||||
if (!(err instanceof UploadError)) {
|
||||
err = new UploadError(500, String((err && err.message) || err));
|
||||
}
|
||||
|
||||
const statusText = http.STATUS_CODES[err.status] || '???';
|
||||
console.error(`Upload error: ${err.status} - ${statusText}`);
|
||||
console.error(err.message);
|
||||
|
||||
res.status(err.status).end(err.message);
|
||||
}
|
||||
|
||||
protected throwRequestError(status: number, error: string, req: express.Request) {
|
||||
const message = `${error} in request: ${req.method} ${req.originalUrl}` +
|
||||
(!req.body ? '' : ` ${JSON.stringify(req.body)}`);
|
||||
|
||||
throw new UploadError(status, message);
|
||||
}
|
||||
}
|
||||
|
||||
// Exports
|
||||
export const uploadServerFactory = new UploadServerFactory();
|
@ -1,16 +1,37 @@
|
||||
// Using the values below, we can fake the response of the corresponding methods in tests. This is
|
||||
// necessary, because the test upload-server will be running as a separate node process, so we will
|
||||
// not have direct access to the code (e.g. for mocking).
|
||||
// (See also 'lib/verify-setup/start-test-upload-server.ts'.)
|
||||
export const enum BuildNums {
|
||||
BUILD_INFO_ERROR = 1,
|
||||
BUILD_INFO_404,
|
||||
BUILD_INFO_BUILD_FAILED,
|
||||
BUILD_INFO_INVALID_GH_ORG,
|
||||
BUILD_INFO_INVALID_GH_REPO,
|
||||
CHANGED_FILES_ERROR,
|
||||
CHANGED_FILES_404,
|
||||
CHANGED_FILES_NONE,
|
||||
BUILD_ARTIFACTS_ERROR,
|
||||
BUILD_ARTIFACTS_404,
|
||||
BUILD_ARTIFACTS_EMPTY,
|
||||
BUILD_ARTIFACTS_MISSING,
|
||||
DOWNLOAD_ARTIFACT_ERROR,
|
||||
DOWNLOAD_ARTIFACT_404,
|
||||
DOWNLOAD_ARTIFACT_TOO_BIG,
|
||||
TRUST_CHECK_ERROR,
|
||||
TRUST_CHECK_UNTRUSTED,
|
||||
TRUST_CHECK_TRUSTED_LABEL,
|
||||
TRUST_CHECK_ACTIVE_TRUSTED_USER,
|
||||
TRUST_CHECK_INACTIVE_TRUSTED_USER,
|
||||
}
|
||||
|
||||
/* tslint:disable: variable-name */
|
||||
export const enum PrNums {
|
||||
CHANGED_FILES_ERROR = 1,
|
||||
CHANGED_FILES_404,
|
||||
CHANGED_FILES_NONE,
|
||||
TRUST_CHECK_ERROR,
|
||||
TRUST_CHECK_UNTRUSTED,
|
||||
TRUST_CHECK_TRUSTED_LABEL,
|
||||
TRUST_CHECK_ACTIVE_TRUSTED_USER,
|
||||
TRUST_CHECK_INACTIVE_TRUSTED_USER,
|
||||
}
|
||||
|
||||
// Special values to be used as `authHeader` in `BuildVerifier#verify()`.
|
||||
export const BV_verify_error = 'FAKE_VERIFICATION_ERROR';
|
||||
export const BV_verify_verifiedNotTrusted = 'FAKE_VERIFIED_NOT_TRUSTED';
|
||||
|
||||
// Special values to be used as `pr` in `BuildVerifier#getPrIsTrusted()`.
|
||||
export const BV_getPrIsTrusted_error = 32203;
|
||||
export const BV_getPrIsTrusted_notTrusted = 72457;
|
||||
|
||||
/* tslint:enable: variable-name */
|
||||
export const SHA = '1234567890'.repeat(4);
|
||||
export const ALT_SHA = 'abcde'.repeat(8);
|
||||
export const SIMILAR_SHA = SHA.slice(0, -1) + 'A';
|
||||
|
10
aio/aio-builds-setup/dockerbuild/scripts-js/lib/verify-setup/delete-empty.d.ts
vendored
Normal file
10
aio/aio-builds-setup/dockerbuild/scripts-js/lib/verify-setup/delete-empty.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
declare module 'delete-empty' {
|
||||
interface Options {
|
||||
dryRun: boolean;
|
||||
verbose: boolean;
|
||||
filter: (filePath: string) => boolean;
|
||||
}
|
||||
export default function deleteEmpty(cwd: string, options?: Options): Promise<string[]>;
|
||||
export default function deleteEmpty(cwd: string, options?: Options, callback?: (err: any, deleted: string[]) => void): void;
|
||||
export function sync(cwd: string, options?: Options): string[];
|
||||
}
|
@ -4,18 +4,14 @@ import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {HIDDEN_DIR_PREFIX, SHORT_SHA_LEN} from '../common/constants';
|
||||
import {getEnvVar} from '../common/utils';
|
||||
|
||||
// Constans
|
||||
const TEST_AIO_BUILDS_DIR = getEnvVar('TEST_AIO_BUILDS_DIR');
|
||||
const TEST_AIO_NGINX_HOSTNAME = getEnvVar('TEST_AIO_NGINX_HOSTNAME');
|
||||
const TEST_AIO_NGINX_PORT_HTTP = +getEnvVar('TEST_AIO_NGINX_PORT_HTTP');
|
||||
const TEST_AIO_NGINX_PORT_HTTPS = +getEnvVar('TEST_AIO_NGINX_PORT_HTTPS');
|
||||
const TEST_AIO_UPLOAD_HOSTNAME = getEnvVar('TEST_AIO_UPLOAD_HOSTNAME');
|
||||
const TEST_AIO_UPLOAD_MAX_SIZE = +getEnvVar('TEST_AIO_UPLOAD_MAX_SIZE');
|
||||
const TEST_AIO_UPLOAD_PORT = +getEnvVar('TEST_AIO_UPLOAD_PORT');
|
||||
const WWW_USER = getEnvVar('AIO_WWW_USER');
|
||||
import {AIO_DOWNLOADS_DIR, HIDDEN_DIR_PREFIX} from '../common/constants';
|
||||
import {
|
||||
AIO_BUILDS_DIR,
|
||||
AIO_NGINX_PORT_HTTP,
|
||||
AIO_NGINX_PORT_HTTPS,
|
||||
AIO_WWW_USER,
|
||||
} from '../common/env-variables';
|
||||
import {computeShortSha, Logger} from '../common/utils';
|
||||
|
||||
// Interfaces - Types
|
||||
export interface CmdResult { success: boolean; err: Error | null; stdout: string; stderr: string; }
|
||||
@ -27,61 +23,50 @@ export type VerifyCmdResultFn = (result: CmdResult) => void;
|
||||
|
||||
// Classes
|
||||
class Helper {
|
||||
// Properties - Public
|
||||
public get buildsDir() { return TEST_AIO_BUILDS_DIR; }
|
||||
public get nginxHostname() { return TEST_AIO_NGINX_HOSTNAME; }
|
||||
public get nginxPortHttp() { return TEST_AIO_NGINX_PORT_HTTP; }
|
||||
public get nginxPortHttps() { return TEST_AIO_NGINX_PORT_HTTPS; }
|
||||
public get uploadHostname() { return TEST_AIO_UPLOAD_HOSTNAME; }
|
||||
public get uploadPort() { return TEST_AIO_UPLOAD_PORT; }
|
||||
public get uploadMaxSize() { return TEST_AIO_UPLOAD_MAX_SIZE; }
|
||||
public get wwwUser() { return WWW_USER; }
|
||||
|
||||
// Properties - Protected
|
||||
protected cleanUpFns: CleanUpFn[] = [];
|
||||
protected portPerScheme: {[scheme: string]: number} = {
|
||||
http: this.nginxPortHttp,
|
||||
https: this.nginxPortHttps,
|
||||
http: AIO_NGINX_PORT_HTTP,
|
||||
https: AIO_NGINX_PORT_HTTPS,
|
||||
};
|
||||
|
||||
private logger = new Logger('TestHelper');
|
||||
|
||||
// Constructor
|
||||
constructor() {
|
||||
shell.mkdir('-p', this.buildsDir);
|
||||
shell.exec(`chown -R ${this.wwwUser} ${this.buildsDir}`);
|
||||
shell.mkdir('-p', AIO_BUILDS_DIR);
|
||||
shell.exec(`chown -R ${AIO_WWW_USER} ${AIO_BUILDS_DIR}`);
|
||||
shell.mkdir('-p', AIO_DOWNLOADS_DIR);
|
||||
shell.exec(`chown -R ${AIO_WWW_USER} ${AIO_DOWNLOADS_DIR}`);
|
||||
}
|
||||
|
||||
// Methods - Public
|
||||
public buildExists(pr: string, sha = '', isPublic = true, legacy = false): boolean {
|
||||
const prDir = this.getPrDir(pr, isPublic);
|
||||
const dir = !sha ? prDir : this.getShaDir(prDir, sha, legacy);
|
||||
return fs.existsSync(dir);
|
||||
}
|
||||
|
||||
public cleanUp() {
|
||||
public cleanUp(): void {
|
||||
while (this.cleanUpFns.length) {
|
||||
// Clean-up fns remove themselves from the list.
|
||||
this.cleanUpFns[0]();
|
||||
}
|
||||
|
||||
if (fs.readdirSync(this.buildsDir).length) {
|
||||
throw new Error(`Directory '${this.buildsDir}' is not empty after clean-up.`);
|
||||
const leftoverDownloads = fs.readdirSync(AIO_DOWNLOADS_DIR);
|
||||
const leftoverBuilds = fs.readdirSync(AIO_BUILDS_DIR);
|
||||
|
||||
if (leftoverDownloads.length) {
|
||||
this.logger.log(`Downloads directory '${AIO_DOWNLOADS_DIR}' is not empty after clean-up.`, leftoverDownloads);
|
||||
shell.rm('-rf', `${AIO_DOWNLOADS_DIR}/*`);
|
||||
}
|
||||
|
||||
if (leftoverBuilds.length) {
|
||||
this.logger.log(`Builds directory '${AIO_BUILDS_DIR}' is not empty after clean-up.`, leftoverBuilds);
|
||||
shell.rm('-rf', `${AIO_BUILDS_DIR}/*`);
|
||||
}
|
||||
|
||||
if (leftoverBuilds.length || leftoverDownloads.length) {
|
||||
throw new Error(`Unexpected test files not cleaned up.`);
|
||||
}
|
||||
}
|
||||
|
||||
public createDummyArchive(pr: string, sha: string, archivePath: string): CleanUpFn {
|
||||
const inputDir = this.getShaDir(this.getPrDir(`uploaded/${pr}`, true), sha);
|
||||
const cmd1 = `tar --create --gzip --directory "${inputDir}" --file "${archivePath}" .`;
|
||||
const cmd2 = `chown ${this.wwwUser} ${archivePath}`;
|
||||
|
||||
const cleanUpTemp = this.createDummyBuild(`uploaded/${pr}`, sha, true, true);
|
||||
shell.exec(cmd1);
|
||||
shell.exec(cmd2);
|
||||
cleanUpTemp();
|
||||
|
||||
return this.createCleanUpFn(() => shell.rm('-rf', archivePath));
|
||||
}
|
||||
|
||||
public createDummyBuild(pr: string, sha: string, isPublic = true, force = false, legacy = false): CleanUpFn {
|
||||
public createDummyBuild(pr: number, sha: string, isPublic = true, force = false, legacy = false): CleanUpFn {
|
||||
const prDir = this.getPrDir(pr, isPublic);
|
||||
const shaDir = this.getShaDir(prDir, sha, legacy);
|
||||
const idxPath = path.join(shaDir, 'index.html');
|
||||
@ -89,34 +74,21 @@ class Helper {
|
||||
|
||||
this.writeFile(idxPath, {content: `PR: ${pr} | SHA: ${sha} | File: /index.html`}, force);
|
||||
this.writeFile(barPath, {content: `PR: ${pr} | SHA: ${sha} | File: /foo/bar.js`}, force);
|
||||
shell.exec(`chown -R ${this.wwwUser} ${prDir}`);
|
||||
shell.exec(`chown -R ${AIO_WWW_USER} ${prDir}`);
|
||||
|
||||
return this.createCleanUpFn(() => shell.rm('-rf', prDir));
|
||||
}
|
||||
|
||||
public deletePrDir(pr: string, isPublic = true) {
|
||||
const prDir = this.getPrDir(pr, isPublic);
|
||||
|
||||
if (fs.existsSync(prDir)) {
|
||||
shell.chmod('-R', 'a+w', prDir);
|
||||
shell.rm('-rf', prDir);
|
||||
}
|
||||
}
|
||||
|
||||
public getPrDir(pr: string, isPublic: boolean): string {
|
||||
const prDirName = isPublic ? pr : HIDDEN_DIR_PREFIX + pr;
|
||||
return path.join(this.buildsDir, prDirName);
|
||||
public getPrDir(pr: number, isPublic: boolean): string {
|
||||
const prDirName = isPublic ? '' + pr : HIDDEN_DIR_PREFIX + pr;
|
||||
return path.join(AIO_BUILDS_DIR, prDirName);
|
||||
}
|
||||
|
||||
public getShaDir(prDir: string, sha: string, legacy = false): string {
|
||||
return path.join(prDir, legacy ? sha : this.getShordSha(sha));
|
||||
return path.join(prDir, legacy ? sha : computeShortSha(sha));
|
||||
}
|
||||
|
||||
public getShordSha(sha: string): string {
|
||||
return sha.substr(0, SHORT_SHA_LEN);
|
||||
}
|
||||
|
||||
public readBuildFile(pr: string, sha: string, relFilePath: string, isPublic = true, legacy = false): string {
|
||||
public readBuildFile(pr: number, sha: string, relFilePath: string, isPublic = true, legacy = false): string {
|
||||
const shaDir = this.getShaDir(this.getPrDir(pr, isPublic), sha, legacy);
|
||||
const absFilePath = path.join(shaDir, relFilePath);
|
||||
return fs.readFileSync(absFilePath, 'utf8');
|
||||
@ -129,11 +101,11 @@ class Helper {
|
||||
});
|
||||
}
|
||||
|
||||
public runForAllSupportedSchemes(suiteFactory: TestSuiteFactory) {
|
||||
public runForAllSupportedSchemes(suiteFactory: TestSuiteFactory): void {
|
||||
Object.keys(this.portPerScheme).forEach(scheme => suiteFactory(scheme, this.portPerScheme[scheme]));
|
||||
}
|
||||
|
||||
public verifyResponse(status: number | [number, string], regex = /^/): VerifyCmdResultFn {
|
||||
public verifyResponse(status: number | [number, string], regex: string | RegExp = /^/): VerifyCmdResultFn {
|
||||
let statusCode: number;
|
||||
let statusText: string;
|
||||
|
||||
@ -153,9 +125,9 @@ class Helper {
|
||||
// Only keep the last to sections (final headers and body).
|
||||
|
||||
if (!result.success) {
|
||||
console.log('Stdout:', result.stdout);
|
||||
console.log('Stderr:', result.stderr);
|
||||
console.log('Error:', result.err);
|
||||
this.logger.log('Stdout:', result.stdout);
|
||||
this.logger.error('Stderr:', result.stderr);
|
||||
this.logger.error('Error:', result.err);
|
||||
}
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@ -164,14 +136,14 @@ class Helper {
|
||||
};
|
||||
}
|
||||
|
||||
public writeBuildFile(pr: string, sha: string, relFilePath: string, content: string, isPublic = true,
|
||||
legacy = false): CleanUpFn {
|
||||
public writeBuildFile(pr: number, sha: string, relFilePath: string, content: string, isPublic = true,
|
||||
legacy = false): void {
|
||||
const shaDir = this.getShaDir(this.getPrDir(pr, isPublic), sha, legacy);
|
||||
const absFilePath = path.join(shaDir, relFilePath);
|
||||
return this.writeFile(absFilePath, {content}, true);
|
||||
this.writeFile(absFilePath, {content}, true);
|
||||
}
|
||||
|
||||
public writeFile(filePath: string, {content, size}: FileSpecs, force = false): CleanUpFn {
|
||||
public writeFile(filePath: string, {content, size}: FileSpecs, force = false): void {
|
||||
if (!force && fs.existsSync(filePath)) {
|
||||
throw new Error(`Refusing to overwrite existing file '${filePath}'.`);
|
||||
}
|
||||
@ -189,9 +161,7 @@ class Helper {
|
||||
// Create a file with the specified content.
|
||||
fs.writeFileSync(filePath, content || '');
|
||||
}
|
||||
shell.exec(`chown ${this.wwwUser} ${filePath}`);
|
||||
|
||||
return this.createCleanUpFn(() => shell.rm('-rf', cleanUpTarget));
|
||||
shell.exec(`chown ${AIO_WWW_USER} ${filePath}`);
|
||||
}
|
||||
|
||||
// Methods - Protected
|
||||
@ -210,5 +180,70 @@ class Helper {
|
||||
}
|
||||
}
|
||||
|
||||
interface DefaultCurlOptions {
|
||||
defaultMethod?: CurlOptions['method'];
|
||||
defaultOptions?: CurlOptions['options'];
|
||||
defaultHeaders?: CurlOptions['headers'];
|
||||
defaultData?: CurlOptions['data'];
|
||||
defaultExtraPath?: CurlOptions['extraPath'];
|
||||
}
|
||||
|
||||
interface CurlOptions {
|
||||
method?: string;
|
||||
options?: string;
|
||||
headers?: string[];
|
||||
data?: any;
|
||||
url?: string;
|
||||
extraPath?: string;
|
||||
}
|
||||
|
||||
export function makeCurl(baseUrl: string, {
|
||||
defaultMethod = 'POST',
|
||||
defaultOptions = '',
|
||||
defaultHeaders = ['Content-Type: application/json'],
|
||||
defaultData = {},
|
||||
defaultExtraPath = '',
|
||||
}: DefaultCurlOptions = {}) {
|
||||
return function curl({
|
||||
method = defaultMethod,
|
||||
options = defaultOptions,
|
||||
headers = defaultHeaders,
|
||||
data = defaultData,
|
||||
url = baseUrl,
|
||||
extraPath = defaultExtraPath,
|
||||
}: CurlOptions) {
|
||||
const dataString = data ? JSON.stringify(data) : '';
|
||||
const cmd = `curl -iLX ${method} ` +
|
||||
`${options} ` +
|
||||
headers.map(header => `--header "${header}" `).join('') +
|
||||
`--data '${dataString}' ` +
|
||||
`${url}${extraPath}`;
|
||||
return helper.runCmd(cmd);
|
||||
};
|
||||
}
|
||||
|
||||
export interface PayloadData {
|
||||
data: {
|
||||
payload: {
|
||||
build_num: number,
|
||||
build_parameters: {
|
||||
CIRCLE_JOB: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function payload(buildNum: number): PayloadData {
|
||||
return {
|
||||
data: {
|
||||
payload: {
|
||||
build_num: buildNum,
|
||||
build_parameters: { CIRCLE_JOB: 'aio_preview' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Exports
|
||||
export const helper = new Helper();
|
||||
|
@ -0,0 +1,7 @@
|
||||
declare module jasmine {
|
||||
interface Matchers {
|
||||
toExistAsAFile(remove = true): boolean;
|
||||
toExistAsABuild(remove = true): boolean;
|
||||
toExistAsAnArtifact(remove = true): boolean;
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
import {sync as deleteEmpty} from 'delete-empty';
|
||||
import {existsSync, unlinkSync} from 'fs';
|
||||
import {join} from 'path';
|
||||
import {AIO_DOWNLOADS_DIR} from '../common/constants';
|
||||
import {computeShortSha} from '../common/utils';
|
||||
import {SHA} from './constants';
|
||||
import {helper} from './helper';
|
||||
|
||||
function checkFile(filePath: string, remove: boolean): boolean {
|
||||
const exists = existsSync(filePath);
|
||||
if (exists && remove) {
|
||||
// if we expected the file to exist then we remove it to prevent leftover file errors
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
return exists;
|
||||
}
|
||||
|
||||
function getArtifactPath(prNum: number, sha: string = SHA): string {
|
||||
return `${AIO_DOWNLOADS_DIR}/${prNum}-${computeShortSha(sha)}-aio-snapshot.tgz`;
|
||||
}
|
||||
|
||||
function checkFiles(prNum: number, isPublic: boolean, sha: string, isLegacy: boolean, remove: boolean) {
|
||||
const files = ['/index.html', '/foo/bar.js'];
|
||||
const prPath = helper.getPrDir(prNum, isPublic);
|
||||
const shaPath = helper.getShaDir(prPath, sha, isLegacy);
|
||||
|
||||
const existingFiles: string[] = [];
|
||||
const missingFiles: string[] = [];
|
||||
files
|
||||
.map(file => join(shaPath, file))
|
||||
.forEach(file => (checkFile(file, remove) ? existingFiles : missingFiles).push(file));
|
||||
|
||||
deleteEmpty(prPath);
|
||||
|
||||
return { existingFiles, missingFiles };
|
||||
}
|
||||
|
||||
class ToExistAsAFile implements jasmine.CustomMatcher {
|
||||
public compare(actual: string, remove = true): jasmine.CustomMatcherResult {
|
||||
const pass = checkFile(actual, remove);
|
||||
return {
|
||||
message: `Expected file at "${actual}" ${pass ? 'not' : ''} to exist`,
|
||||
pass,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ToExistAsAnArtifact implements jasmine.CustomMatcher {
|
||||
public compare(actual: {prNum: number, sha?: string}, remove = true): jasmine.CustomMatcherResult {
|
||||
const { prNum, sha = SHA } = actual;
|
||||
const filePath = getArtifactPath(prNum, sha);
|
||||
const pass = checkFile(filePath, remove);
|
||||
return {
|
||||
message: `Expected artifact "PR:${prNum}, SHA:${sha}, FILE:${filePath}" ${pass ? 'not' : '\b'} to exist`,
|
||||
pass,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ToExistAsABuild implements jasmine.CustomMatcher {
|
||||
public compare(actual: {prNum: number, isPublic?: boolean, sha?: string, isLegacy?: boolean}, remove = true):
|
||||
jasmine.CustomMatcherResult {
|
||||
const {prNum, isPublic = true, sha = SHA, isLegacy = false} = actual;
|
||||
const {missingFiles} = checkFiles(prNum, isPublic, sha, isLegacy, remove);
|
||||
return {
|
||||
message: `Expected files for build "PR:${prNum}, SHA:${sha}" to exist:\n` +
|
||||
missingFiles.map(file => ` - ${file}`).join('\n'),
|
||||
pass: missingFiles.length === 0,
|
||||
};
|
||||
}
|
||||
public negativeCompare(actual: {prNum: number, isPublic?: boolean, sha?: string, isLegacy?: boolean}):
|
||||
jasmine.CustomMatcherResult {
|
||||
const {prNum, isPublic = true, sha = SHA, isLegacy = false} = actual;
|
||||
const { existingFiles } = checkFiles(prNum, isPublic, sha, isLegacy, false);
|
||||
return {
|
||||
message: `Expected files for build "PR:${prNum}, SHA:${sha}" not to exist:\n` +
|
||||
existingFiles.map(file => ` - ${file}`).join('\n'),
|
||||
pass: existingFiles.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const customMatchers = {
|
||||
toExistAsABuild: () => new ToExistAsABuild(),
|
||||
toExistAsAFile: () => new ToExistAsAFile(),
|
||||
toExistAsAnArtifact: () => new ToExistAsAnArtifact(),
|
||||
};
|
@ -0,0 +1,171 @@
|
||||
/* tslint:disable:max-line-length */
|
||||
import * as nock from 'nock';
|
||||
import * as tar from 'tar-stream';
|
||||
import {gzipSync} from 'zlib';
|
||||
import {getEnvVar, Logger} from '../common/utils';
|
||||
import {BuildNums, PrNums, SHA} from './constants';
|
||||
|
||||
// We are using the `nock` library to fake responses from REST requests, when testing.
|
||||
// This is necessary, because the test preview-server runs as a separate node process to
|
||||
// the test harness, so we do not have direct access to the code (e.g. for mocking).
|
||||
// (See also 'lib/verify-setup/start-test-preview-server.ts'.)
|
||||
|
||||
// Each of the potential requests to an external API (e.g. Github or CircleCI) are mocked
|
||||
// below and return a suitable response. This is quite complicated to setup since the
|
||||
// response from, say, CircleCI will affect what request is made to, say, Github.
|
||||
|
||||
const logger = new Logger('mock-external-apis');
|
||||
|
||||
const log = (...args: any[]) => {
|
||||
// Filter out non-matching URL checks
|
||||
if (!/^matching.+: false$/.test(args[0])) {
|
||||
logger.log(...args);
|
||||
}
|
||||
};
|
||||
|
||||
const AIO_CIRCLE_CI_TOKEN = getEnvVar('AIO_CIRCLE_CI_TOKEN');
|
||||
const AIO_GITHUB_TOKEN = getEnvVar('AIO_GITHUB_TOKEN');
|
||||
|
||||
const AIO_ARTIFACT_PATH = getEnvVar('AIO_ARTIFACT_PATH');
|
||||
const AIO_GITHUB_ORGANIZATION = getEnvVar('AIO_GITHUB_ORGANIZATION');
|
||||
const AIO_GITHUB_REPO = getEnvVar('AIO_GITHUB_REPO');
|
||||
const AIO_TRUSTED_PR_LABEL = getEnvVar('AIO_TRUSTED_PR_LABEL');
|
||||
const AIO_GITHUB_TEAM_SLUGS = getEnvVar('AIO_GITHUB_TEAM_SLUGS').split(',');
|
||||
|
||||
const ACTIVE_TRUSTED_USER = 'active-trusted-user';
|
||||
const INACTIVE_TRUSTED_USER = 'inactive-trusted-user';
|
||||
const UNTRUSTED_USER = 'untrusted-user';
|
||||
|
||||
const BASIC_BUILD_INFO = {
|
||||
branch: `pull/${PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER}`,
|
||||
failed: false,
|
||||
reponame: AIO_GITHUB_REPO,
|
||||
username: AIO_GITHUB_ORGANIZATION,
|
||||
vcs_revision: SHA,
|
||||
};
|
||||
|
||||
const ISSUE_INFO_TRUSTED_LABEL = { labels: [{ name: AIO_TRUSTED_PR_LABEL }], user: { login: UNTRUSTED_USER } };
|
||||
const ISSUE_INFO_ACTIVE_TRUSTED_USER = { labels: [], user: { login: ACTIVE_TRUSTED_USER } };
|
||||
const ISSUE_INFO_INACTIVE_TRUSTED_USER = { labels: [], user: { login: INACTIVE_TRUSTED_USER } };
|
||||
const ISSUE_INFO_UNTRUSTED = { labels: [], user: { login: UNTRUSTED_USER } };
|
||||
const ACTIVE_STATE = { state: 'active' };
|
||||
const INACTIVE_STATE = { state: 'inactive' };
|
||||
|
||||
const TEST_TEAM_INFO = AIO_GITHUB_TEAM_SLUGS.map((slug, index) => ({ slug, id: index }));
|
||||
|
||||
const CIRCLE_CI_API_HOST = 'https://circleci.com';
|
||||
const CIRCLE_CI_TOKEN_PARAM = `circle-token=${AIO_CIRCLE_CI_TOKEN}`;
|
||||
const ARTIFACT_1 = { path: 'artifact-1', url: `${CIRCLE_CI_API_HOST}/artifacts/artifact-1`, _urlPath: '/artifacts/artifact-1' };
|
||||
const ARTIFACT_2 = { path: 'artifact-2', url: `${CIRCLE_CI_API_HOST}/artifacts/artifact-2`, _urlPath: '/artifacts/artifact-2' };
|
||||
const ARTIFACT_3 = { path: 'artifact-3', url: `${CIRCLE_CI_API_HOST}/artifacts/artifact-3`, _urlPath: '/artifacts/artifact-3' };
|
||||
const ARTIFACT_ERROR = { path: AIO_ARTIFACT_PATH, url: `${CIRCLE_CI_API_HOST}/artifacts/error`, _urlPath: '/artifacts/error' };
|
||||
const ARTIFACT_404 = { path: AIO_ARTIFACT_PATH, url: `${CIRCLE_CI_API_HOST}/artifacts/404`, _urlPath: '/artifacts/404' };
|
||||
const ARTIFACT_VALID_TRUSTED_USER = { path: AIO_ARTIFACT_PATH, url: `${CIRCLE_CI_API_HOST}/artifacts/valid/user`, _urlPath: '/artifacts/valid/user' };
|
||||
const ARTIFACT_VALID_TRUSTED_LABEL = { path: AIO_ARTIFACT_PATH, url: `${CIRCLE_CI_API_HOST}/artifacts/valid/label`, _urlPath: '/artifacts/valid/label' };
|
||||
const ARTIFACT_VALID_UNTRUSTED = { path: AIO_ARTIFACT_PATH, url: `${CIRCLE_CI_API_HOST}/artifacts/valid/untrusted`, _urlPath: '/artifacts/valid/untrusted' };
|
||||
|
||||
const CIRCLE_CI_BUILD_INFO_URL = `/api/v1.1/project/github/${AIO_GITHUB_ORGANIZATION}/${AIO_GITHUB_REPO}`;
|
||||
|
||||
const buildInfoUrl = (buildNum: number) => `${CIRCLE_CI_BUILD_INFO_URL}/${buildNum}?${CIRCLE_CI_TOKEN_PARAM}`;
|
||||
const buildArtifactsUrl = (buildNum: number) => `${CIRCLE_CI_BUILD_INFO_URL}/${buildNum}/artifacts?${CIRCLE_CI_TOKEN_PARAM}`;
|
||||
const buildInfo = (prNum: number) => ({ ...BASIC_BUILD_INFO, branch: `pull/${prNum}` });
|
||||
|
||||
const GITHUB_API_HOST = 'https://api.github.com';
|
||||
const GITHUB_ISSUES_URL = `/repos/${AIO_GITHUB_ORGANIZATION}/${AIO_GITHUB_REPO}/issues`;
|
||||
const GITHUB_PULLS_URL = `/repos/${AIO_GITHUB_ORGANIZATION}/${AIO_GITHUB_REPO}/pulls`;
|
||||
const GITHUB_TEAMS_URL = `/orgs/${AIO_GITHUB_ORGANIZATION}/teams`;
|
||||
|
||||
const getIssueUrl = (prNum: number) => `${GITHUB_ISSUES_URL}/${prNum}`;
|
||||
const getFilesUrl = (prNum: number, pageNum = 1) => `${GITHUB_PULLS_URL}/${prNum}/files?page=${pageNum}&per_page=100`;
|
||||
const getCommentUrl = (prNum: number) => `${getIssueUrl(prNum)}/comments`;
|
||||
const getTeamMembershipUrl = (teamId: number, username: string) => `/teams/${teamId}/memberships/${username}`;
|
||||
|
||||
const createArchive = (buildNum: number, prNum: number, sha: string) => {
|
||||
logger.log('createArchive', buildNum, prNum, sha);
|
||||
const pack = tar.pack();
|
||||
pack.entry({name: 'index.html'}, `BUILD: ${buildNum} | PR: ${prNum} | SHA: ${sha} | File: /index.html`);
|
||||
pack.entry({name: 'foo/bar.js'}, `BUILD: ${buildNum} | PR: ${prNum} | SHA: ${sha} | File: /foo/bar.js`);
|
||||
pack.finalize();
|
||||
const zip = gzipSync(pack.read());
|
||||
return zip;
|
||||
};
|
||||
|
||||
// Create request scopes
|
||||
const circleCiApi = nock(CIRCLE_CI_API_HOST).log(log).persist();
|
||||
const githubApi = nock(GITHUB_API_HOST).log(log).persist().matchHeader('Authorization', `token ${AIO_GITHUB_TOKEN}`);
|
||||
|
||||
//////////////////////////////
|
||||
|
||||
// GENERAL responses
|
||||
githubApi.get(GITHUB_TEAMS_URL + '?page=1&per_page=100').reply(200, TEST_TEAM_INFO);
|
||||
githubApi.post(getCommentUrl(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)).reply(200);
|
||||
|
||||
// BUILD_INFO errors
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_ERROR)).replyWithError('BUILD_INFO_ERROR');
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_404)).reply(404, 'BUILD_INFO_404');
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_BUILD_FAILED)).reply(200, { ...BASIC_BUILD_INFO, failed: true });
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_INVALID_GH_ORG)).reply(200, { ...BASIC_BUILD_INFO, username: 'bad' });
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_INFO_INVALID_GH_REPO)).reply(200, { ...BASIC_BUILD_INFO, reponame: 'bad' });
|
||||
|
||||
// CHANGED FILE errors
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.CHANGED_FILES_ERROR)).reply(200, buildInfo(PrNums.CHANGED_FILES_ERROR));
|
||||
githubApi.get(getFilesUrl(PrNums.CHANGED_FILES_ERROR)).replyWithError('CHANGED_FILES_ERROR');
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.CHANGED_FILES_404)).reply(200, buildInfo(PrNums.CHANGED_FILES_404));
|
||||
githubApi.get(getFilesUrl(PrNums.CHANGED_FILES_404)).reply(404, 'CHANGED_FILES_404');
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.CHANGED_FILES_NONE)).reply(200, buildInfo(PrNums.CHANGED_FILES_NONE));
|
||||
githubApi.get(getFilesUrl(PrNums.CHANGED_FILES_NONE)).reply(200, []);
|
||||
|
||||
// ARTIFACT URL errors
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_ERROR)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER));
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_ERROR)).replyWithError('BUILD_ARTIFACTS_ERROR');
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_404)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER));
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_404)).reply(404, 'BUILD_ARTIFACTS_ERROR');
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_EMPTY)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER));
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_EMPTY)).reply(200, []);
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.BUILD_ARTIFACTS_MISSING)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER));
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.BUILD_ARTIFACTS_MISSING)).reply(200, [ARTIFACT_1, ARTIFACT_2, ARTIFACT_3]);
|
||||
|
||||
// ARTIFACT DOWNLOAD errors
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.DOWNLOAD_ARTIFACT_ERROR)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER));
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.DOWNLOAD_ARTIFACT_ERROR)).reply(200, [ARTIFACT_ERROR]);
|
||||
circleCiApi.get(ARTIFACT_ERROR._urlPath).replyWithError(ARTIFACT_ERROR._urlPath);
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.DOWNLOAD_ARTIFACT_404)).reply(200, buildInfo(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER));
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.DOWNLOAD_ARTIFACT_404)).reply(200, [ARTIFACT_404]);
|
||||
circleCiApi.get(ARTIFACT_ERROR._urlPath).reply(404, ARTIFACT_ERROR._urlPath);
|
||||
|
||||
// TRUST CHECK errors
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.TRUST_CHECK_ERROR)).reply(200, buildInfo(PrNums.TRUST_CHECK_ERROR));
|
||||
githubApi.get(getFilesUrl(PrNums.TRUST_CHECK_ERROR)).reply(200, [{ filename: 'aio/a' }]);
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.TRUST_CHECK_ERROR)).reply(200, [ARTIFACT_VALID_TRUSTED_USER]);
|
||||
githubApi.get(getIssueUrl(PrNums.TRUST_CHECK_ERROR)).replyWithError('TRUST_CHECK_ERROR');
|
||||
|
||||
// ACTIVE TRUSTED USER response
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)).reply(200, BASIC_BUILD_INFO);
|
||||
githubApi.get(getFilesUrl(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)).reply(200, [{ filename: 'aio/a' }]);
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)).reply(200, [ARTIFACT_VALID_TRUSTED_USER]);
|
||||
circleCiApi.get(ARTIFACT_VALID_TRUSTED_USER._urlPath).reply(200, createArchive(BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, SHA));
|
||||
githubApi.get(getIssueUrl(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER)).reply(200, ISSUE_INFO_ACTIVE_TRUSTED_USER);
|
||||
githubApi.get(getTeamMembershipUrl(0, ACTIVE_TRUSTED_USER)).reply(200, ACTIVE_STATE);
|
||||
|
||||
// TRUSTED LABEL response
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.TRUST_CHECK_TRUSTED_LABEL)).reply(200, BASIC_BUILD_INFO);
|
||||
githubApi.get(getFilesUrl(PrNums.TRUST_CHECK_TRUSTED_LABEL)).reply(200, [{ filename: 'aio/a' }]);
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.TRUST_CHECK_TRUSTED_LABEL)).reply(200, [ARTIFACT_VALID_TRUSTED_LABEL]);
|
||||
circleCiApi.get(ARTIFACT_VALID_TRUSTED_LABEL._urlPath).reply(200, createArchive(BuildNums.TRUST_CHECK_TRUSTED_LABEL, PrNums.TRUST_CHECK_TRUSTED_LABEL, SHA));
|
||||
githubApi.get(getIssueUrl(PrNums.TRUST_CHECK_TRUSTED_LABEL)).reply(200, ISSUE_INFO_TRUSTED_LABEL);
|
||||
githubApi.get(getTeamMembershipUrl(0, ACTIVE_TRUSTED_USER)).reply(200, ACTIVE_STATE);
|
||||
|
||||
// INACTIVE TRUSTED USER response
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.TRUST_CHECK_INACTIVE_TRUSTED_USER)).reply(200, BASIC_BUILD_INFO);
|
||||
githubApi.get(getFilesUrl(PrNums.TRUST_CHECK_INACTIVE_TRUSTED_USER)).reply(200, [{ filename: 'aio/a' }]);
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.TRUST_CHECK_INACTIVE_TRUSTED_USER)).reply(200, [ARTIFACT_VALID_TRUSTED_USER]);
|
||||
githubApi.get(getIssueUrl(PrNums.TRUST_CHECK_INACTIVE_TRUSTED_USER)).reply(200, ISSUE_INFO_INACTIVE_TRUSTED_USER);
|
||||
githubApi.get(getTeamMembershipUrl(0, INACTIVE_TRUSTED_USER)).reply(200, INACTIVE_STATE);
|
||||
|
||||
// UNTRUSTED reponse
|
||||
circleCiApi.get(buildInfoUrl(BuildNums.TRUST_CHECK_UNTRUSTED)).reply(200, buildInfo(PrNums.TRUST_CHECK_UNTRUSTED));
|
||||
githubApi.get(getFilesUrl(PrNums.TRUST_CHECK_UNTRUSTED)).reply(200, [{ filename: 'aio/a' }]);
|
||||
circleCiApi.get(buildArtifactsUrl(BuildNums.TRUST_CHECK_UNTRUSTED)).reply(200, [ARTIFACT_VALID_UNTRUSTED]);
|
||||
circleCiApi.get(ARTIFACT_VALID_UNTRUSTED._urlPath).reply(200, createArchive(BuildNums.TRUST_CHECK_UNTRUSTED, PrNums.TRUST_CHECK_UNTRUSTED, SHA));
|
||||
githubApi.get(getIssueUrl(PrNums.TRUST_CHECK_UNTRUSTED)).reply(200, ISSUE_INFO_UNTRUSTED);
|
||||
githubApi.get(getTeamMembershipUrl(0, UNTRUSTED_USER)).reply(404);
|
@ -1,17 +1,23 @@
|
||||
// Imports
|
||||
import * as path from 'path';
|
||||
import {rm} from 'shelljs';
|
||||
import {AIO_BUILDS_DIR, AIO_NGINX_HOSTNAME, AIO_NGINX_PORT_HTTP, AIO_NGINX_PORT_HTTPS} from '../common/env-variables';
|
||||
import {computeShortSha} from '../common/utils';
|
||||
import {PrNums} from './constants';
|
||||
import {helper as h} from './helper';
|
||||
import {customMatchers} from './jasmine-custom-matchers';
|
||||
|
||||
// Tests
|
||||
describe(`nginx`, () => {
|
||||
|
||||
beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000);
|
||||
beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000);
|
||||
beforeEach(() => jasmine.addMatchers(customMatchers));
|
||||
afterEach(() => h.cleanUp());
|
||||
|
||||
|
||||
it('should redirect HTTP to HTTPS', done => {
|
||||
const httpHost = `${h.nginxHostname}:${h.nginxPortHttp}`;
|
||||
const httpsHost = `${h.nginxHostname}:${h.nginxPortHttps}`;
|
||||
const httpHost = `${AIO_NGINX_HOSTNAME}:${AIO_NGINX_PORT_HTTP}`;
|
||||
const httpsHost = `${AIO_NGINX_HOSTNAME}:${AIO_NGINX_PORT_HTTPS}`;
|
||||
const urlMap = {
|
||||
[`http://${httpHost}/`]: `https://${httpsHost}/`,
|
||||
[`http://${httpHost}/foo`]: `https://${httpsHost}/foo`,
|
||||
@ -32,13 +38,13 @@ describe(`nginx`, () => {
|
||||
|
||||
|
||||
h.runForAllSupportedSchemes((scheme, port) => describe(`(on ${scheme.toUpperCase()})`, () => {
|
||||
const hostname = h.nginxHostname;
|
||||
const hostname = AIO_NGINX_HOSTNAME;
|
||||
const host = `${hostname}:${port}`;
|
||||
const pr = '9';
|
||||
const pr = 9;
|
||||
const sha9 = '9'.repeat(40);
|
||||
const sha0 = '0'.repeat(40);
|
||||
const shortSha9 = h.getShordSha(sha9);
|
||||
const shortSha0 = h.getShordSha(sha0);
|
||||
const shortSha9 = computeShortSha(sha9);
|
||||
const shortSha0 = computeShortSha(sha0);
|
||||
|
||||
|
||||
describe(`pr<pr>-<sha>.${host}/*`, () => {
|
||||
@ -50,6 +56,11 @@ describe(`nginx`, () => {
|
||||
h.createDummyBuild(pr, sha0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
expect({ prNum: pr, sha: sha9 }).toExistAsABuild();
|
||||
expect({ prNum: pr, sha: sha0 }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should return /index.html', done => {
|
||||
const origin = `${scheme}://pr${pr}-${shortSha9}.${host}`;
|
||||
@ -63,17 +74,19 @@ describe(`nginx`, () => {
|
||||
});
|
||||
|
||||
|
||||
it('should return /index.html (for legacy builds)', done => {
|
||||
it('should return /index.html (for legacy builds)', async () => {
|
||||
const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
|
||||
const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /index\\.html$`);
|
||||
|
||||
h.createDummyBuild(pr, sha9, true, false, true);
|
||||
|
||||
Promise.all([
|
||||
await Promise.all([
|
||||
h.runCmd(`curl -iL ${origin}/index.html`).then(h.verifyResponse(200, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${origin}/`).then(h.verifyResponse(200, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${origin}`).then(h.verifyResponse(200, bodyRegex)),
|
||||
]).then(done);
|
||||
]);
|
||||
|
||||
expect({ prNum: pr, sha: sha9, isLegacy: true }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
@ -86,15 +99,15 @@ describe(`nginx`, () => {
|
||||
});
|
||||
|
||||
|
||||
it('should return /foo/bar.js (for legacy builds)', done => {
|
||||
it('should return /foo/bar.js (for legacy builds)', async () => {
|
||||
const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
|
||||
const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /foo/bar\\.js$`);
|
||||
|
||||
h.createDummyBuild(pr, sha9, true, false, true);
|
||||
|
||||
h.runCmd(`curl -iL ${origin}/foo/bar.js`).
|
||||
then(h.verifyResponse(200, bodyRegex)).
|
||||
then(done);
|
||||
await h.runCmd(`curl -iL ${origin}/foo/bar.js`).then(h.verifyResponse(200, bodyRegex));
|
||||
|
||||
expect({ prNum: pr, sha: sha9, isLegacy: true }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
@ -126,7 +139,7 @@ describe(`nginx`, () => {
|
||||
|
||||
it('should respond with 404 for unknown PRs/SHAs', done => {
|
||||
const otherPr = 54321;
|
||||
const otherShortSha = h.getShordSha('8'.repeat(40));
|
||||
const otherShortSha = computeShortSha('8'.repeat(40));
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}9-${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
@ -174,39 +187,41 @@ describe(`nginx`, () => {
|
||||
|
||||
describe('(for hidden builds)', () => {
|
||||
|
||||
it('should respond with 404 for any file or directory', done => {
|
||||
it('should respond with 404 for any file or directory', async () => {
|
||||
const origin = `${scheme}://pr${pr}-${shortSha9}.${host}`;
|
||||
const assert404 = h.verifyResponse(404);
|
||||
|
||||
h.createDummyBuild(pr, sha9, false);
|
||||
expect(h.buildExists(pr, sha9, false)).toBe(true);
|
||||
|
||||
Promise.all([
|
||||
await Promise.all([
|
||||
h.runCmd(`curl -iL ${origin}/index.html`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo/bar.js`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo/`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo`).then(assert404),
|
||||
]).then(done);
|
||||
]);
|
||||
|
||||
expect({ prNum: pr, sha: sha9, isPublic: false }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for any file or directory (for legacy builds)', done => {
|
||||
it('should respond with 404 for any file or directory (for legacy builds)', async () => {
|
||||
const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
|
||||
const assert404 = h.verifyResponse(404);
|
||||
|
||||
h.createDummyBuild(pr, sha9, false, false, true);
|
||||
expect(h.buildExists(pr, sha9, false, true)).toBe(true);
|
||||
|
||||
Promise.all([
|
||||
await Promise.all([
|
||||
h.runCmd(`curl -iL ${origin}/index.html`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo/bar.js`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo/`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo`).then(assert404),
|
||||
]).then(done);
|
||||
]);
|
||||
|
||||
expect({ prNum: pr, sha: sha9, isPublic: false, isLegacy: true }).toExistAsABuild();
|
||||
});
|
||||
|
||||
});
|
||||
@ -238,10 +253,46 @@ describe(`nginx`, () => {
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/create-build/<pr>/<sha>`, () => {
|
||||
describe(`${host}/can-have-public-preview`, () => {
|
||||
const baseUrl = `${scheme}://${host}/can-have-public-preview`;
|
||||
|
||||
|
||||
it('should disallow non-GET requests', async () => {
|
||||
await Promise.all([
|
||||
h.runCmd(`curl -iLX POST ${baseUrl}/42`).then(h.verifyResponse([405, 'Not Allowed'])),
|
||||
h.runCmd(`curl -iLX PUT ${baseUrl}/42`).then(h.verifyResponse([405, 'Not Allowed'])),
|
||||
h.runCmd(`curl -iLX PATCH ${baseUrl}/42`).then(h.verifyResponse([405, 'Not Allowed'])),
|
||||
h.runCmd(`curl -iLX DELETE ${baseUrl}/42`).then(h.verifyResponse([405, 'Not Allowed'])),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should pass requests through to the preview server', async () => {
|
||||
await h.runCmd(`curl -iLX GET ${baseUrl}/${PrNums.CHANGED_FILES_ERROR}`).
|
||||
then(h.verifyResponse(500, /CHANGED_FILES_ERROR/));
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown paths', async () => {
|
||||
const cmdPrefix = `curl -iLX GET ${baseUrl}`;
|
||||
|
||||
await Promise.all([
|
||||
h.runCmd(`${cmdPrefix}/foo/42`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}-foo/42`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}nfoo/42`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/42/foo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/f00`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/`).then(h.verifyResponse(404)),
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/circle-build`, () => {
|
||||
|
||||
it('should disallow non-POST requests', done => {
|
||||
const url = `${scheme}://${host}/create-build/${pr}/${sha9}`;
|
||||
const url = `${scheme}://${host}/circle-build`;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iLX GET ${url}`).then(h.verifyResponse([405, 'Not Allowed'])),
|
||||
@ -252,31 +303,9 @@ describe(`nginx`, () => {
|
||||
});
|
||||
|
||||
|
||||
it(`should reject files larger than ${h.uploadMaxSize}B (according to header)`, done => {
|
||||
const headers = `--header "Content-Length: ${1.5 * h.uploadMaxSize}"`;
|
||||
const url = `${scheme}://${host}/create-build/${pr}/${sha9}`;
|
||||
|
||||
h.runCmd(`curl -iLX POST ${headers} ${url}`).
|
||||
then(h.verifyResponse([413, 'Request Entity Too Large'])).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it(`should reject files larger than ${h.uploadMaxSize}B (without header)`, done => {
|
||||
const filePath = path.join(h.buildsDir, 'snapshot.tar.gz');
|
||||
const url = `${scheme}://${host}/create-build/${pr}/${sha9}`;
|
||||
|
||||
h.writeFile(filePath, {size: 1.5 * h.uploadMaxSize});
|
||||
|
||||
h.runCmd(`curl -iLX POST --data-binary "@${filePath}" ${url}`).
|
||||
then(h.verifyResponse([413, 'Request Entity Too Large'])).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should pass requests through to the upload server', done => {
|
||||
h.runCmd(`curl -iLX POST ${scheme}://${host}/create-build/${pr}/${sha9}`).
|
||||
then(h.verifyResponse(401, /Missing or empty 'AUTHORIZATION' header/)).
|
||||
it('should pass requests through to the preview server', done => {
|
||||
h.runCmd(`curl -iLX POST ${scheme}://${host}/circle-build`).
|
||||
then(h.verifyResponse(400, /Incorrect body content. Expected JSON/)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
@ -285,32 +314,14 @@ describe(`nginx`, () => {
|
||||
const cmdPrefix = `curl -iLX POST ${scheme}://${host}`;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`${cmdPrefix}/foo/create-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/foo-create-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/fooncreate-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-build/foo/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-build-foo/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-buildnfoo/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-build/pr${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-build/${pr}/${sha9}42`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject PRs with leading zeros', done => {
|
||||
h.runCmd(`curl -iLX POST ${scheme}://${host}/create-build/0${pr}/${sha9}`).
|
||||
then(h.verifyResponse(404)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should accept SHAs with leading zeros (but not trim the zeros)', done => {
|
||||
const cmdPrefix = `curl -iLX POST ${scheme}://${host}/create-build/${pr}`;
|
||||
const bodyRegex = /Missing or empty 'AUTHORIZATION' header/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`${cmdPrefix}/0${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/${sha0}`).then(h.verifyResponse(401, bodyRegex)),
|
||||
h.runCmd(`${cmdPrefix}/foo/circle-build/`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/foo-circle-build/`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/fooncircle-build/`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/circle-build/foo/`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/circle-build-foo/`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/circle-buildnfoo/`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/circle-build/pr`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/circle-build/42`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
@ -331,17 +342,13 @@ describe(`nginx`, () => {
|
||||
});
|
||||
|
||||
|
||||
it('should pass requests through to the upload server', done => {
|
||||
it('should pass requests through to the preview server', done => {
|
||||
const cmdPrefix = `curl -iLX POST --header "Content-Type: application/json"`;
|
||||
|
||||
const cmd1 = `${cmdPrefix} ${url}`;
|
||||
const cmd2 = `${cmdPrefix} --data '{"number":${pr}}' ${url}`;
|
||||
const cmd3 = `${cmdPrefix} --data '{"number":${pr},"action":"foo"}' ${url}`;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(cmd1).then(h.verifyResponse(400, /Missing or empty 'number' field/)),
|
||||
h.runCmd(cmd2).then(h.verifyResponse(200)),
|
||||
h.runCmd(cmd3).then(h.verifyResponse(200)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
@ -364,13 +371,15 @@ describe(`nginx`, () => {
|
||||
|
||||
describe(`${host}/*`, () => {
|
||||
|
||||
it('should respond with 404 for unknown URLs (even if the resource exists)', done => {
|
||||
beforeEach(() => {
|
||||
['index.html', 'foo.js', 'foo/index.html'].forEach(relFilePath => {
|
||||
const absFilePath = path.join(h.buildsDir, relFilePath);
|
||||
h.writeFile(absFilePath, {content: `File: /${relFilePath}`});
|
||||
const absFilePath = path.join(AIO_BUILDS_DIR, relFilePath);
|
||||
return h.writeFile(absFilePath, {content: `File: /${relFilePath}`});
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all([
|
||||
it('should respond with 404 for unknown URLs (even if the resource exists)', async () => {
|
||||
await Promise.all([
|
||||
h.runCmd(`curl -iL ${scheme}://${host}/index.html`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://${host}/`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://${host}`).then(h.verifyResponse(404)),
|
||||
@ -379,7 +388,14 @@ describe(`nginx`, () => {
|
||||
h.runCmd(`curl -iL ${scheme}://foo.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://${host}/foo.js`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://${host}/foo/index.html`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
['index.html', 'foo.js', 'foo/index.html', 'foo'].forEach(relFilePath => {
|
||||
const absFilePath = path.join(AIO_BUILDS_DIR, relFilePath);
|
||||
rm('-r', absFilePath);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -0,0 +1,569 @@
|
||||
// Imports
|
||||
import * as fs from 'fs';
|
||||
import {join} from 'path';
|
||||
import {AIO_PREVIEW_SERVER_HOSTNAME, AIO_PREVIEW_SERVER_PORT, AIO_WWW_USER} from '../common/env-variables';
|
||||
import {computeShortSha} from '../common/utils';
|
||||
import {ALT_SHA, BuildNums, PrNums, SHA, SIMILAR_SHA} from './constants';
|
||||
import {helper as h, makeCurl, payload} from './helper';
|
||||
import {customMatchers} from './jasmine-custom-matchers';
|
||||
|
||||
// Tests
|
||||
describe('preview-server', () => {
|
||||
const hostname = AIO_PREVIEW_SERVER_HOSTNAME;
|
||||
const port = AIO_PREVIEW_SERVER_PORT;
|
||||
const host = `http://${hostname}:${port}`;
|
||||
|
||||
beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000);
|
||||
beforeEach(() => jasmine.addMatchers(customMatchers));
|
||||
afterEach(() => h.cleanUp());
|
||||
|
||||
|
||||
describe(`${host}/can-have-public-preview`, () => {
|
||||
const curl = makeCurl(`${host}/can-have-public-preview`, {
|
||||
defaultData: null,
|
||||
defaultExtraPath: `/${PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER}`,
|
||||
defaultHeaders: [],
|
||||
defaultMethod: 'GET',
|
||||
});
|
||||
|
||||
|
||||
it('should disallow non-GET requests', async () => {
|
||||
const bodyRegex = /^Unknown resource in request/;
|
||||
|
||||
await Promise.all([
|
||||
curl({method: 'POST'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({method: 'PUT'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({method: 'PATCH'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({method: 'DELETE'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown paths', async () => {
|
||||
const bodyRegex = /^Unknown resource in request/;
|
||||
|
||||
await Promise.all([
|
||||
curl({extraPath: `/foo/${PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER}`}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({extraPath: `-foo/${PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER}`}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({extraPath: `nfoo/${PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER}`}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({extraPath: `/${PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER}/foo`}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({extraPath: '/f00'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({extraPath: '/'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 500 if checking for significant file changes fails', async () => {
|
||||
await Promise.all([
|
||||
curl({extraPath: `/${PrNums.CHANGED_FILES_404}`}).then(h.verifyResponse(500, /CHANGED_FILES_404/)),
|
||||
curl({extraPath: `/${PrNums.CHANGED_FILES_ERROR}`}).then(h.verifyResponse(500, /CHANGED_FILES_ERROR/)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 200 (false) if no significant files were touched', async () => {
|
||||
const expectedResponse = JSON.stringify({
|
||||
canHavePublicPreview: false,
|
||||
reason: 'No significant files touched.',
|
||||
});
|
||||
|
||||
await curl({extraPath: `/${PrNums.CHANGED_FILES_NONE}`}).then(h.verifyResponse(200, expectedResponse));
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 500 if checking "trusted" status fails', async () => {
|
||||
await curl({extraPath: `/${PrNums.TRUST_CHECK_ERROR}`}).then(h.verifyResponse(500, 'TRUST_CHECK_ERROR'));
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 200 (false) if the PR is not automatically verifiable as "trusted"', async () => {
|
||||
const expectedResponse = JSON.stringify({
|
||||
canHavePublicPreview: false,
|
||||
reason: 'Not automatically verifiable as \\"trusted\\".',
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
curl({extraPath: `/${PrNums.TRUST_CHECK_INACTIVE_TRUSTED_USER}`}).then(h.verifyResponse(200, expectedResponse)),
|
||||
curl({extraPath: `/${PrNums.TRUST_CHECK_UNTRUSTED}`}).then(h.verifyResponse(200, expectedResponse)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 200 (true) if the PR can have a public preview', async () => {
|
||||
const expectedResponse = JSON.stringify({
|
||||
canHavePublicPreview: true,
|
||||
reason: null,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
curl({extraPath: `/${PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER}`}).then(h.verifyResponse(200, expectedResponse)),
|
||||
curl({extraPath: `/${PrNums.TRUST_CHECK_TRUSTED_LABEL}`}).then(h.verifyResponse(200, expectedResponse)),
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/circle-build`, () => {
|
||||
|
||||
const curl = makeCurl(`${host}/circle-build`);
|
||||
|
||||
it('should disallow non-POST requests', async () => {
|
||||
const bodyRegex = /^Unknown resource/;
|
||||
|
||||
await Promise.all([
|
||||
curl({method: 'GET'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({method: 'PUT'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({method: 'PATCH'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({method: 'DELETE'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown paths', async () => {
|
||||
await Promise.all([
|
||||
curl({url: `${host}/foo/circle-build`}).then(h.verifyResponse(404)),
|
||||
curl({url: `${host}/foo-circle-build`}).then(h.verifyResponse(404)),
|
||||
curl({url: `${host}/fooncircle-build`}).then(h.verifyResponse(404)),
|
||||
curl({url: `${host}/circle-build/foo`}).then(h.verifyResponse(404)),
|
||||
curl({url: `${host}/circle-build-foo`}).then(h.verifyResponse(404)),
|
||||
curl({url: `${host}/circle-buildnfoo`}).then(h.verifyResponse(404)),
|
||||
curl({url: `${host}/circle-build/pr`}).then(h.verifyResponse(404)),
|
||||
curl({url: `${host}/circle-build42`}).then(h.verifyResponse(404)),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should respond with 400 if the body is not valid', async () => {
|
||||
await Promise.all([
|
||||
curl({ data: '' }).then(h.verifyResponse(400)),
|
||||
curl({ data: {} }).then(h.verifyResponse(400)),
|
||||
curl({ data: { payload: {} } }).then(h.verifyResponse(400)),
|
||||
curl({ data: { payload: { build_num: 1 } } }).then(h.verifyResponse(400)),
|
||||
curl({ data: { payload: { build_num: 1, build_parameters: {} } } }).then(h.verifyResponse(400)),
|
||||
curl(payload(0)).then(h.verifyResponse(400)),
|
||||
curl(payload(-1)).then(h.verifyResponse(400)),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should respond with 500 if the CircleCI API request errors', async () => {
|
||||
await curl(payload(BuildNums.BUILD_INFO_ERROR)).then(h.verifyResponse(500));
|
||||
await curl(payload(BuildNums.BUILD_INFO_404)).then(h.verifyResponse(500));
|
||||
});
|
||||
|
||||
it('should respond with 204 if the build on CircleCI failed', async () => {
|
||||
await curl(payload(BuildNums.BUILD_INFO_BUILD_FAILED)).then(h.verifyResponse(204));
|
||||
});
|
||||
|
||||
it('should respond with 500 if the github org from CircleCI does not match what is configured', async () => {
|
||||
await curl(payload(BuildNums.BUILD_INFO_INVALID_GH_ORG)).then(h.verifyResponse(500));
|
||||
});
|
||||
|
||||
it('should respond with 500 if the github repo from CircleCI does not match what is configured', async () => {
|
||||
await curl(payload(BuildNums.BUILD_INFO_INVALID_GH_REPO)).then(h.verifyResponse(500));
|
||||
});
|
||||
|
||||
it('should respond with 500 if the github files API errors', async () => {
|
||||
await curl(payload(BuildNums.CHANGED_FILES_ERROR)).then(h.verifyResponse(500));
|
||||
await curl(payload(BuildNums.CHANGED_FILES_404)).then(h.verifyResponse(500));
|
||||
});
|
||||
|
||||
it('should respond with 204 if no significant files are changed by the PR', async () => {
|
||||
await curl(payload(BuildNums.CHANGED_FILES_NONE)).then(h.verifyResponse(204));
|
||||
});
|
||||
|
||||
it('should respond with 500 if the CircleCI artifact API fails', async () => {
|
||||
await curl(payload(BuildNums.BUILD_ARTIFACTS_ERROR)).then(h.verifyResponse(500));
|
||||
await curl(payload(BuildNums.BUILD_ARTIFACTS_404)).then(h.verifyResponse(500));
|
||||
await curl(payload(BuildNums.BUILD_ARTIFACTS_EMPTY)).then(h.verifyResponse(500));
|
||||
await curl(payload(BuildNums.BUILD_ARTIFACTS_MISSING)).then(h.verifyResponse(500));
|
||||
});
|
||||
|
||||
it('should respond with 500 if fetching the artifact errors', async () => {
|
||||
await curl(payload(BuildNums.DOWNLOAD_ARTIFACT_ERROR)).then(h.verifyResponse(500));
|
||||
await curl(payload(BuildNums.DOWNLOAD_ARTIFACT_404)).then(h.verifyResponse(500));
|
||||
});
|
||||
|
||||
it('should respond with 500 if the GH trusted API fails', async () => {
|
||||
await curl(payload(BuildNums.TRUST_CHECK_ERROR)).then(h.verifyResponse(500));
|
||||
expect({ prNum: PrNums.TRUST_CHECK_ERROR }).toExistAsAnArtifact();
|
||||
});
|
||||
|
||||
it('should respond with 201 if a new public build is created', async () => {
|
||||
await curl(payload(BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER))
|
||||
.then(h.verifyResponse(201));
|
||||
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER }).toExistAsABuild();
|
||||
});
|
||||
|
||||
it('should respond with 202 if a new private build is created', async () => {
|
||||
await curl(payload(BuildNums.TRUST_CHECK_UNTRUSTED)).then(h.verifyResponse(202));
|
||||
expect({ prNum: PrNums.TRUST_CHECK_UNTRUSTED, isPublic: false }).toExistAsABuild();
|
||||
});
|
||||
|
||||
[true].forEach(isPublic => {
|
||||
const build = isPublic ? BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER : BuildNums.TRUST_CHECK_UNTRUSTED;
|
||||
const prNum = isPublic ? PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER : PrNums.TRUST_CHECK_UNTRUSTED;
|
||||
const label = isPublic ? 'public' : 'non-public';
|
||||
const overwriteRe = RegExp(`^Request to overwrite existing ${label} directory`);
|
||||
const statusCode = isPublic ? 201 : 202;
|
||||
|
||||
describe(`for ${label} builds`, () => {
|
||||
|
||||
it('should extract the contents of the build artifact', async () => {
|
||||
await curl(payload(build))
|
||||
.then(h.verifyResponse(statusCode));
|
||||
expect(h.readBuildFile(prNum, SHA, 'index.html', isPublic))
|
||||
.toContain(`PR: ${prNum} | SHA: ${SHA} | File: /index.html`);
|
||||
expect(h.readBuildFile(prNum, SHA, 'foo/bar.js', isPublic))
|
||||
.toContain(`PR: ${prNum} | SHA: ${SHA} | File: /foo/bar.js`);
|
||||
expect({ prNum, isPublic }).toExistAsABuild();
|
||||
});
|
||||
|
||||
it(`should create files/directories owned by '${AIO_WWW_USER}'`, async () => {
|
||||
await curl(payload(build))
|
||||
.then(h.verifyResponse(statusCode));
|
||||
|
||||
const shaDir = h.getShaDir(h.getPrDir(prNum, isPublic), SHA);
|
||||
const { stdout: allFiles } = await h.runCmd(`find ${shaDir}`);
|
||||
const { stdout: userFiles } = await h.runCmd(`find ${shaDir} -user ${AIO_WWW_USER}`);
|
||||
|
||||
expect(userFiles).toBe(allFiles);
|
||||
expect(userFiles).toContain(shaDir);
|
||||
expect(userFiles).toContain(join(shaDir, 'index.html'));
|
||||
expect(userFiles).toContain(join(shaDir, 'foo', 'bar.js'));
|
||||
|
||||
expect({ prNum, isPublic }).toExistAsABuild();
|
||||
});
|
||||
|
||||
it('should delete the build artifact file', async () => {
|
||||
await curl(payload(build))
|
||||
.then(h.verifyResponse(statusCode));
|
||||
expect({ prNum, SHA }).not.toExistAsAnArtifact();
|
||||
expect({ prNum, isPublic }).toExistAsABuild();
|
||||
});
|
||||
|
||||
it('should make the build directory non-writable', async () => {
|
||||
await curl(payload(build))
|
||||
.then(h.verifyResponse(statusCode));
|
||||
|
||||
// See https://github.com/nodejs/node-v0.x-archive/issues/3045#issuecomment-4862588.
|
||||
const isNotWritable = (fileOrDir: string) => {
|
||||
const mode = fs.statSync(fileOrDir).mode;
|
||||
// tslint:disable-next-line: no-bitwise
|
||||
return !(mode & parseInt('222', 8));
|
||||
};
|
||||
|
||||
const shaDir = h.getShaDir(h.getPrDir(prNum, isPublic), SHA);
|
||||
expect(isNotWritable(shaDir)).toBe(true);
|
||||
expect(isNotWritable(join(shaDir, 'index.html'))).toBe(true);
|
||||
expect(isNotWritable(join(shaDir, 'foo', 'bar.js'))).toBe(true);
|
||||
|
||||
expect({ prNum, isPublic }).toExistAsABuild();
|
||||
});
|
||||
|
||||
it('should ignore a legacy 40-chars long build directory (even if it starts with the same chars)',
|
||||
async () => {
|
||||
// It is possible that 40-chars long build directories exist, if they had been deployed
|
||||
// before implementing the shorter build directory names. In that case, we don't want the
|
||||
// second (shorter) name to be considered the same as the old one (even if they originate
|
||||
// from the same SHA).
|
||||
|
||||
h.createDummyBuild(prNum, SHA, isPublic, false, true);
|
||||
h.writeBuildFile(prNum, SHA, 'index.html', 'My content', isPublic, true);
|
||||
expect(h.readBuildFile(prNum, SHA, 'index.html', isPublic, true)).toBe('My content');
|
||||
|
||||
await curl(payload(build))
|
||||
.then(h.verifyResponse(statusCode));
|
||||
|
||||
expect(h.readBuildFile(prNum, SHA, 'index.html', isPublic, false)).toContain('index.html');
|
||||
expect(h.readBuildFile(prNum, SHA, 'index.html', isPublic, true)).toBe('My content');
|
||||
|
||||
expect({ prNum, isPublic, sha: SHA, isLegacy: false }).toExistAsABuild();
|
||||
expect({ prNum, isPublic, sha: SHA, isLegacy: true }).toExistAsABuild();
|
||||
});
|
||||
|
||||
it(`should not overwrite existing builds`, async () => {
|
||||
// setup a build already in place
|
||||
h.createDummyBuild(prNum, SHA, isPublic);
|
||||
// distinguish this build from the downloaded one
|
||||
h.writeBuildFile(prNum, SHA, 'index.html', 'My content', isPublic);
|
||||
await curl(payload(build)).then(h.verifyResponse(409, overwriteRe));
|
||||
expect(h.readBuildFile(prNum, SHA, 'index.html', isPublic)).toBe('My content');
|
||||
expect({ prNum, isPublic }).toExistAsABuild();
|
||||
expect({ prNum }).toExistAsAnArtifact();
|
||||
});
|
||||
|
||||
it(`should not overwrite existing builds (even if the SHA is different)`, async () => {
|
||||
// Since only the first few characters of the SHA are used, it is possible for two different
|
||||
// SHAs to correspond to the same directory. In that case, we don't want the second SHA to
|
||||
// overwrite the first.
|
||||
expect(SIMILAR_SHA).not.toEqual(SHA);
|
||||
expect(computeShortSha(SIMILAR_SHA)).toEqual(computeShortSha(SHA));
|
||||
h.createDummyBuild(prNum, SIMILAR_SHA, isPublic);
|
||||
expect(h.readBuildFile(prNum, SIMILAR_SHA, 'index.html', isPublic)).toContain('index.html');
|
||||
h.writeBuildFile(prNum, SIMILAR_SHA, 'index.html', 'My content', isPublic);
|
||||
expect(h.readBuildFile(prNum, SIMILAR_SHA, 'index.html', isPublic)).toBe('My content');
|
||||
|
||||
await curl(payload(build)).then(h.verifyResponse(409, overwriteRe));
|
||||
expect(h.readBuildFile(prNum, SIMILAR_SHA, 'index.html', isPublic)).toBe('My content');
|
||||
expect({ prNum, isPublic, sha: SIMILAR_SHA }).toExistAsABuild();
|
||||
expect({ prNum, sha: SIMILAR_SHA }).toExistAsAnArtifact();
|
||||
});
|
||||
|
||||
it('should only delete the SHA directory on error (for existing PR)', async () => {
|
||||
h.createDummyBuild(prNum, ALT_SHA, isPublic);
|
||||
await curl(payload(BuildNums.TRUST_CHECK_ERROR)).then(h.verifyResponse(500));
|
||||
expect({ prNum: PrNums.TRUST_CHECK_ERROR }).toExistAsAnArtifact();
|
||||
expect({ prNum, isPublic, sha: SHA }).not.toExistAsABuild();
|
||||
expect({ prNum, isPublic, sha: ALT_SHA }).toExistAsABuild();
|
||||
});
|
||||
|
||||
describe('when the PR\'s visibility has changed', () => {
|
||||
|
||||
it('should update the PR\'s visibility', async () => {
|
||||
h.createDummyBuild(prNum, ALT_SHA, !isPublic);
|
||||
await curl(payload(build)).then(h.verifyResponse(statusCode));
|
||||
expect({ prNum, isPublic }).toExistAsABuild();
|
||||
expect({ prNum, isPublic, sha: ALT_SHA }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should not overwrite existing builds (but keep the updated visibility)', async () => {
|
||||
h.createDummyBuild(prNum, SHA, !isPublic);
|
||||
await curl(payload(build)).then(h.verifyResponse(409));
|
||||
expect({ prNum, isPublic }).toExistAsABuild();
|
||||
expect({ prNum, isPublic: !isPublic }).not.toExistAsABuild();
|
||||
// since it errored we didn't clear up the downloaded artifact - perhaps we should?
|
||||
expect({ prNum }).toExistAsAnArtifact();
|
||||
});
|
||||
|
||||
|
||||
it('should reject the request if it fails to update the PR\'s visibility', async () => {
|
||||
// One way to cause an error is to have both a public and a hidden directory for the same PR.
|
||||
h.createDummyBuild(prNum, ALT_SHA, isPublic);
|
||||
h.createDummyBuild(prNum, ALT_SHA, !isPublic);
|
||||
|
||||
const errorRegex = new RegExp(`^Request to move '${h.getPrDir(prNum, !isPublic)}' ` +
|
||||
`to existing directory '${h.getPrDir(prNum, isPublic)}'.`);
|
||||
|
||||
await curl(payload(build)).then(h.verifyResponse(409, errorRegex));
|
||||
|
||||
expect({ prNum, isPublic }).not.toExistAsABuild();
|
||||
|
||||
// The bad folders should have been deleted
|
||||
expect({ prNum, sha: ALT_SHA, isPublic }).toExistAsABuild();
|
||||
expect({ prNum, sha: ALT_SHA, isPublic: !isPublic }).toExistAsABuild();
|
||||
|
||||
// since it errored we didn't clear up the downloaded artifact - perhaps we should?
|
||||
expect({ prNum }).toExistAsAnArtifact();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/health-check`, () => {
|
||||
|
||||
it('should respond with 200', done => {
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${host}/health-check`).then(h.verifyResponse(200)),
|
||||
h.runCmd(`curl -iL ${host}/health-check/`).then(h.verifyResponse(200)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 if the path does not match exactly', done => {
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${host}/health-check/foo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${host}/health-check-foo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${host}/health-checknfoo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${host}/foo/health-check`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${host}/foo-health-check`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${host}/foonhealth-check`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/pr-updated`, () => {
|
||||
const curl = makeCurl(`${host}/pr-updated`);
|
||||
|
||||
it('should disallow non-POST requests', async () => {
|
||||
const bodyRegex = /^Unknown resource in request/;
|
||||
|
||||
await Promise.all([
|
||||
curl({method: 'GET'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({method: 'PUT'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({method: 'PATCH'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
curl({method: 'DELETE'}).then(h.verifyResponse(404, bodyRegex)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 400 for requests without a payload', async () => {
|
||||
const bodyRegex = /^Missing or empty 'number' field in request/;
|
||||
|
||||
await Promise.all([
|
||||
curl({ data: '' }).then(h.verifyResponse(400, bodyRegex)),
|
||||
curl({ data: {} }).then(h.verifyResponse(400, bodyRegex)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 400 for requests without a \'number\' field', async () => {
|
||||
const bodyRegex = /^Missing or empty 'number' field in request/;
|
||||
|
||||
await Promise.all([
|
||||
curl({ data: {} }).then(h.verifyResponse(400, bodyRegex)),
|
||||
curl({ data: { number: null} }).then(h.verifyResponse(400, bodyRegex)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should reject requests for which checking the PR visibility fails', async () => {
|
||||
await curl({ data: { number: PrNums.TRUST_CHECK_ERROR } }).then(h.verifyResponse(500, /TRUST_CHECK_ERROR/));
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown paths', done => {
|
||||
const mockPayload = JSON.stringify({number: 1}); // MockExternalApiFlags.TRUST_CHECK_ACTIVE_TRUSTED_USER });
|
||||
const cmdPrefix = `curl -iLX POST --data "${mockPayload}" ${host}`;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`${cmdPrefix}/foo/pr-updated`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/foo-pr-updated`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/foonpr-updated`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/pr-updated/foo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/pr-updated-foo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/pr-updatednfoo`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should do nothing if PR\'s visibility is already up-to-date', async () => {
|
||||
const publicPr = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
const hiddenPr = PrNums.TRUST_CHECK_UNTRUSTED;
|
||||
|
||||
const checkVisibilities = (remove: boolean) => {
|
||||
// Public build is already public.
|
||||
expect({ prNum: publicPr, isPublic: false }).not.toExistAsABuild(remove);
|
||||
expect({ prNum: publicPr, isPublic: true }).toExistAsABuild(remove);
|
||||
// Hidden build is already hidden.
|
||||
expect({ prNum: hiddenPr, isPublic: false }).toExistAsABuild(remove);
|
||||
expect({ prNum: hiddenPr, isPublic: true }).not.toExistAsABuild(remove);
|
||||
};
|
||||
|
||||
h.createDummyBuild(publicPr, SHA, true);
|
||||
h.createDummyBuild(hiddenPr, SHA, false);
|
||||
checkVisibilities(false);
|
||||
|
||||
await Promise.all([
|
||||
curl({ data: {number: +publicPr, action: 'foo' } }).then(h.verifyResponse(200)),
|
||||
curl({ data: {number: +hiddenPr, action: 'foo' } }).then(h.verifyResponse(200)),
|
||||
]);
|
||||
|
||||
// Visibilities should not have changed, because the specified action could not have triggered a change.
|
||||
checkVisibilities(true);
|
||||
});
|
||||
|
||||
|
||||
it('should do nothing if \'action\' implies no visibility change', async () => {
|
||||
const publicPr = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
const hiddenPr = PrNums.TRUST_CHECK_UNTRUSTED;
|
||||
|
||||
const checkVisibilities = (remove: boolean) => {
|
||||
// Public build is hidden atm.
|
||||
expect({ prNum: publicPr, isPublic: false }).toExistAsABuild(remove);
|
||||
expect({ prNum: publicPr, isPublic: true }).not.toExistAsABuild(remove);
|
||||
// Hidden build is public atm.
|
||||
expect({ prNum: hiddenPr, isPublic: false }).not.toExistAsABuild(remove);
|
||||
expect({ prNum: hiddenPr, isPublic: true }).toExistAsABuild(remove);
|
||||
};
|
||||
|
||||
h.createDummyBuild(publicPr, SHA, false);
|
||||
h.createDummyBuild(hiddenPr, SHA, true);
|
||||
checkVisibilities(false);
|
||||
|
||||
await Promise.all([
|
||||
curl({ data: {number: +publicPr, action: 'foo' } }).then(h.verifyResponse(200)),
|
||||
curl({ data: {number: +hiddenPr, action: 'foo' } }).then(h.verifyResponse(200)),
|
||||
]);
|
||||
// Visibilities should not have changed, because the specified action could not have triggered a change.
|
||||
checkVisibilities(true);
|
||||
});
|
||||
|
||||
|
||||
describe('when the visiblity has changed', () => {
|
||||
const publicPr = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
const hiddenPr = PrNums.TRUST_CHECK_UNTRUSTED;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create initial PR builds with opposite visibilities as the ones that will be reported:
|
||||
// - The now public PR was previously hidden.
|
||||
// - The now hidden PR was previously public.
|
||||
h.createDummyBuild(publicPr, SHA, false);
|
||||
h.createDummyBuild(hiddenPr, SHA, true);
|
||||
|
||||
expect({ prNum: publicPr, isPublic: false }).toExistAsABuild(false);
|
||||
expect({ prNum: publicPr, isPublic: true }).not.toExistAsABuild(false);
|
||||
expect({ prNum: hiddenPr, isPublic: false }).not.toExistAsABuild(false);
|
||||
expect({ prNum: hiddenPr, isPublic: true }).toExistAsABuild(false);
|
||||
});
|
||||
afterEach(() => {
|
||||
// Expect PRs' visibility to have been updated:
|
||||
// - The public PR should be actually public (previously it was hidden).
|
||||
// - The hidden PR should be actually hidden (previously it was public).
|
||||
expect({ prNum: publicPr, isPublic: false }).not.toExistAsABuild();
|
||||
expect({ prNum: publicPr, isPublic: true }).toExistAsABuild();
|
||||
expect({ prNum: hiddenPr, isPublic: false }).toExistAsABuild();
|
||||
expect({ prNum: hiddenPr, isPublic: true }).not.toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should update the PR\'s visibility (action: undefined)', async () => {
|
||||
await Promise.all([
|
||||
curl({ data: {number: +publicPr } }).then(h.verifyResponse(200)),
|
||||
curl({ data: {number: +hiddenPr } }).then(h.verifyResponse(200)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should update the PR\'s visibility (action: labeled)', async () => {
|
||||
await Promise.all([
|
||||
curl({ data: {number: +publicPr, action: 'labeled' } }).then(h.verifyResponse(200)),
|
||||
curl({ data: {number: +hiddenPr, action: 'labeled' } }).then(h.verifyResponse(200)),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should update the PR\'s visibility (action: unlabeled)', async () => {
|
||||
await Promise.all([
|
||||
curl({ data: {number: +publicPr, action: 'unlabeled' } }).then(h.verifyResponse(200)),
|
||||
curl({ data: {number: +hiddenPr, action: 'unlabeled' } }).then(h.verifyResponse(200)),
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/*`, () => {
|
||||
|
||||
it('should respond with 404 for requests to unknown URLs', done => {
|
||||
const bodyRegex = /^Unknown resource/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${host}/index.html`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${host}/`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX PUT ${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX POST ${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX PATCH ${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX DELETE ${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
@ -1,101 +1,80 @@
|
||||
// Imports
|
||||
import * as path from 'path';
|
||||
import * as c from './constants';
|
||||
import {helper as h} from './helper';
|
||||
import {AIO_NGINX_HOSTNAME} from '../common/env-variables';
|
||||
import {computeShortSha} from '../common/utils';
|
||||
import {ALT_SHA, BuildNums, PrNums, SHA} from './constants';
|
||||
import {helper as h, makeCurl, payload} from './helper';
|
||||
import {customMatchers} from './jasmine-custom-matchers';
|
||||
|
||||
// Tests
|
||||
h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme.toUpperCase()})`, () => {
|
||||
const hostname = h.nginxHostname;
|
||||
const hostname = AIO_NGINX_HOSTNAME;
|
||||
const host = `${hostname}:${port}`;
|
||||
const pr9 = '9';
|
||||
const sha9 = '9'.repeat(40);
|
||||
const sha0 = '0'.repeat(40);
|
||||
const archivePath = path.join(h.buildsDir, 'snapshot.tar.gz');
|
||||
const curlPrUpdated = makeCurl(`${scheme}://${host}/pr-updated`);
|
||||
|
||||
const getFile = (pr: string, sha: string, file: string) =>
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${h.getShordSha(sha)}.${host}/${file}`);
|
||||
const uploadBuild = (pr: string, sha: string, archive: string, authHeader = 'Token FOO') => {
|
||||
const curlPost = `curl -iLX POST --header "Authorization: ${authHeader}"`;
|
||||
return h.runCmd(`${curlPost} --data-binary "@${archive}" ${scheme}://${host}/create-build/${pr}/${sha}`);
|
||||
};
|
||||
const prUpdated = (pr: number, action?: string) => {
|
||||
const url = `${scheme}://${host}/pr-updated`;
|
||||
const payloadStr = JSON.stringify({number: pr, action});
|
||||
return h.runCmd(`curl -iLX POST --header "Content-Type: application/json" --data '${payloadStr}' ${url}`);
|
||||
};
|
||||
const getFile = (pr: number, sha: string, file: string) =>
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${computeShortSha(sha)}.${host}/${file}`);
|
||||
const prUpdated = (prNum: number, action?: string) => curlPrUpdated({ data: { number: prNum, action } });
|
||||
const circleBuild = makeCurl(`${scheme}://${host}/circle-build`);
|
||||
|
||||
beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000);
|
||||
afterEach(() => {
|
||||
h.deletePrDir(pr9);
|
||||
h.deletePrDir(pr9, false);
|
||||
h.cleanUp();
|
||||
beforeEach(() => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000;
|
||||
jasmine.addMatchers(customMatchers);
|
||||
});
|
||||
afterEach(() => h.cleanUp());
|
||||
|
||||
|
||||
describe('for a new/non-existing PR', () => {
|
||||
|
||||
it('should be able to upload and serve a public build', done => {
|
||||
const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
it('should be able to create and serve a public preview', async () => {
|
||||
const BUILD = BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
const PR = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
const regexPrefix = `^BUILD: ${BUILD} \\| PR: ${PR} \\| SHA: ${SHA} \\| File:`;
|
||||
const idxContentRegex = new RegExp(`${regexPrefix} \\/index\\.html$`);
|
||||
const barContentRegex = new RegExp(`${regexPrefix} \\/foo\\/bar\\.js$`);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath).
|
||||
then(() => Promise.all([
|
||||
getFile(pr9, sha9, 'index.html').then(h.verifyResponse(200, idxContentRegex9)),
|
||||
getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex9)),
|
||||
])).
|
||||
then(done);
|
||||
await circleBuild(payload(BUILD)).then(h.verifyResponse(201));
|
||||
await Promise.all([
|
||||
getFile(PR, SHA, 'index.html').then(h.verifyResponse(200, idxContentRegex)),
|
||||
getFile(PR, SHA, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex)),
|
||||
]);
|
||||
|
||||
expect({ prNum: PR }).toExistAsABuild();
|
||||
expect({ prNum: PR, isPublic: false }).not.toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should be able to upload but not serve a hidden build', done => {
|
||||
const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
it('should be able to create but not serve a hidden preview', async () => {
|
||||
const BUILD = BuildNums.TRUST_CHECK_UNTRUSTED;
|
||||
const PR = PrNums.TRUST_CHECK_UNTRUSTED;
|
||||
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
await circleBuild(payload(BUILD)).then(h.verifyResponse(202));
|
||||
await Promise.all([
|
||||
getFile(PR, SHA, 'index.html').then(h.verifyResponse(404)),
|
||||
getFile(PR, SHA, 'foo/bar.js').then(h.verifyResponse(404)),
|
||||
]);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath, c.BV_verify_verifiedNotTrusted).
|
||||
then(() => Promise.all([
|
||||
getFile(pr9, sha9, 'index.html').then(h.verifyResponse(404)),
|
||||
getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(404)),
|
||||
])).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr9, sha9)).toBe(false);
|
||||
expect(h.buildExists(pr9, sha9, false)).toBe(true);
|
||||
expect(h.readBuildFile(pr9, sha9, 'index.html', false)).toMatch(idxContentRegex9);
|
||||
expect(h.readBuildFile(pr9, sha9, 'foo/bar.js', false)).toMatch(barContentRegex9);
|
||||
}).
|
||||
then(done);
|
||||
expect({ prNum: PR }).not.toExistAsABuild();
|
||||
expect({ prNum: PR, isPublic: false }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should reject an upload if verification fails', done => {
|
||||
const errorRegex9 = new RegExp(`Error while verifying upload for PR ${pr9}: Test`);
|
||||
it('should reject if verification fails', async () => {
|
||||
const BUILD = BuildNums.TRUST_CHECK_ERROR;
|
||||
const PR = PrNums.TRUST_CHECK_ERROR;
|
||||
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath, c.BV_verify_error).
|
||||
then(h.verifyResponse(403, errorRegex9)).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr9)).toBe(false);
|
||||
expect(h.buildExists(pr9, '', false)).toBe(false);
|
||||
}).
|
||||
then(done);
|
||||
await circleBuild(payload(BUILD)).then(h.verifyResponse(500));
|
||||
expect({ prNum: PR }).toExistAsAnArtifact();
|
||||
expect({ prNum: PR }).not.toExistAsABuild();
|
||||
expect({ prNum: PR, isPublic: false }).not.toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should be able to notify that a PR has been updated (and do nothing)', done => {
|
||||
prUpdated(+pr9).
|
||||
then(h.verifyResponse(200)).
|
||||
then(() => {
|
||||
it('should be able to notify that a PR has been updated (and do nothing)', async () => {
|
||||
await prUpdated(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER).then(h.verifyResponse(200));
|
||||
// The PR should still not exist.
|
||||
expect(h.buildExists(pr9, '', false)).toBe(false);
|
||||
expect(h.buildExists(pr9, '', true)).toBe(false);
|
||||
}).
|
||||
then(done);
|
||||
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: false }).not.toExistAsABuild();
|
||||
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: true }).not.toExistAsABuild();
|
||||
});
|
||||
|
||||
});
|
||||
@ -103,215 +82,186 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme
|
||||
|
||||
describe('for an existing PR', () => {
|
||||
|
||||
it('should be able to upload and serve a public build', done => {
|
||||
const regexPrefix0 = `^PR: ${pr9} \\| SHA: ${sha0} \\| File:`;
|
||||
const idxContentRegex0 = new RegExp(`${regexPrefix0} \\/index\\.html$`);
|
||||
const barContentRegex0 = new RegExp(`${regexPrefix0} \\/foo\\/bar\\.js$`);
|
||||
it('should be able to create and serve a public preview', async () => {
|
||||
const BUILD = BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
const PR = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
|
||||
const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
const regexPrefix1 = `^PR: ${PR} \\| SHA: ${ALT_SHA} \\| File:`;
|
||||
const idxContentRegex1 = new RegExp(`${regexPrefix1} \\/index\\.html$`);
|
||||
const barContentRegex1 = new RegExp(`${regexPrefix1} \\/foo\\/bar\\.js$`);
|
||||
|
||||
h.createDummyBuild(pr9, sha0);
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
const regexPrefix2 = `^BUILD: ${BUILD} \\| PR: ${PR} \\| SHA: ${SHA} \\| File:`;
|
||||
const idxContentRegex2 = new RegExp(`${regexPrefix2} \\/index\\.html$`);
|
||||
const barContentRegex2 = new RegExp(`${regexPrefix2} \\/foo\\/bar\\.js$`);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath).
|
||||
then(() => Promise.all([
|
||||
getFile(pr9, sha0, 'index.html').then(h.verifyResponse(200, idxContentRegex0)),
|
||||
getFile(pr9, sha0, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex0)),
|
||||
getFile(pr9, sha9, 'index.html').then(h.verifyResponse(200, idxContentRegex9)),
|
||||
getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex9)),
|
||||
])).
|
||||
then(done);
|
||||
h.createDummyBuild(PR, ALT_SHA);
|
||||
await circleBuild(payload(BUILD)).then(h.verifyResponse(201));
|
||||
await Promise.all([
|
||||
getFile(PR, ALT_SHA, 'index.html').then(h.verifyResponse(200, idxContentRegex1)),
|
||||
getFile(PR, ALT_SHA, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex1)),
|
||||
getFile(PR, SHA, 'index.html').then(h.verifyResponse(200, idxContentRegex2)),
|
||||
getFile(PR, SHA, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex2)),
|
||||
]);
|
||||
|
||||
expect({ prNum: PR, sha: SHA }).toExistAsABuild();
|
||||
expect({ prNum: PR, sha: ALT_SHA }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should be able to upload but not serve a hidden build', done => {
|
||||
const regexPrefix0 = `^PR: ${pr9} \\| SHA: ${sha0} \\| File:`;
|
||||
const idxContentRegex0 = new RegExp(`${regexPrefix0} \\/index\\.html$`);
|
||||
const barContentRegex0 = new RegExp(`${regexPrefix0} \\/foo\\/bar\\.js$`);
|
||||
it('should be able to create but not serve a hidden preview', async () => {
|
||||
const BUILD = BuildNums.TRUST_CHECK_UNTRUSTED;
|
||||
const PR = PrNums.TRUST_CHECK_UNTRUSTED;
|
||||
|
||||
const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
h.createDummyBuild(PR, ALT_SHA, false);
|
||||
await circleBuild(payload(BUILD)).then(h.verifyResponse(202));
|
||||
|
||||
h.createDummyBuild(pr9, sha0, false);
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
await Promise.all([
|
||||
getFile(PR, ALT_SHA, 'index.html').then(h.verifyResponse(404)),
|
||||
getFile(PR, ALT_SHA, 'foo/bar.js').then(h.verifyResponse(404)),
|
||||
getFile(PR, SHA, 'index.html').then(h.verifyResponse(404)),
|
||||
getFile(PR, SHA, 'foo/bar.js').then(h.verifyResponse(404)),
|
||||
]);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath, c.BV_verify_verifiedNotTrusted).
|
||||
then(() => Promise.all([
|
||||
getFile(pr9, sha0, 'index.html').then(h.verifyResponse(404)),
|
||||
getFile(pr9, sha0, 'foo/bar.js').then(h.verifyResponse(404)),
|
||||
getFile(pr9, sha9, 'index.html').then(h.verifyResponse(404)),
|
||||
getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(404)),
|
||||
])).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr9, sha9)).toBe(false);
|
||||
expect(h.buildExists(pr9, sha9, false)).toBe(true);
|
||||
expect(h.readBuildFile(pr9, sha0, 'index.html', false)).toMatch(idxContentRegex0);
|
||||
expect(h.readBuildFile(pr9, sha0, 'foo/bar.js', false)).toMatch(barContentRegex0);
|
||||
expect(h.readBuildFile(pr9, sha9, 'index.html', false)).toMatch(idxContentRegex9);
|
||||
expect(h.readBuildFile(pr9, sha9, 'foo/bar.js', false)).toMatch(barContentRegex9);
|
||||
}).
|
||||
then(done);
|
||||
expect({ prNum: PR, sha: SHA }).not.toExistAsABuild();
|
||||
expect({ prNum: PR, sha: SHA, isPublic: false }).toExistAsABuild();
|
||||
expect({ prNum: PR, sha: ALT_SHA }).not.toExistAsABuild();
|
||||
expect({ prNum: PR, sha: ALT_SHA, isPublic: false }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should reject an upload if verification fails', done => {
|
||||
const errorRegex9 = new RegExp(`Error while verifying upload for PR ${pr9}: Test`);
|
||||
it('should reject if verification fails', async () => {
|
||||
const BUILD = BuildNums.TRUST_CHECK_ERROR;
|
||||
const PR = PrNums.TRUST_CHECK_ERROR;
|
||||
|
||||
h.createDummyBuild(pr9, sha0);
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
h.createDummyBuild(PR, ALT_SHA, false);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath, c.BV_verify_error).
|
||||
then(h.verifyResponse(403, errorRegex9)).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr9)).toBe(true);
|
||||
expect(h.buildExists(pr9, sha0)).toBe(true);
|
||||
expect(h.buildExists(pr9, sha9)).toBe(false);
|
||||
}).
|
||||
then(done);
|
||||
await circleBuild(payload(BUILD)).then(h.verifyResponse(500));
|
||||
|
||||
expect({ prNum: PR }).toExistAsAnArtifact();
|
||||
expect({ prNum: PR }).not.toExistAsABuild();
|
||||
expect({ prNum: PR, isPublic: false }).not.toExistAsABuild();
|
||||
expect({ prNum: PR, sha: ALT_SHA, isPublic: false }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should not be able to overwrite an existing public build', done => {
|
||||
const regexPrefix9 = `^PR: ${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
it('should not be able to overwrite an existing public preview', async () => {
|
||||
const BUILD = BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
const PR = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
|
||||
h.createDummyBuild(pr9, sha9);
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
const regexPrefix = `^PR: ${PR} \\| SHA: ${SHA} \\| File:`;
|
||||
const idxContentRegex = new RegExp(`${regexPrefix} \\/index\\.html$`);
|
||||
const barContentRegex = new RegExp(`${regexPrefix} \\/foo\\/bar\\.js$`);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath).
|
||||
then(h.verifyResponse(409)).
|
||||
then(() => Promise.all([
|
||||
getFile(pr9, sha9, 'index.html').then(h.verifyResponse(200, idxContentRegex9)),
|
||||
getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex9)),
|
||||
])).
|
||||
then(done);
|
||||
h.createDummyBuild(PR, SHA);
|
||||
|
||||
await circleBuild(payload(BUILD)).then(h.verifyResponse(409));
|
||||
await Promise.all([
|
||||
getFile(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, SHA, 'index.html').then(h.verifyResponse(200, idxContentRegex)),
|
||||
getFile(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, SHA, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex)),
|
||||
]);
|
||||
|
||||
expect({ prNum: PR }).toExistAsAnArtifact();
|
||||
expect({ prNum: PR }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should not be able to overwrite an existing hidden build', done => {
|
||||
const regexPrefix9 = `^PR: ${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
it('should not be able to overwrite an existing hidden preview', async () => {
|
||||
const BUILD = BuildNums.TRUST_CHECK_UNTRUSTED;
|
||||
const PR = PrNums.TRUST_CHECK_UNTRUSTED;
|
||||
h.createDummyBuild(PR, SHA, false);
|
||||
|
||||
h.createDummyBuild(pr9, sha9, false);
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
await circleBuild(payload(BUILD)).then(h.verifyResponse(409));
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath, c.BV_verify_verifiedNotTrusted).
|
||||
then(h.verifyResponse(409)).
|
||||
then(() => {
|
||||
expect(h.readBuildFile(pr9, sha9, 'index.html', false)).toMatch(idxContentRegex9);
|
||||
expect(h.readBuildFile(pr9, sha9, 'foo/bar.js', false)).toMatch(barContentRegex9);
|
||||
}).
|
||||
then(done);
|
||||
expect({ prNum: PR }).toExistAsAnArtifact();
|
||||
expect({ prNum: PR, isPublic: false }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should be able to request re-checking visibility (if outdated)', done => {
|
||||
const publicPr = pr9;
|
||||
const hiddenPr = String(c.BV_getPrIsTrusted_notTrusted);
|
||||
it('should be able to request re-checking visibility (if outdated)', async () => {
|
||||
const publicPr = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
const hiddenPr = PrNums.TRUST_CHECK_UNTRUSTED;
|
||||
|
||||
h.createDummyBuild(publicPr, sha9, false);
|
||||
h.createDummyBuild(hiddenPr, sha9, true);
|
||||
h.createDummyBuild(publicPr, SHA, false);
|
||||
h.createDummyBuild(hiddenPr, SHA, true);
|
||||
|
||||
// PR visibilities are outdated (i.e. the opposte of what the should).
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(false);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(true);
|
||||
expect({ prNum: publicPr, sha: SHA, isPublic: false }).toExistAsABuild(false);
|
||||
expect({ prNum: publicPr, sha: SHA, isPublic: true }).not.toExistAsABuild(false);
|
||||
expect({ prNum: hiddenPr, sha: SHA, isPublic: false }).not.toExistAsABuild(false);
|
||||
expect({ prNum: hiddenPr, sha: SHA, isPublic: true }).toExistAsABuild(false);
|
||||
|
||||
await Promise.all([
|
||||
prUpdated(publicPr).then(h.verifyResponse(200)),
|
||||
prUpdated(hiddenPr).then(h.verifyResponse(200)),
|
||||
]);
|
||||
|
||||
Promise.
|
||||
all([
|
||||
prUpdated(+publicPr).then(h.verifyResponse(200)),
|
||||
prUpdated(+hiddenPr).then(h.verifyResponse(200)),
|
||||
]).
|
||||
then(() => {
|
||||
// PR visibilities should have been updated.
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(false);
|
||||
}).
|
||||
then(() => {
|
||||
h.deletePrDir(publicPr, true);
|
||||
h.deletePrDir(hiddenPr, false);
|
||||
}).
|
||||
then(done);
|
||||
expect({ prNum: publicPr, isPublic: false }).not.toExistAsABuild();
|
||||
expect({ prNum: publicPr, isPublic: true }).toExistAsABuild();
|
||||
expect({ prNum: hiddenPr, isPublic: false }).toExistAsABuild();
|
||||
expect({ prNum: hiddenPr, isPublic: true }).not.toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should be able to request re-checking visibility (if up-to-date)', done => {
|
||||
const publicPr = pr9;
|
||||
const hiddenPr = String(c.BV_getPrIsTrusted_notTrusted);
|
||||
it('should be able to request re-checking visibility (if up-to-date)', async () => {
|
||||
const publicPr = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER;
|
||||
const hiddenPr = PrNums.TRUST_CHECK_UNTRUSTED;
|
||||
|
||||
h.createDummyBuild(publicPr, sha9, true);
|
||||
h.createDummyBuild(hiddenPr, sha9, false);
|
||||
h.createDummyBuild(publicPr, SHA, true);
|
||||
h.createDummyBuild(hiddenPr, SHA, false);
|
||||
|
||||
// PR visibilities are already up-to-date.
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(false);
|
||||
expect({ prNum: publicPr, sha: SHA, isPublic: false }).not.toExistAsABuild(false);
|
||||
expect({ prNum: publicPr, sha: SHA, isPublic: true }).toExistAsABuild(false);
|
||||
expect({ prNum: hiddenPr, sha: SHA, isPublic: false }).toExistAsABuild(false);
|
||||
expect({ prNum: hiddenPr, sha: SHA, isPublic: true }).not.toExistAsABuild(false);
|
||||
|
||||
await Promise.all([
|
||||
prUpdated(publicPr).then(h.verifyResponse(200)),
|
||||
prUpdated(hiddenPr).then(h.verifyResponse(200)),
|
||||
]);
|
||||
|
||||
Promise.
|
||||
all([
|
||||
prUpdated(+publicPr).then(h.verifyResponse(200)),
|
||||
prUpdated(+hiddenPr).then(h.verifyResponse(200)),
|
||||
]).
|
||||
then(() => {
|
||||
// PR visibilities are still up-to-date.
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(false);
|
||||
}).
|
||||
then(done);
|
||||
expect({ prNum: publicPr, isPublic: true }).toExistAsABuild();
|
||||
expect({ prNum: publicPr, isPublic: false }).not.toExistAsABuild();
|
||||
expect({ prNum: hiddenPr, isPublic: true }).not.toExistAsABuild();
|
||||
expect({ prNum: hiddenPr, isPublic: false }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should reject a request if re-checking visibility fails', done => {
|
||||
const errorPr = String(c.BV_getPrIsTrusted_error);
|
||||
it('should reject a request if re-checking visibility fails', async () => {
|
||||
const errorPr = PrNums.TRUST_CHECK_ERROR;
|
||||
|
||||
h.createDummyBuild(errorPr, sha9, true);
|
||||
h.createDummyBuild(errorPr, SHA, true);
|
||||
|
||||
expect(h.buildExists(errorPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(errorPr, '', true)).toBe(true);
|
||||
expect({ prNum: errorPr, isPublic: false }).not.toExistAsABuild(false);
|
||||
expect({ prNum: errorPr, isPublic: true }).toExistAsABuild(false);
|
||||
|
||||
await prUpdated(errorPr).then(h.verifyResponse(500, /TRUST_CHECK_ERROR/));
|
||||
|
||||
prUpdated(+errorPr).
|
||||
then(h.verifyResponse(500, /Test/)).
|
||||
then(() => {
|
||||
// PR visibility should not have been updated.
|
||||
expect(h.buildExists(errorPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(errorPr, '', true)).toBe(true);
|
||||
}).
|
||||
then(done);
|
||||
expect({ prNum: errorPr, isPublic: false }).not.toExistAsABuild();
|
||||
expect({ prNum: errorPr, isPublic: true }).toExistAsABuild();
|
||||
});
|
||||
|
||||
|
||||
it('should reject a request if updating visibility fails', done => {
|
||||
it('should reject a request if updating visibility fails', async () => {
|
||||
// One way to cause an error is to have both a public and a hidden directory for the same PR.
|
||||
h.createDummyBuild(pr9, sha9, false);
|
||||
h.createDummyBuild(pr9, sha9, true);
|
||||
h.createDummyBuild(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, SHA, false);
|
||||
h.createDummyBuild(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, SHA, true);
|
||||
|
||||
const hiddenPrDir = h.getPrDir(pr9, false);
|
||||
const publicPrDir = h.getPrDir(pr9, true);
|
||||
const hiddenPrDir = h.getPrDir(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, false);
|
||||
const publicPrDir = h.getPrDir(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, true);
|
||||
const bodyRegex = new RegExp(`Request to move '${hiddenPrDir}' to existing directory '${publicPrDir}'`);
|
||||
|
||||
expect(h.buildExists(pr9, '', false)).toBe(true);
|
||||
expect(h.buildExists(pr9, '', true)).toBe(true);
|
||||
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: false }).toExistAsABuild(false);
|
||||
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: true }).toExistAsABuild(false);
|
||||
|
||||
await prUpdated(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER).then(h.verifyResponse(409, bodyRegex));
|
||||
|
||||
prUpdated(+pr9).
|
||||
then(h.verifyResponse(409, bodyRegex)).
|
||||
then(() => {
|
||||
// PR visibility should not have been updated.
|
||||
expect(h.buildExists(pr9, '', false)).toBe(true);
|
||||
expect(h.buildExists(pr9, '', true)).toBe(true);
|
||||
}).
|
||||
then(done);
|
||||
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: false }).toExistAsABuild();
|
||||
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: true }).toExistAsABuild();
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -0,0 +1,2 @@
|
||||
import '../preview-server';
|
||||
import './mock-external-apis';
|
@ -1,38 +0,0 @@
|
||||
// Imports
|
||||
import {GithubPullRequests} from '../common/github-pull-requests';
|
||||
import {BUILD_VERIFICATION_STATUS, BuildVerifier} from '../upload-server/build-verifier';
|
||||
import {UploadError} from '../upload-server/upload-error';
|
||||
import * as c from './constants';
|
||||
|
||||
// Run
|
||||
// TODO(gkalpak): Add e2e tests to cover these interactions as well.
|
||||
GithubPullRequests.prototype.addComment = () => Promise.resolve();
|
||||
BuildVerifier.prototype.getPrIsTrusted = (pr: number) => {
|
||||
switch (pr) {
|
||||
case c.BV_getPrIsTrusted_error:
|
||||
// For e2e tests, fake an error.
|
||||
return Promise.reject('Test');
|
||||
case c.BV_getPrIsTrusted_notTrusted:
|
||||
// For e2e tests, fake an untrusted PR (`false`).
|
||||
return Promise.resolve(false);
|
||||
default:
|
||||
// For e2e tests, default to trusted PRs (`true`).
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
BuildVerifier.prototype.verify = (expectedPr: number, authHeader: string) => {
|
||||
switch (authHeader) {
|
||||
case c.BV_verify_error:
|
||||
// For e2e tests, fake a verification error.
|
||||
return Promise.reject(new UploadError(403, `Error while verifying upload for PR ${expectedPr}: Test`));
|
||||
case c.BV_verify_verifiedNotTrusted:
|
||||
// For e2e tests, fake a `verifiedNotTrusted` verification status.
|
||||
return Promise.resolve(BUILD_VERIFICATION_STATUS.verifiedNotTrusted);
|
||||
default:
|
||||
// For e2e tests, default to `verifiedAndTrusted` verification status.
|
||||
return Promise.resolve(BUILD_VERIFICATION_STATUS.verifiedAndTrusted);
|
||||
}
|
||||
};
|
||||
|
||||
// tslint:disable-next-line: no-var-requires
|
||||
require('../upload-server/index');
|
30
aio/aio-builds-setup/dockerbuild/scripts-js/lib/verify-setup/tar-stream.d.ts
vendored
Normal file
30
aio/aio-builds-setup/dockerbuild/scripts-js/lib/verify-setup/tar-stream.d.ts
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
declare module 'tar-stream' {
|
||||
|
||||
import {Readable, Writable} from 'stream';
|
||||
|
||||
export interface Pack extends Readable {
|
||||
entry(header: Header, callback?: (err?: any) => {}): Writable;
|
||||
entry(header: Header, contents: string, callback?: (err?: any) => {}): Writable;
|
||||
entry(header: Header, buffer: Buffer, callback?: (err?: any) => {}): Writable;
|
||||
entry(header: Header, buffer: string|Buffer, callback?: (err?: any) => {}): Writable;
|
||||
finalize();
|
||||
destroy(err: any);
|
||||
}
|
||||
|
||||
export interface Header {
|
||||
name: string;
|
||||
mode?: number;
|
||||
uid?: number;
|
||||
gid?: number;
|
||||
size?: number;
|
||||
mtime?: Date;
|
||||
type?: type;
|
||||
linkname?: string;
|
||||
uname?: string;
|
||||
gname?: string;
|
||||
devmajor?: number;
|
||||
devminor?: number;
|
||||
}
|
||||
|
||||
export function pack(): Pack;
|
||||
}
|
@ -1,571 +0,0 @@
|
||||
// Imports
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as c from './constants';
|
||||
import {CmdResult, helper as h} from './helper';
|
||||
|
||||
// Tests
|
||||
describe('upload-server (on HTTP)', () => {
|
||||
const hostname = h.uploadHostname;
|
||||
const port = h.uploadPort;
|
||||
const host = `${hostname}:${port}`;
|
||||
const pr = '9';
|
||||
const sha9 = '9'.repeat(40);
|
||||
const sha0 = '0'.repeat(40);
|
||||
|
||||
beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000);
|
||||
afterEach(() => h.cleanUp());
|
||||
|
||||
|
||||
describe(`${host}/create-build/<pr>/<sha>`, () => {
|
||||
const authorizationHeader = `--header "Authorization: Token FOO"`;
|
||||
const xFileHeader = `--header "X-File: ${h.buildsDir}/snapshot.tar.gz"`;
|
||||
const defaultHeaders = `${authorizationHeader} ${xFileHeader}`;
|
||||
const curl = (url: string, headers = defaultHeaders) => `curl -iL ${headers} ${url}`;
|
||||
|
||||
|
||||
it('should disallow non-GET requests', done => {
|
||||
const url = `http://${host}/create-build/${pr}/${sha9}`;
|
||||
const bodyRegex = /^Unknown resource/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iLX PUT ${url}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX POST ${url}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX PATCH ${url}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX DELETE ${url}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject requests without an \'AUTHORIZATION\' header', done => {
|
||||
const headers1 = '';
|
||||
const headers2 = '--header "AUTHORIXATION: "';
|
||||
const url = `http://${host}/create-build/${pr}/${sha9}`;
|
||||
const bodyRegex = /^Missing or empty 'AUTHORIZATION' header/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(curl(url, headers1)).then(h.verifyResponse(401, bodyRegex)),
|
||||
h.runCmd(curl(url, headers2)).then(h.verifyResponse(401, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject requests without an \'X-FILE\' header', done => {
|
||||
const headers1 = authorizationHeader;
|
||||
const headers2 = `${authorizationHeader} --header "X-FILE: "`;
|
||||
const url = `http://${host}/create-build/${pr}/${sha9}`;
|
||||
const bodyRegex = /^Missing or empty 'X-FILE' header/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(curl(url, headers1)).then(h.verifyResponse(400, bodyRegex)),
|
||||
h.runCmd(curl(url, headers2)).then(h.verifyResponse(400, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject requests for which the PR verification fails', done => {
|
||||
const headers = `--header "Authorization: ${c.BV_verify_error}" ${xFileHeader}`;
|
||||
const url = `http://${host}/create-build/${pr}/${sha9}`;
|
||||
const bodyRegex = new RegExp(`Error while verifying upload for PR ${pr}: Test`);
|
||||
|
||||
h.runCmd(curl(url, headers)).
|
||||
then(h.verifyResponse(403, bodyRegex)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown paths', done => {
|
||||
const cmdPrefix = curl(`http://${host}`);
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`${cmdPrefix}/foo/create-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/foo-create-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/fooncreate-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-build/foo/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-build-foo/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-buildnfoo/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-build/pr${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/create-build/${pr}/${sha9}42`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject PRs with leading zeros', done => {
|
||||
h.runCmd(curl(`http://${host}/create-build/0${pr}/${sha9}`)).
|
||||
then(h.verifyResponse(404)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should accept SHAs with leading zeros (but not trim the zeros)', done => {
|
||||
Promise.all([
|
||||
h.runCmd(curl(`http://${host}/create-build/${pr}/0${sha9}`)).then(h.verifyResponse(404)),
|
||||
h.runCmd(curl(`http://${host}/create-build/${pr}/${sha9}`)).then(h.verifyResponse(500)),
|
||||
h.runCmd(curl(`http://${host}/create-build/${pr}/${sha0}`)).then(h.verifyResponse(500)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
[true, false].forEach(isPublic => describe(`(for ${isPublic ? 'public' : 'hidden'} builds)`, () => {
|
||||
const authorizationHeader2 = isPublic ?
|
||||
authorizationHeader : `--header "Authorization: ${c.BV_verify_verifiedNotTrusted}"`;
|
||||
const cmdPrefix = curl('', `${authorizationHeader2} ${xFileHeader}`);
|
||||
const overwriteRe = RegExp(`^Request to overwrite existing ${isPublic ? 'public' : 'non-public'} directory`);
|
||||
|
||||
|
||||
it('should not overwrite existing builds', done => {
|
||||
h.createDummyBuild(pr, sha9, isPublic);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toContain('index.html');
|
||||
|
||||
h.writeBuildFile(pr, sha9, 'index.html', 'My content', isPublic);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toBe('My content');
|
||||
|
||||
h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9}`).
|
||||
then(h.verifyResponse(409, overwriteRe)).
|
||||
then(() => expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toBe('My content')).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should not overwrite existing builds (even if the SHA is different)', done => {
|
||||
// Since only the first few characters of the SHA are used, it is possible for two different
|
||||
// SHAs to correspond to the same directory. In that case, we don't want the second SHA to
|
||||
// overwrite the first.
|
||||
|
||||
const sha9Almost = sha9.replace(/.$/, '8');
|
||||
expect(sha9Almost).not.toBe(sha9);
|
||||
|
||||
h.createDummyBuild(pr, sha9, isPublic);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toContain('index.html');
|
||||
|
||||
h.writeBuildFile(pr, sha9, 'index.html', 'My content', isPublic);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toBe('My content');
|
||||
|
||||
h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9Almost}`).
|
||||
then(h.verifyResponse(409, overwriteRe)).
|
||||
then(() => expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toBe('My content')).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should delete the PR directory on error (for new PR)', done => {
|
||||
h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9}`).
|
||||
then(h.verifyResponse(500)).
|
||||
then(() => expect(h.buildExists(pr, '', isPublic)).toBe(false)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should only delete the SHA directory on error (for existing PR)', done => {
|
||||
h.createDummyBuild(pr, sha0, isPublic);
|
||||
|
||||
h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9}`).
|
||||
then(h.verifyResponse(500)).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr, sha9, isPublic)).toBe(false);
|
||||
expect(h.buildExists(pr, '', isPublic)).toBe(true);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
describe('on successful upload', () => {
|
||||
const archivePath = path.join(h.buildsDir, 'snapshot.tar.gz');
|
||||
const statusCode = isPublic ? 201 : 202;
|
||||
let uploadPromise: Promise<CmdResult>;
|
||||
|
||||
beforeEach(() => {
|
||||
h.createDummyArchive(pr, sha9, archivePath);
|
||||
uploadPromise = h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9}`);
|
||||
});
|
||||
afterEach(() => h.deletePrDir(pr, isPublic));
|
||||
|
||||
|
||||
it(`should respond with ${statusCode}`, done => {
|
||||
uploadPromise.then(h.verifyResponse(statusCode)).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should extract the contents of the uploaded file', done => {
|
||||
uploadPromise.
|
||||
then(() => {
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toContain(`uploaded/${pr}`);
|
||||
expect(h.readBuildFile(pr, sha9, 'foo/bar.js', isPublic)).toContain(`uploaded/${pr}`);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it(`should create files/directories owned by '${h.wwwUser}'`, done => {
|
||||
const prDir = h.getPrDir(pr, isPublic);
|
||||
const shaDir = h.getShaDir(prDir, sha9);
|
||||
const idxPath = path.join(shaDir, 'index.html');
|
||||
const barPath = path.join(shaDir, 'foo', 'bar.js');
|
||||
|
||||
uploadPromise.
|
||||
then(() => Promise.all([
|
||||
h.runCmd(`find ${shaDir}`),
|
||||
h.runCmd(`find ${shaDir} -user ${h.wwwUser}`),
|
||||
])).
|
||||
then(([{stdout: allFiles}, {stdout: userFiles}]) => {
|
||||
expect(userFiles).toBe(allFiles);
|
||||
expect(userFiles).toContain(shaDir);
|
||||
expect(userFiles).toContain(idxPath);
|
||||
expect(userFiles).toContain(barPath);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should delete the uploaded file', done => {
|
||||
expect(fs.existsSync(archivePath)).toBe(true);
|
||||
uploadPromise.
|
||||
then(() => expect(fs.existsSync(archivePath)).toBe(false)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should make the build directory non-writable', done => {
|
||||
const prDir = h.getPrDir(pr, isPublic);
|
||||
const shaDir = h.getShaDir(prDir, sha9);
|
||||
const idxPath = path.join(shaDir, 'index.html');
|
||||
const barPath = path.join(shaDir, 'foo', 'bar.js');
|
||||
|
||||
// See https://github.com/nodejs/node-v0.x-archive/issues/3045#issuecomment-4862588.
|
||||
const isNotWritable = (fileOrDir: string) => {
|
||||
const mode = fs.statSync(fileOrDir).mode;
|
||||
// tslint:disable-next-line: no-bitwise
|
||||
return !(mode & parseInt('222', 8));
|
||||
};
|
||||
|
||||
uploadPromise.
|
||||
then(() => {
|
||||
expect(isNotWritable(shaDir)).toBe(true);
|
||||
expect(isNotWritable(idxPath)).toBe(true);
|
||||
expect(isNotWritable(barPath)).toBe(true);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should ignore a legacy 40-chars long build directory (even if it starts with the same chars)', done => {
|
||||
// It is possible that 40-chars long build directories exist, if they had been deployed
|
||||
// before implementing the shorter build directory names. In that case, we don't want the
|
||||
// second (shorter) name to be considered the same as the old one (even if they originate
|
||||
// from the same SHA).
|
||||
|
||||
h.createDummyBuild(pr, sha9, isPublic, false, true);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic, true)).toContain('index.html');
|
||||
|
||||
h.writeBuildFile(pr, sha9, 'index.html', 'My content', isPublic, true);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic, true)).toBe('My content');
|
||||
|
||||
h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9}`).
|
||||
then(h.verifyResponse(statusCode)).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr, sha9, isPublic)).toBe(true);
|
||||
expect(h.buildExists(pr, sha9, isPublic, true)).toBe(true);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toContain('index.html');
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic, true)).toBe('My content');
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('when the PR\'s visibility has changed', () => {
|
||||
const archivePath = path.join(h.buildsDir, 'snapshot.tar.gz');
|
||||
const statusCode = isPublic ? 201 : 202;
|
||||
|
||||
const checkPrVisibility = (isPublic2: boolean) => {
|
||||
expect(h.buildExists(pr, '', isPublic2)).toBe(true);
|
||||
expect(h.buildExists(pr, '', !isPublic2)).toBe(false);
|
||||
expect(h.buildExists(pr, sha0, isPublic2)).toBe(true);
|
||||
expect(h.buildExists(pr, sha0, !isPublic2)).toBe(false);
|
||||
};
|
||||
const uploadBuild = (sha: string) => h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha}`);
|
||||
|
||||
beforeEach(() => {
|
||||
h.createDummyBuild(pr, sha0, !isPublic);
|
||||
h.createDummyArchive(pr, sha9, archivePath);
|
||||
checkPrVisibility(!isPublic);
|
||||
});
|
||||
afterEach(() => h.deletePrDir(pr, isPublic));
|
||||
|
||||
|
||||
it('should update the PR\'s visibility', done => {
|
||||
uploadBuild(sha9).
|
||||
then(h.verifyResponse(statusCode)).
|
||||
then(() => {
|
||||
checkPrVisibility(isPublic);
|
||||
expect(h.buildExists(pr, sha9, isPublic)).toBe(true);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toContain(`uploaded/${pr}`);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toContain(sha9);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should not overwrite existing builds (but keep the updated visibility)', done => {
|
||||
expect(h.buildExists(pr, sha0, isPublic)).toBe(false);
|
||||
|
||||
uploadBuild(sha0).
|
||||
then(h.verifyResponse(409, overwriteRe)).
|
||||
then(() => {
|
||||
checkPrVisibility(isPublic);
|
||||
expect(h.readBuildFile(pr, sha0, 'index.html', isPublic)).toContain(pr);
|
||||
expect(h.readBuildFile(pr, sha0, 'index.html', isPublic)).not.toContain(`uploaded/${pr}`);
|
||||
expect(h.readBuildFile(pr, sha0, 'index.html', isPublic)).toContain(sha0);
|
||||
expect(h.readBuildFile(pr, sha0, 'index.html', isPublic)).not.toContain(sha9);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject the request if it fails to update the PR\'s visibility', done => {
|
||||
// One way to cause an error is to have both a public and a hidden directory for the same PR.
|
||||
h.createDummyBuild(pr, sha0, isPublic);
|
||||
|
||||
expect(h.buildExists(pr, sha0, isPublic)).toBe(true);
|
||||
expect(h.buildExists(pr, sha0, !isPublic)).toBe(true);
|
||||
|
||||
const errorRegex = new RegExp(`^Request to move '${h.getPrDir(pr, !isPublic)}' ` +
|
||||
`to existing directory '${h.getPrDir(pr, isPublic)}'.`);
|
||||
|
||||
uploadBuild(sha9).
|
||||
then(h.verifyResponse(409, errorRegex)).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr, sha0, isPublic)).toBe(true);
|
||||
expect(h.buildExists(pr, sha0, !isPublic)).toBe(true);
|
||||
expect(h.buildExists(pr, sha9, isPublic)).toBe(false);
|
||||
expect(h.buildExists(pr, sha9, !isPublic)).toBe(false);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/health-check`, () => {
|
||||
|
||||
it('should respond with 200', done => {
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL http://${host}/health-check`).then(h.verifyResponse(200)),
|
||||
h.runCmd(`curl -iL http://${host}/health-check/`).then(h.verifyResponse(200)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 if the path does not match exactly', done => {
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL http://${host}/health-check/foo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL http://${host}/health-check-foo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL http://${host}/health-checknfoo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL http://${host}/foo/health-check`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL http://${host}/foo-health-check`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL http://${host}/foonhealth-check`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/pr-updated`, () => {
|
||||
const url = `http://${host}/pr-updated`;
|
||||
|
||||
// Helpers
|
||||
const curl = (payload?: {number: number, action?: string}) => {
|
||||
const payloadStr = payload && JSON.stringify(payload) || '';
|
||||
return `curl -iLX POST --header "Content-Type: application/json" --data '${payloadStr}' ${url}`;
|
||||
};
|
||||
|
||||
|
||||
it('should disallow non-POST requests', done => {
|
||||
const bodyRegex = /^Unknown resource in request/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iLX GET ${url}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX PUT ${url}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX PATCH ${url}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX DELETE ${url}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 400 for requests without a payload', done => {
|
||||
const bodyRegex = /^Missing or empty 'number' field in request/;
|
||||
|
||||
h.runCmd(curl()).
|
||||
then(h.verifyResponse(400, bodyRegex)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 400 for requests without a \'number\' field', done => {
|
||||
const bodyRegex = /^Missing or empty 'number' field in request/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(curl({} as any)).then(h.verifyResponse(400, bodyRegex)),
|
||||
h.runCmd(curl({number: null} as any)).then(h.verifyResponse(400, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject requests for which checking the PR visibility fails', done => {
|
||||
h.runCmd(curl({number: c.BV_getPrIsTrusted_error})).
|
||||
then(h.verifyResponse(500, /Test/)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown paths', done => {
|
||||
const mockPayload = JSON.stringify({number: +pr});
|
||||
const cmdPrefix = `curl -iLX POST --data "${mockPayload}" http://${host}`;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`${cmdPrefix}/foo/pr-updated`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/foo-pr-updated`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/foonpr-updated`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/pr-updated/foo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/pr-updated-foo`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`${cmdPrefix}/pr-updatednfoo`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should do nothing if PR\'s visibility is already up-to-date', done => {
|
||||
const publicPr = pr;
|
||||
const hiddenPr = String(c.BV_getPrIsTrusted_notTrusted);
|
||||
const checkVisibilities = () => {
|
||||
// Public build is already public.
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(true);
|
||||
// Hidden build is already hidden.
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(false);
|
||||
};
|
||||
|
||||
h.createDummyBuild(publicPr, sha9, true);
|
||||
h.createDummyBuild(hiddenPr, sha9, false);
|
||||
checkVisibilities();
|
||||
|
||||
Promise.
|
||||
all([
|
||||
h.runCmd(curl({number: +publicPr, action: 'foo'})).then(h.verifyResponse(200)),
|
||||
h.runCmd(curl({number: +hiddenPr, action: 'foo'})).then(h.verifyResponse(200)),
|
||||
]).
|
||||
// Visibilities should not have changed, because the specified action could not have triggered a change.
|
||||
then(checkVisibilities).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should do nothing if \'action\' implies no visibility change', done => {
|
||||
const publicPr = pr;
|
||||
const hiddenPr = String(c.BV_getPrIsTrusted_notTrusted);
|
||||
const checkVisibilities = () => {
|
||||
// Public build is hidden atm.
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(false);
|
||||
// Hidden build is public atm.
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(true);
|
||||
};
|
||||
|
||||
h.createDummyBuild(publicPr, sha9, false);
|
||||
h.createDummyBuild(hiddenPr, sha9, true);
|
||||
checkVisibilities();
|
||||
|
||||
Promise.
|
||||
all([
|
||||
h.runCmd(curl({number: +publicPr, action: 'foo'})).then(h.verifyResponse(200)),
|
||||
h.runCmd(curl({number: +hiddenPr, action: 'foo'})).then(h.verifyResponse(200)),
|
||||
]).
|
||||
// Visibilities should not have changed, because the specified action could not have triggered a change.
|
||||
then(checkVisibilities).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
describe('when the visiblity has changed', () => {
|
||||
const publicPr = pr;
|
||||
const hiddenPr = String(c.BV_getPrIsTrusted_notTrusted);
|
||||
|
||||
beforeEach(() => {
|
||||
// Create initial PR builds with opposite visibilities as the ones that will be reported:
|
||||
// - The now public PR was previously hidden.
|
||||
// - The now hidden PR was previously public.
|
||||
h.createDummyBuild(publicPr, sha9, false);
|
||||
h.createDummyBuild(hiddenPr, sha9, true);
|
||||
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(false);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
// Expect PRs' visibility to have been updated:
|
||||
// - The public PR should be actually public (previously it was hidden).
|
||||
// - The hidden PR should be actually hidden (previously it was public).
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(false);
|
||||
|
||||
h.deletePrDir(publicPr, true);
|
||||
h.deletePrDir(hiddenPr, false);
|
||||
});
|
||||
|
||||
|
||||
it('should update the PR\'s visibility (action: undefined)', done => {
|
||||
Promise.all([
|
||||
h.runCmd(curl({number: +publicPr})).then(h.verifyResponse(200)),
|
||||
h.runCmd(curl({number: +hiddenPr})).then(h.verifyResponse(200)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should update the PR\'s visibility (action: labeled)', done => {
|
||||
Promise.all([
|
||||
h.runCmd(curl({number: +publicPr, action: 'labeled'})).then(h.verifyResponse(200)),
|
||||
h.runCmd(curl({number: +hiddenPr, action: 'labeled'})).then(h.verifyResponse(200)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should update the PR\'s visibility (action: unlabeled)', done => {
|
||||
Promise.all([
|
||||
h.runCmd(curl({number: +publicPr, action: 'unlabeled'})).then(h.verifyResponse(200)),
|
||||
h.runCmd(curl({number: +hiddenPr, action: 'unlabeled'})).then(h.verifyResponse(200)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/*`, () => {
|
||||
|
||||
it('should respond with 404 for requests to unknown URLs', done => {
|
||||
const bodyRegex = /^Unknown resource/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL http://${host}/index.html`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iL http://${host}/`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iL http://${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX PUT http://${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX POST http://${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX PATCH http://${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
h.runCmd(`curl -iLX DELETE http://${host}`).then(h.verifyResponse(404, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
@ -7,39 +7,49 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"prebuild": "yarn clean-dist",
|
||||
"build": "tsc",
|
||||
"build-watch": "yarn build --watch",
|
||||
"build": "yarn ~~build",
|
||||
"prebuild-watch": "yarn prebuild",
|
||||
"build-watch": "yarn ~~build-watch",
|
||||
"clean-dist": "node --eval \"require('shelljs').rm('-rf', 'dist')\"",
|
||||
"dev": "concurrently --kill-others --raw --success first \"yarn build-watch\" \"yarn test-watch\"",
|
||||
"predev": "yarn build || true",
|
||||
"dev": "run-p ~~build-watch ~~test-watch",
|
||||
"lint": "tslint --project tsconfig.json",
|
||||
"pre~~test-only": "yarn lint",
|
||||
"~~test-only": "node dist/test",
|
||||
"pretest": "yarn build",
|
||||
"test": "yarn ~~test-only",
|
||||
"pretest-watch": "yarn build",
|
||||
"test-watch": "nodemon --exec \"yarn ~~test-only\" --watch dist"
|
||||
"pretest-watch": "yarn pretest",
|
||||
"test-watch": "yarn ~~test-watch",
|
||||
"~~build": "tsc",
|
||||
"~~build-watch": "yarn ~~build --watch",
|
||||
"pre~~test-only": "yarn lint",
|
||||
"~~test-only": "node dist/test",
|
||||
"~~test-watch": "nodemon --delay 1 --exec \"yarn ~~test-only\" --watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.18.2",
|
||||
"express": "^4.15.4",
|
||||
"jasmine": "^2.8.0",
|
||||
"jsonwebtoken": "^8.0.1",
|
||||
"shelljs": "^0.7.8",
|
||||
"tslib": "^1.7.1"
|
||||
"body-parser": "^1.18.3",
|
||||
"delete-empty": "^2.0.0",
|
||||
"express": "^4.16.3",
|
||||
"jasmine": "^3.2.0",
|
||||
"nock": "^9.6.1",
|
||||
"node-fetch": "^2.2.0",
|
||||
"shelljs": "^0.8.2",
|
||||
"source-map-support": "^0.5.9",
|
||||
"tar-stream": "^1.6.1",
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/body-parser": "^1.16.5",
|
||||
"@types/express": "^4.0.37",
|
||||
"@types/jasmine": "^2.6.0",
|
||||
"@types/jsonwebtoken": "^7.2.3",
|
||||
"@types/node": "^8.0.30",
|
||||
"@types/body-parser": "^1.17.0",
|
||||
"@types/express": "^4.16.0",
|
||||
"@types/jasmine": "^2.8.8",
|
||||
"@types/nock": "^9.3.0",
|
||||
"@types/node": "^10.9.2",
|
||||
"@types/node-fetch": "^2.1.2",
|
||||
"@types/shelljs": "^0.8.0",
|
||||
"@types/supertest": "^2.0.3",
|
||||
"concurrently": "^3.5.0",
|
||||
"nodemon": "^1.12.1",
|
||||
"supertest": "^3.0.0",
|
||||
"tslint": "^5.7.0",
|
||||
"tslint-jasmine-noSkipOrFocus": "^1.0.8",
|
||||
"typescript": "^2.5.2"
|
||||
"@types/supertest": "^2.0.5",
|
||||
"nodemon": "^1.18.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"supertest": "^3.1.0",
|
||||
"tslint": "^5.11.0",
|
||||
"tslint-jasmine-noSkipOrFocus": "^1.0.9",
|
||||
"typescript": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
@ -1,135 +1,186 @@
|
||||
// Imports
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {normalize} from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {BuildCleaner} from '../../lib/clean-up/build-cleaner';
|
||||
import {HIDDEN_DIR_PREFIX} from '../../lib/common/constants';
|
||||
import {GithubPullRequests} from '../../lib/common/github-pull-requests';
|
||||
import {Logger} from '../../lib/common/utils';
|
||||
|
||||
const EXISTING_BUILDS = [10, 20, 30, 40];
|
||||
const EXISTING_DOWNLOADS = [
|
||||
'10-ABCDEF0-build.zip',
|
||||
'10-1234567-build.zip',
|
||||
'20-ABCDEF0-build.zip',
|
||||
'20-1234567-build.zip',
|
||||
];
|
||||
const OPEN_PRS = [10, 40];
|
||||
const ANY_DATE = jasmine.any(String);
|
||||
|
||||
// Tests
|
||||
describe('BuildCleaner', () => {
|
||||
let loggerErrorSpy: jasmine.Spy;
|
||||
let loggerLogSpy: jasmine.Spy;
|
||||
let cleaner: BuildCleaner;
|
||||
|
||||
beforeEach(() => cleaner = new BuildCleaner('/foo/bar', 'baz/qux', '12345'));
|
||||
|
||||
beforeEach(() => {
|
||||
loggerErrorSpy = spyOn(Logger.prototype, 'error');
|
||||
loggerLogSpy = spyOn(Logger.prototype, 'log');
|
||||
cleaner = new BuildCleaner('/foo/bar', 'baz', 'qux', '12345', '/downloads', 'build.zip');
|
||||
});
|
||||
|
||||
describe('constructor()', () => {
|
||||
|
||||
it('should throw if \'buildsDir\' is empty', () => {
|
||||
expect(() => new BuildCleaner('', '/baz/qux', '12345')).
|
||||
expect(() => new BuildCleaner('', 'baz', 'qux', '12345', 'downloads', 'build.zip')).
|
||||
toThrowError('Missing or empty required parameter \'buildsDir\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'repoSlug\' is empty', () => {
|
||||
expect(() => new BuildCleaner('/foo/bar', '', '12345')).
|
||||
toThrowError('Missing or empty required parameter \'repoSlug\'!');
|
||||
it('should throw if \'githubOrg\' is empty', () => {
|
||||
expect(() => new BuildCleaner('/foo/bar', '', 'qux', '12345', 'downloads', 'build.zip')).
|
||||
toThrowError('Missing or empty required parameter \'githubOrg\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubRepo\' is empty', () => {
|
||||
expect(() => new BuildCleaner('/foo/bar', 'baz', '', '12345', 'downloads', 'build.zip')).
|
||||
toThrowError('Missing or empty required parameter \'githubRepo\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubToken\' is empty', () => {
|
||||
expect(() => new BuildCleaner('/foo/bar', 'baz/qux', '')).
|
||||
expect(() => new BuildCleaner('/foo/bar', 'baz', 'qux', '', 'downloads', 'build.zip')).
|
||||
toThrowError('Missing or empty required parameter \'githubToken\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'downloadsDir\' is empty', () => {
|
||||
expect(() => new BuildCleaner('/foo/bar', 'baz', 'qux', '12345', '', 'build.zip')).
|
||||
toThrowError('Missing or empty required parameter \'downloadsDir\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'artifactPath\' is empty', () => {
|
||||
expect(() => new BuildCleaner('/foo/bar', 'baz', 'qux', '12345', 'downloads', '')).
|
||||
toThrowError('Missing or empty required parameter \'artifactPath\'!');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('cleanUp()', () => {
|
||||
let cleanerGetExistingBuildNumbersSpy: jasmine.Spy;
|
||||
let cleanerGetOpenPrNumbersSpy: jasmine.Spy;
|
||||
let cleanerGetExistingDownloadsSpy: jasmine.Spy;
|
||||
let cleanerRemoveUnnecessaryBuildsSpy: jasmine.Spy;
|
||||
let existingBuildsDeferred: {resolve: (v?: any) => void, reject: (e?: any) => void};
|
||||
let openPrsDeferred: {resolve: (v?: any) => void, reject: (e?: any) => void};
|
||||
let promise: Promise<void>;
|
||||
let cleanerRemoveUnnecessaryDownloadsSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanerGetExistingBuildNumbersSpy = spyOn(cleaner as any, 'getExistingBuildNumbers').and.callFake(() => {
|
||||
return new Promise((resolve, reject) => existingBuildsDeferred = {resolve, reject});
|
||||
});
|
||||
cleanerGetOpenPrNumbersSpy = spyOn(cleaner as any, 'getOpenPrNumbers').and.callFake(() => {
|
||||
return new Promise((resolve, reject) => openPrsDeferred = {resolve, reject});
|
||||
});
|
||||
cleanerRemoveUnnecessaryBuildsSpy = spyOn(cleaner as any, 'removeUnnecessaryBuilds');
|
||||
cleanerGetExistingBuildNumbersSpy = spyOn(cleaner, 'getExistingBuildNumbers')
|
||||
.and.callFake(() => Promise.resolve(EXISTING_BUILDS));
|
||||
cleanerGetOpenPrNumbersSpy = spyOn(cleaner, 'getOpenPrNumbers')
|
||||
.and.callFake(() => Promise.resolve(OPEN_PRS));
|
||||
cleanerGetExistingDownloadsSpy = spyOn(cleaner, 'getExistingDownloads')
|
||||
.and.callFake(() => Promise.resolve(EXISTING_DOWNLOADS));
|
||||
|
||||
cleanerRemoveUnnecessaryBuildsSpy = spyOn(cleaner, 'removeUnnecessaryBuilds');
|
||||
cleanerRemoveUnnecessaryDownloadsSpy = spyOn(cleaner, 'removeUnnecessaryDownloads');
|
||||
|
||||
promise = cleaner.cleanUp();
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', () => {
|
||||
it('should return a promise', async () => {
|
||||
const promise = cleaner.cleanUp();
|
||||
expect(promise).toEqual(jasmine.any(Promise));
|
||||
|
||||
// Do not complete the test and release the spies synchronously, to avoid running the actual implementations.
|
||||
await promise;
|
||||
});
|
||||
|
||||
|
||||
it('should get the existing builds', () => {
|
||||
expect(cleanerGetExistingBuildNumbersSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should get the open PRs', () => {
|
||||
it('should get the open PRs', async () => {
|
||||
await cleaner.cleanUp();
|
||||
expect(cleanerGetOpenPrNumbersSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should reject if \'getExistingBuildNumbers()\' rejects', done => {
|
||||
promise.catch(err => {
|
||||
it('should get the existing builds', async () => {
|
||||
await cleaner.cleanUp();
|
||||
expect(cleanerGetExistingBuildNumbersSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should get the existing downloads', async () => {
|
||||
await cleaner.cleanUp();
|
||||
expect(cleanerGetExistingDownloadsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should pass existing builds and open PRs to \'removeUnnecessaryBuilds()\'', async () => {
|
||||
await cleaner.cleanUp();
|
||||
expect(cleanerRemoveUnnecessaryBuildsSpy).toHaveBeenCalledWith(EXISTING_BUILDS, OPEN_PRS);
|
||||
});
|
||||
|
||||
|
||||
it('should pass existing downloads and open PRs to \'removeUnnecessaryDownloads()\'', async () => {
|
||||
await cleaner.cleanUp();
|
||||
expect(cleanerRemoveUnnecessaryDownloadsSpy).toHaveBeenCalledWith(EXISTING_DOWNLOADS, OPEN_PRS);
|
||||
});
|
||||
|
||||
|
||||
it('should reject if \'getOpenPrNumbers()\' rejects', async () => {
|
||||
try {
|
||||
cleanerGetOpenPrNumbersSpy.and.callFake(() => Promise.reject('Test'));
|
||||
await cleaner.cleanUp();
|
||||
} catch (err) {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
existingBuildsDeferred.reject('Test');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('should reject if \'getOpenPrNumbers()\' rejects', done => {
|
||||
promise.catch(err => {
|
||||
it('should reject if \'getExistingBuildNumbers()\' rejects', async () => {
|
||||
try {
|
||||
cleanerGetExistingBuildNumbersSpy.and.callFake(() => Promise.reject('Test'));
|
||||
await cleaner.cleanUp();
|
||||
} catch (err) {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
openPrsDeferred.reject('Test');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('should reject if \'removeUnnecessaryBuilds()\' rejects', done => {
|
||||
promise.catch(err => {
|
||||
it('should reject if \'getExistingDownloads()\' rejects', async () => {
|
||||
try {
|
||||
cleanerGetExistingDownloadsSpy.and.callFake(() => Promise.reject('Test'));
|
||||
await cleaner.cleanUp();
|
||||
} catch (err) {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
cleanerRemoveUnnecessaryBuildsSpy.and.returnValue(Promise.reject('Test'));
|
||||
existingBuildsDeferred.resolve();
|
||||
openPrsDeferred.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('should pass existing builds and open PRs to \'removeUnnecessaryBuilds()\'', done => {
|
||||
promise.then(() => {
|
||||
expect(cleanerRemoveUnnecessaryBuildsSpy).toHaveBeenCalledWith('foo', 'bar');
|
||||
done();
|
||||
});
|
||||
|
||||
existingBuildsDeferred.resolve('foo');
|
||||
openPrsDeferred.resolve('bar');
|
||||
it('should reject if \'removeUnnecessaryBuilds()\' rejects', async () => {
|
||||
try {
|
||||
cleanerRemoveUnnecessaryBuildsSpy.and.callFake(() => Promise.reject('Test'));
|
||||
await cleaner.cleanUp();
|
||||
} catch (err) {
|
||||
expect(err).toBe('Test');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('should resolve with the value returned by \'removeUnnecessaryBuilds()\'', done => {
|
||||
promise.then(result => {
|
||||
expect(result as any).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
cleanerRemoveUnnecessaryBuildsSpy.and.returnValue(Promise.resolve('Test'));
|
||||
existingBuildsDeferred.resolve();
|
||||
openPrsDeferred.resolve();
|
||||
it('should reject if \'removeUnnecessaryDownloads()\' rejects', async () => {
|
||||
try {
|
||||
cleanerRemoveUnnecessaryDownloadsSpy.and.callFake(() => Promise.reject('Test'));
|
||||
await cleaner.cleanUp();
|
||||
} catch (err) {
|
||||
expect(err).toBe('Test');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// Protected methods
|
||||
|
||||
describe('getExistingBuildNumbers()', () => {
|
||||
let fsReaddirSpy: jasmine.Spy;
|
||||
let readdirCb: (err: any, files?: string[]) => void;
|
||||
@ -137,7 +188,7 @@ describe('BuildCleaner', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fsReaddirSpy = spyOn(fs, 'readdir').and.callFake((_: string, cb: typeof readdirCb) => readdirCb = cb);
|
||||
promise = (cleaner as any).getExistingBuildNumbers();
|
||||
promise = cleaner.getExistingBuildNumbers();
|
||||
});
|
||||
|
||||
|
||||
@ -203,7 +254,7 @@ describe('BuildCleaner', () => {
|
||||
return new Promise((resolve, reject) => prDeferred = {resolve, reject});
|
||||
});
|
||||
|
||||
promise = (cleaner as any).getOpenPrNumbers();
|
||||
promise = cleaner.getOpenPrNumbers();
|
||||
});
|
||||
|
||||
|
||||
@ -236,6 +287,68 @@ describe('BuildCleaner', () => {
|
||||
prDeferred.resolve([{id: 0, number: 1}, {id: 1, number: 2}, {id: 2, number: 3}]);
|
||||
});
|
||||
|
||||
|
||||
it('should log the number of open PRs', () => {
|
||||
promise.then(prNumbers => {
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith(
|
||||
ANY_DATE, 'BuildCleaner: ', `Open pull requests: ${prNumbers}`);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('getExistingDownloads()', () => {
|
||||
let fsReaddirSpy: jasmine.Spy;
|
||||
let readdirCb: (err: any, files?: string[]) => void;
|
||||
let promise: Promise<string[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
fsReaddirSpy = spyOn(fs, 'readdir').and.callFake((_: string, cb: typeof readdirCb) => readdirCb = cb);
|
||||
promise = cleaner.getExistingDownloads();
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', () => {
|
||||
expect(promise).toEqual(jasmine.any(Promise));
|
||||
});
|
||||
|
||||
|
||||
it('should get the contents of the downloads directory', () => {
|
||||
expect(fsReaddirSpy).toHaveBeenCalled();
|
||||
expect(fsReaddirSpy.calls.argsFor(0)[0]).toBe('/downloads');
|
||||
});
|
||||
|
||||
|
||||
it('should reject if an error occurs while getting the files', done => {
|
||||
promise.catch(err => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
readdirCb('Test');
|
||||
});
|
||||
|
||||
|
||||
it('should resolve with the returned file names', done => {
|
||||
promise.then(result => {
|
||||
expect(result).toEqual(EXISTING_DOWNLOADS);
|
||||
done();
|
||||
});
|
||||
|
||||
readdirCb(null, EXISTING_DOWNLOADS);
|
||||
});
|
||||
|
||||
|
||||
it('should ignore files that do not match the artifactPath', done => {
|
||||
promise.then(result => {
|
||||
expect(result).toEqual(['10-ABCDEF-build.zip', '30-FFFFFFF-build.zip']);
|
||||
done();
|
||||
});
|
||||
|
||||
readdirCb(null, ['10-ABCDEF-build.zip', '20-AAAAAAA-otherfile.zip', '30-FFFFFFF-build.zip']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@ -253,7 +366,7 @@ describe('BuildCleaner', () => {
|
||||
|
||||
it('should test if the directory exists (and return if is does not)', () => {
|
||||
shellTestSpy.and.returnValue(false);
|
||||
(cleaner as any).removeDir('/foo/bar');
|
||||
cleaner.removeDir('/foo/bar');
|
||||
|
||||
expect(shellTestSpy).toHaveBeenCalledWith('-d', '/foo/bar');
|
||||
expect(shellChmodSpy).not.toHaveBeenCalled();
|
||||
@ -262,99 +375,127 @@ describe('BuildCleaner', () => {
|
||||
|
||||
|
||||
it('should remove the specified directory and its content', () => {
|
||||
(cleaner as any).removeDir('/foo/bar');
|
||||
cleaner.removeDir('/foo/bar');
|
||||
expect(shellRmSpy).toHaveBeenCalledWith('-rf', '/foo/bar');
|
||||
});
|
||||
|
||||
|
||||
it('should make the directory and its content writable before removing', () => {
|
||||
shellRmSpy.and.callFake(() => expect(shellChmodSpy).toHaveBeenCalledWith('-R', 'a+w', '/foo/bar'));
|
||||
(cleaner as any).removeDir('/foo/bar');
|
||||
cleaner.removeDir('/foo/bar');
|
||||
|
||||
expect(shellRmSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should catch errors and log them', () => {
|
||||
const consoleErrorSpy = spyOn(console, 'error');
|
||||
shellRmSpy.and.callFake(() => {
|
||||
// tslint:disable-next-line: no-string-throw
|
||||
throw 'Test';
|
||||
});
|
||||
|
||||
(cleaner as any).removeDir('/foo/bar');
|
||||
cleaner.removeDir('/foo/bar');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy.calls.argsFor(0)[0]).toContain('Unable to remove \'/foo/bar\'');
|
||||
expect(consoleErrorSpy.calls.argsFor(0)[1]).toBe('Test');
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith('ERROR: Unable to remove \'/foo/bar\' due to:', 'Test');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('removeUnnecessaryBuilds()', () => {
|
||||
let consoleLogSpy: jasmine.Spy;
|
||||
let cleanerRemoveDirSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = spyOn(console, 'log');
|
||||
cleanerRemoveDirSpy = spyOn(cleaner as any, 'removeDir');
|
||||
cleanerRemoveDirSpy = spyOn(cleaner, 'removeDir');
|
||||
});
|
||||
|
||||
|
||||
it('should log the number of existing builds, open PRs and builds to be removed', () => {
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3], [3, 4, 5, 6]);
|
||||
it('should log the number of existing builds and builds to be removed', () => {
|
||||
cleaner.removeUnnecessaryBuilds([1, 2, 3], [3, 4, 5, 6]);
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith('Existing builds: 3');
|
||||
expect(console.log).toHaveBeenCalledWith('Open pull requests: 4');
|
||||
expect(console.log).toHaveBeenCalledWith('Removing 2 build(s): 1, 2');
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith('Existing builds: 3');
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith('Removing 2 build(s): 1, 2');
|
||||
});
|
||||
|
||||
|
||||
it('should construct full paths to directories (by prepending \'buildsDir\')', () => {
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3], []);
|
||||
cleaner.removeUnnecessaryBuilds([1, 2, 3], []);
|
||||
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/1'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/2'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/3'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/1'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/2'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/3'));
|
||||
});
|
||||
|
||||
|
||||
it('should try removing hidden directories as well', () => {
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3], []);
|
||||
cleaner.removeUnnecessaryBuilds([1, 2, 3], []);
|
||||
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
|
||||
});
|
||||
|
||||
|
||||
it('should remove the builds that do not correspond to open PRs', () => {
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], [2, 4]);
|
||||
cleaner.removeUnnecessaryBuilds([1, 2, 3, 4], [2, 4]);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(4);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/1'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/3'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/1'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/3'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
|
||||
cleanerRemoveDirSpy.calls.reset();
|
||||
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], [1, 2, 3, 4]);
|
||||
cleaner.removeUnnecessaryBuilds([1, 2, 3, 4], [1, 2, 3, 4]);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(0);
|
||||
cleanerRemoveDirSpy.calls.reset();
|
||||
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], []);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(8);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/1'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/2'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/3'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/4'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}4`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/1'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/2'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/3'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/4'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}4`));
|
||||
cleanerRemoveDirSpy.calls.reset();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('removeUnnecessaryDownloads()', () => {
|
||||
let shellRmSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
shellRmSpy = spyOn(shell, 'rm');
|
||||
});
|
||||
|
||||
|
||||
it('should log the number of existing downloads and downloads to be removed', () => {
|
||||
cleaner.removeUnnecessaryDownloads(EXISTING_DOWNLOADS, OPEN_PRS);
|
||||
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith('Existing downloads: 4');
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith('Removing 2 download(s): 20-ABCDEF0-build.zip, 20-1234567-build.zip');
|
||||
});
|
||||
|
||||
|
||||
it('should construct full paths to directories (by prepending \'downloadsDir\')', () => {
|
||||
cleaner.removeUnnecessaryDownloads(['dl-1', 'dl-2', 'dl-3'], []);
|
||||
|
||||
expect(shellRmSpy).toHaveBeenCalledWith(normalize('/downloads/dl-1'));
|
||||
expect(shellRmSpy).toHaveBeenCalledWith(normalize('/downloads/dl-2'));
|
||||
expect(shellRmSpy).toHaveBeenCalledWith(normalize('/downloads/dl-3'));
|
||||
});
|
||||
|
||||
|
||||
it('should remove the downloads that do not correspond to open PRs', () => {
|
||||
cleaner.removeUnnecessaryDownloads(EXISTING_DOWNLOADS, OPEN_PRS);
|
||||
expect(shellRmSpy).toHaveBeenCalledTimes(2);
|
||||
expect(shellRmSpy).toHaveBeenCalledWith(normalize('/downloads/20-ABCDEF0-build.zip'));
|
||||
expect(shellRmSpy).toHaveBeenCalledWith(normalize('/downloads/20-1234567-build.zip'));
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,134 @@
|
||||
import * as nock from 'nock';
|
||||
import {CircleCiApi} from '../../lib/common/circle-ci-api';
|
||||
|
||||
const ORG = 'testorg';
|
||||
const REPO = 'testrepo';
|
||||
const TOKEN = 'xxxx';
|
||||
const BASE_URL = `https://circleci.com/api/v1.1/project/github/${ORG}/${REPO}`;
|
||||
|
||||
describe('CircleCIApi', () => {
|
||||
describe('constructor()', () => {
|
||||
it('should throw if \'githubOrg\' is missing or empty', () => {
|
||||
expect(() => new CircleCiApi('', REPO, TOKEN)).
|
||||
toThrowError('Missing or empty required parameter \'githubOrg\'!');
|
||||
});
|
||||
|
||||
it('should throw if \'githubRepo\' is missing or empty', () => {
|
||||
expect(() => new CircleCiApi(ORG, '', TOKEN)).
|
||||
toThrowError('Missing or empty required parameter \'githubRepo\'!');
|
||||
});
|
||||
|
||||
it('should throw if \'circleCiToken\' is missing or empty', () => {
|
||||
expect(() => new CircleCiApi(ORG, REPO, '')).
|
||||
toThrowError('Missing or empty required parameter \'circleCiToken\'!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildInfo', () => {
|
||||
it('should make a request to the CircleCI API for the given build number', async () => {
|
||||
const api = new CircleCiApi(ORG, REPO, TOKEN);
|
||||
const buildNum = 12345;
|
||||
const expectedBuildInfo: any = { org: ORG, repo: REPO, build_num: buildNum };
|
||||
|
||||
const request = nock(BASE_URL)
|
||||
.get(`/${buildNum}?circle-token=${TOKEN}`)
|
||||
.reply(200, expectedBuildInfo);
|
||||
|
||||
const buildInfo = await api.getBuildInfo(buildNum);
|
||||
expect(buildInfo).toEqual(expectedBuildInfo);
|
||||
request.done();
|
||||
});
|
||||
|
||||
it('should throw an error if the request fails', async () => {
|
||||
const api = new CircleCiApi(ORG, REPO, TOKEN);
|
||||
const buildNum = 12345;
|
||||
const errorMessage = 'Invalid request';
|
||||
const request = nock(BASE_URL).get(`/${buildNum}?circle-token=${TOKEN}`);
|
||||
|
||||
try {
|
||||
request.replyWithError(errorMessage);
|
||||
await api.getBuildInfo(buildNum);
|
||||
throw new Error('Exception Expected');
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
`CircleCI build info request failed ` +
|
||||
`(request to ${BASE_URL}/${buildNum}?circle-token=${TOKEN} failed, reason: ${errorMessage})`);
|
||||
}
|
||||
|
||||
try {
|
||||
request.reply(404, errorMessage);
|
||||
await api.getBuildInfo(buildNum);
|
||||
throw new Error('Exception Expected');
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
`CircleCI build info request failed ` +
|
||||
`(request to ${BASE_URL}/${buildNum}?circle-token=${TOKEN} failed, reason: ${errorMessage})`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildArtifactUrl', () => {
|
||||
it('should make a request to the CircleCI API for the given build number', async () => {
|
||||
const api = new CircleCiApi(ORG, REPO, TOKEN);
|
||||
const buildNum = 12345;
|
||||
const artifact0: any = { path: 'some/path/0', url: 'https://url/0' };
|
||||
const artifact1: any = { path: 'some/path/1', url: 'https://url/1' };
|
||||
const artifact2: any = { path: 'some/path/2', url: 'https://url/2' };
|
||||
const request = nock(BASE_URL)
|
||||
.get(`/${buildNum}/artifacts?circle-token=${TOKEN}`)
|
||||
.reply(200, [artifact0, artifact1, artifact2]);
|
||||
|
||||
const artifactUrl = await api.getBuildArtifactUrl(buildNum, 'some/path/1');
|
||||
expect(artifactUrl).toEqual('https://url/1');
|
||||
request.done();
|
||||
});
|
||||
|
||||
|
||||
it('should throw an error if the request fails', async () => {
|
||||
const api = new CircleCiApi(ORG, REPO, TOKEN);
|
||||
const buildNum = 12345;
|
||||
const errorMessage = 'Invalid request';
|
||||
const request = nock(BASE_URL).get(`/${buildNum}/artifacts?circle-token=${TOKEN}`);
|
||||
|
||||
try {
|
||||
request.replyWithError(errorMessage);
|
||||
await api.getBuildArtifactUrl(buildNum, 'some/path/1');
|
||||
throw new Error('Exception Expected');
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
`CircleCI artifact URL request failed ` +
|
||||
`(request to ${BASE_URL}/${buildNum}/artifacts?circle-token=${TOKEN} failed, reason: ${errorMessage})`);
|
||||
}
|
||||
|
||||
try {
|
||||
request.reply(404, errorMessage);
|
||||
await api.getBuildArtifactUrl(buildNum, 'some/path/1');
|
||||
throw new Error('Exception Expected');
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
`CircleCI artifact URL request failed ` +
|
||||
`(request to ${BASE_URL}/${buildNum}/artifacts?circle-token=${TOKEN} failed, reason: ${errorMessage})`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw an error if the response does not contain the specified artifact', async () => {
|
||||
const api = new CircleCiApi(ORG, REPO, TOKEN);
|
||||
const buildNum = 12345;
|
||||
const artifact0: any = { path: 'some/path/0', url: 'https://url/0' };
|
||||
const artifact1: any = { path: 'some/path/1', url: 'https://url/1' };
|
||||
const artifact2: any = { path: 'some/path/2', url: 'https://url/2' };
|
||||
nock(BASE_URL)
|
||||
.get(`/${buildNum}/artifacts?circle-token=${TOKEN}`)
|
||||
.reply(200, [artifact0, artifact1, artifact2]);
|
||||
|
||||
try {
|
||||
await api.getBuildArtifactUrl(buildNum, 'some/path/3');
|
||||
throw new Error('Exception Expected');
|
||||
} catch (err) {
|
||||
expect(err.message).toEqual(
|
||||
`CircleCI artifact URL request failed ` +
|
||||
`(Missing artifact (some/path/3) for CircleCI build: ${buildNum})`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -1,7 +1,5 @@
|
||||
// Imports
|
||||
import {EventEmitter} from 'events';
|
||||
import {ClientRequest, IncomingMessage} from 'http';
|
||||
import * as https from 'https';
|
||||
import * as nock from 'nock';
|
||||
import {GithubApi} from '../../lib/common/github-api';
|
||||
|
||||
// Tests
|
||||
@ -110,39 +108,6 @@ describe('GithubApi', () => {
|
||||
});
|
||||
|
||||
|
||||
// Protected methods
|
||||
|
||||
describe('buildPath()', () => {
|
||||
|
||||
it('should return the pathname if no params', () => {
|
||||
expect((api as any).buildPath('/foo')).toBe('/foo');
|
||||
expect((api as any).buildPath('/foo', undefined)).toBe('/foo');
|
||||
expect((api as any).buildPath('/foo', null)).toBe('/foo');
|
||||
});
|
||||
|
||||
|
||||
it('should append the params to the pathname', () => {
|
||||
expect((api as any).buildPath('/foo', {bar: 'baz'})).toBe('/foo?bar=baz');
|
||||
});
|
||||
|
||||
|
||||
it('should join the params with \'&\'', () => {
|
||||
expect((api as any).buildPath('/foo', {bar: 1, baz: 2})).toBe('/foo?bar=1&baz=2');
|
||||
});
|
||||
|
||||
|
||||
it('should ignore undefined/null params', () => {
|
||||
expect((api as any).buildPath('/foo', {bar: undefined, baz: null})).toBe('/foo');
|
||||
});
|
||||
|
||||
|
||||
it('should encode param values as URI components', () => {
|
||||
expect((api as any).buildPath('/foo', {bar: 'b a&z'})).toBe('/foo?bar=b%20a%26z');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('getPaginated()', () => {
|
||||
let deferreds: {resolve: (v: any) => void, reject: (v: any) => void}[];
|
||||
|
||||
@ -161,8 +126,8 @@ describe('GithubApi', () => {
|
||||
(api as any).getPaginated('/foo/bar');
|
||||
(api as any).getPaginated('/foo/bar', {baz: 'qux'});
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith('/foo/bar', {page: 0, per_page: 100});
|
||||
expect(api.get).toHaveBeenCalledWith('/foo/bar', {baz: 'qux', page: 0, per_page: 100});
|
||||
expect(api.get).toHaveBeenCalledWith('/foo/bar', {page: 1, per_page: 100});
|
||||
expect(api.get).toHaveBeenCalledWith('/foo/bar', {baz: 'qux', page: 1, per_page: 100});
|
||||
});
|
||||
|
||||
|
||||
@ -197,9 +162,9 @@ describe('GithubApi', () => {
|
||||
const paramsForPage = (page: number) => ({baz: 'qux', page, per_page: 100});
|
||||
|
||||
expect(apiGetSpy).toHaveBeenCalledTimes(3);
|
||||
expect(apiGetSpy.calls.argsFor(0)).toEqual(['/foo/bar', paramsForPage(0)]);
|
||||
expect(apiGetSpy.calls.argsFor(1)).toEqual(['/foo/bar', paramsForPage(1)]);
|
||||
expect(apiGetSpy.calls.argsFor(2)).toEqual(['/foo/bar', paramsForPage(2)]);
|
||||
expect(apiGetSpy.calls.argsFor(0)).toEqual(['/foo/bar', paramsForPage(1)]);
|
||||
expect(apiGetSpy.calls.argsFor(1)).toEqual(['/foo/bar', paramsForPage(2)]);
|
||||
expect(apiGetSpy.calls.argsFor(2)).toEqual(['/foo/bar', paramsForPage(3)]);
|
||||
|
||||
expect(data).toEqual(allItems);
|
||||
|
||||
@ -218,191 +183,162 @@ describe('GithubApi', () => {
|
||||
});
|
||||
|
||||
|
||||
// Protected methods
|
||||
|
||||
describe('buildPath()', () => {
|
||||
|
||||
it('should return the pathname if no params', () => {
|
||||
expect((api as any).buildPath('/foo')).toBe('/foo');
|
||||
expect((api as any).buildPath('/foo', undefined)).toBe('/foo');
|
||||
expect((api as any).buildPath('/foo', null)).toBe('/foo');
|
||||
});
|
||||
|
||||
|
||||
it('should append the params to the pathname', () => {
|
||||
expect((api as any).buildPath('/foo', {bar: 'baz'})).toBe('/foo?bar=baz');
|
||||
});
|
||||
|
||||
|
||||
it('should join the params with \'&\'', () => {
|
||||
expect((api as any).buildPath('/foo', {bar: 1, baz: 2})).toBe('/foo?bar=1&baz=2');
|
||||
});
|
||||
|
||||
|
||||
it('should ignore undefined/null params', () => {
|
||||
expect((api as any).buildPath('/foo', {bar: undefined, baz: null})).toBe('/foo');
|
||||
});
|
||||
|
||||
|
||||
it('should encode param values as URI components', () => {
|
||||
expect((api as any).buildPath('/foo', {bar: 'b a&z'})).toBe('/foo?bar=b%20a%26z');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('request()', () => {
|
||||
let httpsRequestSpy: jasmine.Spy;
|
||||
let latestRequest: ClientRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
const originalRequest = https.request;
|
||||
|
||||
httpsRequestSpy = spyOn(https, 'request').and.callFake((...args: any[]) => {
|
||||
latestRequest = originalRequest.apply(https, args);
|
||||
|
||||
spyOn(latestRequest, 'on').and.callThrough();
|
||||
spyOn(latestRequest, 'end');
|
||||
|
||||
return latestRequest;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', () => {
|
||||
nock('https://api.github.com').get('').reply(200);
|
||||
expect((api as any).request()).toEqual(jasmine.any(Promise));
|
||||
});
|
||||
|
||||
|
||||
it('should call \'https.request()\' with the correct options', () => {
|
||||
(api as any).request('method', 'path');
|
||||
const requestHandler = nock('https://api.github.com')
|
||||
.intercept('/path', 'method')
|
||||
.reply(200);
|
||||
|
||||
expect(httpsRequestSpy).toHaveBeenCalled();
|
||||
expect(httpsRequestSpy.calls.argsFor(0)[0]).toEqual(jasmine.objectContaining({
|
||||
headers: jasmine.objectContaining({
|
||||
'User-Agent': `Node/${process.versions.node}`,
|
||||
}),
|
||||
host: 'api.github.com',
|
||||
method: 'method',
|
||||
path: 'path',
|
||||
}));
|
||||
(api as any).request('method', '/path');
|
||||
requestHandler.done();
|
||||
});
|
||||
|
||||
|
||||
it('should call specify an \'Authorization\' header if \'githubToken\' is present', () => {
|
||||
(api as any).request('method', 'path');
|
||||
|
||||
expect(httpsRequestSpy).toHaveBeenCalled();
|
||||
expect(httpsRequestSpy.calls.argsFor(0)[0].headers).toEqual(jasmine.objectContaining({
|
||||
Authorization: 'token 12345',
|
||||
}));
|
||||
it('should add the \'Authorization\' header containing the \'githubToken\'', () => {
|
||||
const requestHandler = nock('https://api.github.com')
|
||||
.intercept('/path', 'method', undefined, {
|
||||
reqheaders: {Authorization: 'token 12345'},
|
||||
})
|
||||
.reply(200);
|
||||
(api as any).request('method', '/path');
|
||||
requestHandler.done();
|
||||
});
|
||||
|
||||
|
||||
it('should reject on request error', done => {
|
||||
(api as any).request('method', 'path').catch((err: any) => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
latestRequest.emit('error', 'Test');
|
||||
});
|
||||
|
||||
|
||||
it('should send the request (i.e. call \'end()\')', () => {
|
||||
(api as any).request('method', 'path');
|
||||
expect(latestRequest.end).toHaveBeenCalled();
|
||||
it('should reject on request error', async () => {
|
||||
nock('https://api.github.com')
|
||||
.intercept('/path', 'method')
|
||||
.replyWithError('Test');
|
||||
let message = 'Failed to reject error';
|
||||
await (api as any).request('method', '/path').catch((err: any) => message = err.message);
|
||||
expect(message).toEqual('Test');
|
||||
});
|
||||
|
||||
|
||||
it('should \'JSON.stringify\' and send the data along with the request', () => {
|
||||
(api as any).request('method', 'path');
|
||||
expect(latestRequest.end).toHaveBeenCalledWith(null);
|
||||
|
||||
(api as any).request('method', 'path', {key: 'value'});
|
||||
expect(latestRequest.end).toHaveBeenCalledWith('{"key":"value"}');
|
||||
const data = {key: 'value'};
|
||||
const requestHandler = nock('https://api.github.com')
|
||||
.intercept('/path', 'method', JSON.stringify(data))
|
||||
.reply(200);
|
||||
(api as any).request('method', '/path', data);
|
||||
requestHandler.done();
|
||||
});
|
||||
|
||||
|
||||
describe('onResponse', () => {
|
||||
let promise: Promise<object>;
|
||||
let respond: (statusCode: number) => IncomingMessage;
|
||||
it('should reject if response statusCode is <200', done => {
|
||||
const requestHandler = nock('https://api.github.com')
|
||||
.intercept('/path', 'method')
|
||||
.reply(199);
|
||||
|
||||
beforeEach(() => {
|
||||
promise = (api as any).request('method', 'path');
|
||||
|
||||
respond = (statusCode: number) => {
|
||||
const mockResponse = new EventEmitter() as IncomingMessage;
|
||||
mockResponse.statusCode = statusCode;
|
||||
|
||||
const onResponse = httpsRequestSpy.calls.argsFor(0)[1];
|
||||
onResponse(mockResponse);
|
||||
|
||||
return mockResponse;
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
it('should reject on response error', done => {
|
||||
promise.catch(err => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
const res = respond(200);
|
||||
res.emit('error', 'Test');
|
||||
});
|
||||
|
||||
|
||||
it('should reject if returned statusCode is <200', done => {
|
||||
promise.catch(err => {
|
||||
(api as any).request('method', '/path')
|
||||
.catch((err: string) => {
|
||||
expect(err).toContain('failed');
|
||||
expect(err).toContain('status: 199');
|
||||
done();
|
||||
});
|
||||
|
||||
const res = respond(199);
|
||||
res.emit('end');
|
||||
requestHandler.done();
|
||||
});
|
||||
|
||||
|
||||
it('should reject if returned statusCode is >=400', done => {
|
||||
promise.catch(err => {
|
||||
it('should reject if response statusCode is >=400', done => {
|
||||
const requestHandler = nock('https://api.github.com')
|
||||
.intercept('/path', 'method')
|
||||
.reply(400);
|
||||
|
||||
(api as any).request('method', '/path')
|
||||
.catch((err: string) => {
|
||||
expect(err).toContain('failed');
|
||||
expect(err).toContain('status: 400');
|
||||
done();
|
||||
});
|
||||
|
||||
const res = respond(400);
|
||||
res.emit('end');
|
||||
requestHandler.done();
|
||||
});
|
||||
|
||||
|
||||
it('should include the response text in the rejection message', done => {
|
||||
promise.catch(err => {
|
||||
const requestHandler = nock('https://api.github.com')
|
||||
.intercept('/path', 'method')
|
||||
.reply(500, 'Test');
|
||||
|
||||
(api as any).request('method', '/path')
|
||||
.catch((err: string) => {
|
||||
expect(err).toContain('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
const res = respond(500);
|
||||
res.emit('data', 'Test');
|
||||
res.emit('end');
|
||||
requestHandler.done();
|
||||
});
|
||||
|
||||
|
||||
it('should resolve if returned statusCode is <=200 <400', done => {
|
||||
promise.then(done);
|
||||
it('should resolve if returned statusCode is >=200 and <400', done => {
|
||||
const requestHandler = nock('https://api.github.com')
|
||||
.intercept('/path', 'method')
|
||||
.reply(200);
|
||||
|
||||
const res = respond(200);
|
||||
res.emit('data', '{}');
|
||||
res.emit('end');
|
||||
(api as any).request('method', '/path').then(done);
|
||||
requestHandler.done();
|
||||
});
|
||||
|
||||
|
||||
it('should resolve with the response text \'JSON.parsed\'', done => {
|
||||
promise.then(data => {
|
||||
it('should parse the response body into an object using \'JSON.parse\'', done => {
|
||||
const requestHandler = nock('https://api.github.com')
|
||||
.intercept('/path', 'method')
|
||||
.reply(300, '{"foo": "bar"}');
|
||||
|
||||
(api as any).request('method', '/path').then((data: any) => {
|
||||
expect(data).toEqual({foo: 'bar'});
|
||||
done();
|
||||
});
|
||||
|
||||
const res = respond(300);
|
||||
res.emit('data', '{"foo":"bar"}');
|
||||
res.emit('end');
|
||||
requestHandler.done();
|
||||
});
|
||||
|
||||
|
||||
it('should collect and concatenate the whole response text', done => {
|
||||
promise.then(data => {
|
||||
expect(data).toEqual({foo: 'bar', baz: 'qux'});
|
||||
done();
|
||||
});
|
||||
|
||||
const res = respond(300);
|
||||
res.emit('data', '{"foo":');
|
||||
res.emit('data', '"bar","baz"');
|
||||
res.emit('data', ':"qux"}');
|
||||
res.emit('end');
|
||||
});
|
||||
|
||||
|
||||
it('should reject if the response text is malformed JSON', done => {
|
||||
promise.catch(err => {
|
||||
const requestHandler = nock('https://api.github.com')
|
||||
.intercept('/path', 'method')
|
||||
.reply(300, '}');
|
||||
|
||||
(api as any).request('method', '/path').catch((err: any) => {
|
||||
expect(err).toEqual(jasmine.any(SyntaxError));
|
||||
done();
|
||||
});
|
||||
|
||||
const res = respond(300);
|
||||
res.emit('data', '}');
|
||||
res.emit('end');
|
||||
});
|
||||
|
||||
requestHandler.done();
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,20 +1,27 @@
|
||||
// Imports
|
||||
import {GithubApi} from '../../lib/common/github-api';
|
||||
import {GithubPullRequests} from '../../lib/common/github-pull-requests';
|
||||
|
||||
// Tests
|
||||
describe('GithubPullRequests', () => {
|
||||
let githubApi: jasmine.SpyObj<GithubApi>;
|
||||
|
||||
describe('constructor()', () => {
|
||||
|
||||
it('should throw if \'githubToken\' is missing or empty', () => {
|
||||
expect(() => new GithubPullRequests('', 'foo/bar')).
|
||||
toThrowError('Missing or empty required parameter \'githubToken\'!');
|
||||
beforeEach(() => {
|
||||
githubApi = jasmine.createSpyObj('githubApi', ['post', 'get', 'getPaginated']);
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'repoSlug\' is missing or empty', () => {
|
||||
expect(() => new GithubPullRequests('12345', '')).
|
||||
toThrowError('Missing or empty required parameter \'repoSlug\'!');
|
||||
describe('constructor()', () => {
|
||||
|
||||
it('should throw if \'githubOrg\' is missing or empty', () => {
|
||||
expect(() => new GithubPullRequests(githubApi, '', 'bar')).
|
||||
toThrowError('Missing or empty required parameter \'githubOrg\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubRepo\' is missing or empty', () => {
|
||||
expect(() => new GithubPullRequests(githubApi, 'foo', '')).
|
||||
toThrowError('Missing or empty required parameter \'githubRepo\'!');
|
||||
});
|
||||
|
||||
});
|
||||
@ -22,17 +29,9 @@ describe('GithubPullRequests', () => {
|
||||
|
||||
describe('addComment()', () => {
|
||||
let prs: GithubPullRequests;
|
||||
let deferred: {resolve: (v: any) => void, reject: (v: any) => void};
|
||||
|
||||
beforeEach(() => {
|
||||
prs = new GithubPullRequests('12345', 'foo/bar');
|
||||
|
||||
spyOn(prs, 'post').and.callFake(() => new Promise((resolve, reject) => deferred = {resolve, reject}));
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', () => {
|
||||
expect(prs.addComment(42, 'body')).toEqual(jasmine.any(Promise));
|
||||
prs = new GithubPullRequests(githubApi, 'foo', 'bar');
|
||||
});
|
||||
|
||||
|
||||
@ -47,30 +46,28 @@ describe('GithubPullRequests', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should call \'post()\' with the correct pathname, params and data', () => {
|
||||
it('should make a POST request to Github with the correct pathname, params and data', () => {
|
||||
githubApi.post.and.callFake(() => Promise.resolve());
|
||||
prs.addComment(42, 'body');
|
||||
|
||||
expect(prs.post).toHaveBeenCalledWith('/repos/foo/bar/issues/42/comments', null, {body: 'body'});
|
||||
expect(githubApi.post).toHaveBeenCalledWith('/repos/foo/bar/issues/42/comments', null, {body: 'body'});
|
||||
});
|
||||
|
||||
|
||||
it('should reject if the request fails', done => {
|
||||
githubApi.post.and.callFake(() => Promise.reject('Test'));
|
||||
prs.addComment(42, 'body').catch(err => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
deferred.reject('Test');
|
||||
});
|
||||
|
||||
|
||||
it('should resolve with the returned response', done => {
|
||||
it('should resolve with the data from the Github POST', done => {
|
||||
githubApi.post.and.callFake(() => Promise.resolve('Test'));
|
||||
prs.addComment(42, 'body').then(data => {
|
||||
expect(data as any).toBe('Test');
|
||||
expect(data).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
deferred.resolve('Test');
|
||||
});
|
||||
|
||||
});
|
||||
@ -78,23 +75,25 @@ describe('GithubPullRequests', () => {
|
||||
|
||||
describe('fetch()', () => {
|
||||
let prs: GithubPullRequests;
|
||||
let prsGetSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
prs = new GithubPullRequests('12345', 'foo/bar');
|
||||
prsGetSpy = spyOn(prs as any, 'get');
|
||||
prs = new GithubPullRequests(githubApi, 'foo', 'bar');
|
||||
});
|
||||
|
||||
|
||||
it('should call \'get()\' with the correct pathname', () => {
|
||||
it('should make a GET request to GitHub with the correct pathname', () => {
|
||||
prs.fetch(42);
|
||||
expect(prsGetSpy).toHaveBeenCalledWith('/repos/foo/bar/issues/42');
|
||||
expect(githubApi.get).toHaveBeenCalledWith('/repos/foo/bar/issues/42');
|
||||
});
|
||||
|
||||
|
||||
it('should forward the value returned by \'get()\'', () => {
|
||||
prsGetSpy.and.returnValue('Test');
|
||||
expect(prs.fetch(42) as any).toBe('Test');
|
||||
it('should resolve with the data returned from GitHub', done => {
|
||||
const expected: any = {number: 42};
|
||||
githubApi.get.and.callFake(() => Promise.resolve(expected));
|
||||
prs.fetch(42).then(data => {
|
||||
expect(data).toEqual(expected);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@ -102,13 +101,8 @@ describe('GithubPullRequests', () => {
|
||||
|
||||
describe('fetchAll()', () => {
|
||||
let prs: GithubPullRequests;
|
||||
let prsGetPaginatedSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
prs = new GithubPullRequests('12345', 'foo/bar');
|
||||
prsGetPaginatedSpy = spyOn(prs as any, 'getPaginated');
|
||||
spyOn(console, 'log');
|
||||
});
|
||||
beforeEach(() => prs = new GithubPullRequests(githubApi, 'foo', 'bar'));
|
||||
|
||||
|
||||
it('should call \'getPaginated()\' with the correct pathname and params', () => {
|
||||
@ -118,24 +112,50 @@ describe('GithubPullRequests', () => {
|
||||
prs.fetchAll('closed');
|
||||
prs.fetchAll('open');
|
||||
|
||||
expect(prsGetPaginatedSpy).toHaveBeenCalledTimes(3);
|
||||
expect(prsGetPaginatedSpy.calls.argsFor(0)).toEqual([expectedPathname, {state: 'all'}]);
|
||||
expect(prsGetPaginatedSpy.calls.argsFor(1)).toEqual([expectedPathname, {state: 'closed'}]);
|
||||
expect(prsGetPaginatedSpy.calls.argsFor(2)).toEqual([expectedPathname, {state: 'open'}]);
|
||||
expect(githubApi.getPaginated).toHaveBeenCalledTimes(3);
|
||||
expect(githubApi.getPaginated.calls.argsFor(0)).toEqual([expectedPathname, {state: 'all'}]);
|
||||
expect(githubApi.getPaginated.calls.argsFor(1)).toEqual([expectedPathname, {state: 'closed'}]);
|
||||
expect(githubApi.getPaginated.calls.argsFor(2)).toEqual([expectedPathname, {state: 'open'}]);
|
||||
});
|
||||
|
||||
|
||||
it('should default to \'all\' if no state is specified', () => {
|
||||
prs.fetchAll();
|
||||
expect(prsGetPaginatedSpy).toHaveBeenCalledWith('/repos/foo/bar/pulls', {state: 'all'});
|
||||
expect(githubApi.getPaginated).toHaveBeenCalledWith('/repos/foo/bar/pulls', {state: 'all'});
|
||||
});
|
||||
|
||||
|
||||
it('should forward the value returned by \'getPaginated()\'', () => {
|
||||
prsGetPaginatedSpy.and.returnValue('Test');
|
||||
githubApi.getPaginated.and.returnValue('Test');
|
||||
expect(prs.fetchAll() as any).toBe('Test');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('fetchFiles()', () => {
|
||||
let prs: GithubPullRequests;
|
||||
|
||||
beforeEach(() => {
|
||||
prs = new GithubPullRequests(githubApi, 'foo', 'bar');
|
||||
});
|
||||
|
||||
|
||||
it('should make a paginated GET request to GitHub with the correct pathname', () => {
|
||||
prs.fetchFiles(42);
|
||||
expect(githubApi.getPaginated).toHaveBeenCalledWith('/repos/foo/bar/pulls/42/files');
|
||||
});
|
||||
|
||||
|
||||
it('should resolve with the data returned from GitHub', done => {
|
||||
const expected: any = [{sha: 'ABCDE', filename: 'a/b/c'}, {sha: '12345', filename: 'x/y/z'}];
|
||||
githubApi.getPaginated.and.callFake(() => Promise.resolve(expected));
|
||||
prs.fetchFiles(42).then(data => {
|
||||
expect(data).toEqual(expected);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,43 +1,40 @@
|
||||
// Imports
|
||||
import {GithubApi} from '../../lib/common/github-api';
|
||||
import {GithubTeams} from '../../lib/common/github-teams';
|
||||
|
||||
// Tests
|
||||
describe('GithubTeams', () => {
|
||||
|
||||
let githubApi: jasmine.SpyObj<GithubApi>;
|
||||
|
||||
beforeEach(() => {
|
||||
githubApi = jasmine.createSpyObj('githubApi', ['post', 'get', 'getPaginated']);
|
||||
});
|
||||
|
||||
describe('constructor()', () => {
|
||||
|
||||
it('should throw if \'githubToken\' is missing or empty', () => {
|
||||
expect(() => new GithubTeams('', 'org')).
|
||||
toThrowError('Missing or empty required parameter \'githubToken\'!');
|
||||
it('should throw if \'githubOrg\' is missing or empty', () => {
|
||||
expect(() => new GithubTeams(githubApi, '')).
|
||||
toThrowError('Missing or empty required parameter \'githubOrg\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'organization\' is missing or empty', () => {
|
||||
expect(() => new GithubTeams('12345', '')).
|
||||
toThrowError('Missing or empty required parameter \'organization\'!');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('fetchAll()', () => {
|
||||
let teams: GithubTeams;
|
||||
let teamsGetPaginatedSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
teams = new GithubTeams('12345', 'foo');
|
||||
teamsGetPaginatedSpy = spyOn(teams as any, 'getPaginated');
|
||||
teams = new GithubTeams(githubApi, 'foo');
|
||||
});
|
||||
|
||||
|
||||
it('should call \'getPaginated()\' with the correct pathname and params', () => {
|
||||
teams.fetchAll();
|
||||
expect(teamsGetPaginatedSpy).toHaveBeenCalledWith('/orgs/foo/teams');
|
||||
expect(githubApi.getPaginated).toHaveBeenCalledWith('/orgs/foo/teams');
|
||||
});
|
||||
|
||||
|
||||
it('should forward the value returned by \'getPaginated()\'', () => {
|
||||
teamsGetPaginatedSpy.and.returnValue('Test');
|
||||
githubApi.getPaginated.and.returnValue('Test');
|
||||
expect(teams.fetchAll() as any).toBe('Test');
|
||||
});
|
||||
|
||||
@ -46,19 +43,15 @@ describe('GithubTeams', () => {
|
||||
|
||||
describe('isMemberById()', () => {
|
||||
let teams: GithubTeams;
|
||||
let teamsGetSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
teams = new GithubTeams('12345', 'foo');
|
||||
teamsGetSpy = spyOn(teams, 'get').and.returnValue(Promise.resolve(null));
|
||||
teams = new GithubTeams(githubApi, 'foo');
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', done => {
|
||||
it('should return a promise', () => {
|
||||
githubApi.get.and.callFake(() => Promise.resolve());
|
||||
const promise = teams.isMemberById('user', [1]);
|
||||
promise.then(done); // Do not complete the test (and release the spies) synchronously
|
||||
// to avoid running the actual `get()`.
|
||||
|
||||
expect(promise).toEqual(jasmine.any(Promise));
|
||||
});
|
||||
|
||||
@ -66,42 +59,43 @@ describe('GithubTeams', () => {
|
||||
it('should resolve with false if called with an empty array', done => {
|
||||
teams.isMemberById('user', []).then(isMember => {
|
||||
expect(isMember).toBe(false);
|
||||
expect(teamsGetSpy).not.toHaveBeenCalled();
|
||||
expect(githubApi.get).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should call \'get()\' with the correct pathname', done => {
|
||||
githubApi.get.and.callFake(() => Promise.resolve());
|
||||
teams.isMemberById('user', [1]).then(() => {
|
||||
expect(teamsGetSpy).toHaveBeenCalledWith('/teams/1/memberships/user');
|
||||
expect(githubApi.get).toHaveBeenCalledWith('/teams/1/memberships/user');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should resolve with false if \'get()\' rejects', done => {
|
||||
teamsGetSpy.and.returnValue(Promise.reject(null));
|
||||
githubApi.get.and.callFake(() => Promise.reject(null));
|
||||
teams.isMemberById('user', [1]).then(isMember => {
|
||||
expect(isMember).toBe(false);
|
||||
expect(teamsGetSpy).toHaveBeenCalled();
|
||||
expect(githubApi.get).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should resolve with false if the membership is not active', done => {
|
||||
teamsGetSpy.and.returnValue(Promise.resolve({state: 'pending'}));
|
||||
githubApi.get.and.callFake(() => Promise.resolve({state: 'pending'}));
|
||||
teams.isMemberById('user', [1]).then(isMember => {
|
||||
expect(isMember).toBe(false);
|
||||
expect(teamsGetSpy).toHaveBeenCalled();
|
||||
expect(githubApi.get).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should resolve with true if the membership is active', done => {
|
||||
teamsGetSpy.and.returnValue(Promise.resolve({state: 'active'}));
|
||||
githubApi.get.and.callFake(() => Promise.resolve({state: 'active'}));
|
||||
teams.isMemberById('user', [1]).then(isMember => {
|
||||
expect(isMember).toBe(true);
|
||||
done();
|
||||
@ -115,15 +109,15 @@ describe('GithubTeams', () => {
|
||||
'/teams/2/memberships/user': Promise.reject(null),
|
||||
'/teams/3/memberships/user': Promise.resolve({state: 'active'}),
|
||||
};
|
||||
teamsGetSpy.and.callFake((pathname: string) => trainedResponses[pathname]);
|
||||
githubApi.get.and.callFake((pathname: string) => trainedResponses[pathname]);
|
||||
|
||||
teams.isMemberById('user', [1, 2, 3, 4]).then(isMember => {
|
||||
expect(isMember).toBe(true);
|
||||
|
||||
expect(teamsGetSpy).toHaveBeenCalledTimes(3);
|
||||
expect(teamsGetSpy.calls.argsFor(0)[0]).toBe('/teams/1/memberships/user');
|
||||
expect(teamsGetSpy.calls.argsFor(1)[0]).toBe('/teams/2/memberships/user');
|
||||
expect(teamsGetSpy.calls.argsFor(2)[0]).toBe('/teams/3/memberships/user');
|
||||
expect(githubApi.get).toHaveBeenCalledTimes(3);
|
||||
expect(githubApi.get.calls.argsFor(0)[0]).toBe('/teams/1/memberships/user');
|
||||
expect(githubApi.get.calls.argsFor(1)[0]).toBe('/teams/2/memberships/user');
|
||||
expect(githubApi.get.calls.argsFor(2)[0]).toBe('/teams/3/memberships/user');
|
||||
|
||||
done();
|
||||
});
|
||||
@ -137,16 +131,16 @@ describe('GithubTeams', () => {
|
||||
'/teams/3/memberships/user': Promise.resolve({state: 'not active'}),
|
||||
'/teams/4/memberships/user': Promise.reject(null),
|
||||
};
|
||||
teamsGetSpy.and.callFake((pathname: string) => trainedResponses[pathname]);
|
||||
githubApi.get.and.callFake((pathname: string) => trainedResponses[pathname]);
|
||||
|
||||
teams.isMemberById('user', [1, 2, 3, 4]).then(isMember => {
|
||||
expect(isMember).toBe(false);
|
||||
|
||||
expect(teamsGetSpy).toHaveBeenCalledTimes(4);
|
||||
expect(teamsGetSpy.calls.argsFor(0)[0]).toBe('/teams/1/memberships/user');
|
||||
expect(teamsGetSpy.calls.argsFor(1)[0]).toBe('/teams/2/memberships/user');
|
||||
expect(teamsGetSpy.calls.argsFor(2)[0]).toBe('/teams/3/memberships/user');
|
||||
expect(teamsGetSpy.calls.argsFor(3)[0]).toBe('/teams/4/memberships/user');
|
||||
expect(githubApi.get).toHaveBeenCalledTimes(4);
|
||||
expect(githubApi.get.calls.argsFor(0)[0]).toBe('/teams/1/memberships/user');
|
||||
expect(githubApi.get.calls.argsFor(1)[0]).toBe('/teams/2/memberships/user');
|
||||
expect(githubApi.get.calls.argsFor(2)[0]).toBe('/teams/3/memberships/user');
|
||||
expect(githubApi.get.calls.argsFor(3)[0]).toBe('/teams/4/memberships/user');
|
||||
|
||||
done();
|
||||
});
|
||||
@ -161,7 +155,7 @@ describe('GithubTeams', () => {
|
||||
let teamsIsMemberByIdSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
teams = new GithubTeams('12345', 'foo');
|
||||
teams = new GithubTeams(githubApi, 'foo');
|
||||
|
||||
const mockResponse = Promise.resolve([{id: 1, slug: 'team1'}, {id: 2, slug: 'team2'}]);
|
||||
teamsFetchAllSpy = spyOn(teams, 'fetchAll').and.returnValue(mockResponse);
|
||||
@ -181,7 +175,7 @@ describe('GithubTeams', () => {
|
||||
|
||||
|
||||
it('should resolve with false if \'fetchAll()\' rejects', done => {
|
||||
teamsFetchAllSpy.and.returnValue(Promise.reject(null));
|
||||
teamsFetchAllSpy.and.callFake(() => Promise.reject(null));
|
||||
teams.isMemberBySlug('user', ['team-slug']).then(isMember => {
|
||||
expect(isMember).toBe(false);
|
||||
done();
|
||||
@ -209,7 +203,7 @@ describe('GithubTeams', () => {
|
||||
|
||||
|
||||
it('should resolve with false if \'isMemberById()\' rejects', done => {
|
||||
teamsIsMemberByIdSpy.and.returnValue(Promise.reject(null));
|
||||
teamsIsMemberByIdSpy.and.callFake(() => Promise.reject(null));
|
||||
teams.isMemberBySlug('user', ['team1']).then(isMember => {
|
||||
expect(isMember).toBe(false);
|
||||
expect(teamsIsMemberByIdSpy).toHaveBeenCalled();
|
||||
@ -218,16 +212,17 @@ describe('GithubTeams', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should resolve with the value \'isMemberById()\' resolves with', done => {
|
||||
teamsIsMemberByIdSpy.and.returnValues(Promise.resolve(false), Promise.resolve(true));
|
||||
it('should resolve with the value \'isMemberById()\' resolves with', async () => {
|
||||
|
||||
Promise.all([
|
||||
teams.isMemberBySlug('user', ['team1']).then(isMember => expect(isMember).toBe(false)),
|
||||
teams.isMemberBySlug('user', ['team1']).then(isMember => expect(isMember).toBe(true)),
|
||||
]).then(() => {
|
||||
expect(teamsIsMemberByIdSpy).toHaveBeenCalledTimes(2);
|
||||
done();
|
||||
});
|
||||
teamsIsMemberByIdSpy.and.callFake(() => Promise.resolve(true));
|
||||
const isMember1 = await teams.isMemberBySlug('user', ['team1']);
|
||||
expect(isMember1).toBe(true);
|
||||
expect(teamsIsMemberByIdSpy).toHaveBeenCalledWith('user', [1]);
|
||||
|
||||
teamsIsMemberByIdSpy.and.callFake(() => Promise.resolve(false));
|
||||
const isMember2 = await teams.isMemberBySlug('user', ['team1']);
|
||||
expect(isMember2).toBe(false);
|
||||
expect(teamsIsMemberByIdSpy).toHaveBeenCalledWith('user', [1]);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,9 +1,59 @@
|
||||
// Imports
|
||||
import {assertNotMissingOrEmpty, getEnvVar} from '../../lib/common/utils';
|
||||
import {resolve as resolvePath} from 'path';
|
||||
import {
|
||||
assert,
|
||||
assertNotMissingOrEmpty,
|
||||
computeArtifactDownloadPath,
|
||||
computeShortSha,
|
||||
getEnvVar,
|
||||
getPrInfoFromDownloadPath,
|
||||
Logger,
|
||||
} from '../../lib/common/utils';
|
||||
|
||||
// Tests
|
||||
describe('utils', () => {
|
||||
|
||||
describe('computeShortSha', () => {
|
||||
it('should return only the first SHORT_SHA_LEN characters of the SHA', () => {
|
||||
expect(computeShortSha('0123456789')).toEqual('0123456');
|
||||
expect(computeShortSha('ABC')).toEqual('ABC');
|
||||
expect(computeShortSha('')).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('assert', () => {
|
||||
it('should throw if passed a false value', () => {
|
||||
expect(() => assert(false, 'error message')).toThrowError('error message');
|
||||
});
|
||||
|
||||
it('should not throw if passed a true value', () => {
|
||||
expect(() => assert(true, 'error message')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('computeArtifactDownloadPath', () => {
|
||||
it('should compute an absolute path based on the artifact info provided', () => {
|
||||
const downloadDir = '/a/b/c';
|
||||
const pr = 123;
|
||||
const sha = 'ABCDEF1234567';
|
||||
const artifactPath = 'a/path/to/file.zip';
|
||||
const path = computeArtifactDownloadPath(downloadDir, pr, sha, artifactPath);
|
||||
expect(path).toBe(resolvePath('/a/b/c/123-ABCDEF1-file.zip'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('getPrInfoFromDownloadPath', () => {
|
||||
it('should extract the PR and SHA from the file path', () => {
|
||||
const {pr, sha} = getPrInfoFromDownloadPath('a/b/c/12345-ABCDE-artifact.zip');
|
||||
expect(pr).toEqual(12345);
|
||||
expect(sha).toEqual('ABCDE');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('assertNotMissingOrEmpty()', () => {
|
||||
|
||||
it('should throw if passed an empty value', () => {
|
||||
@ -78,4 +128,79 @@ describe('utils', () => {
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('Logger', () => {
|
||||
let consoleErrorSpy: jasmine.Spy;
|
||||
let consoleInfoSpy: jasmine.Spy;
|
||||
let consoleLogSpy: jasmine.Spy;
|
||||
let consoleWarnSpy: jasmine.Spy;
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = spyOn(console, 'error');
|
||||
consoleInfoSpy = spyOn(console, 'info');
|
||||
consoleLogSpy = spyOn(console, 'log');
|
||||
consoleWarnSpy = spyOn(console, 'warn');
|
||||
|
||||
logger = new Logger('TestScope');
|
||||
});
|
||||
|
||||
|
||||
it('should delegate to `console`', () => {
|
||||
logger.error('foo');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy.calls.argsFor(0)).toContain('foo');
|
||||
|
||||
logger.info('bar');
|
||||
expect(consoleInfoSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleInfoSpy.calls.argsFor(0)).toContain('bar');
|
||||
|
||||
logger.log('baz');
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleLogSpy.calls.argsFor(0)).toContain('baz');
|
||||
|
||||
logger.warn('qux');
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.calls.argsFor(0)).toContain('qux');
|
||||
});
|
||||
|
||||
|
||||
it('should prepend messages with the current date and logger\'s scope', () => {
|
||||
const mockDate = new Date(1337);
|
||||
const expectedDateStr = `[${mockDate}]`;
|
||||
const expectedScopeStr = 'TestScope: ';
|
||||
|
||||
jasmine.clock().mockDate(mockDate);
|
||||
jasmine.clock().withMock(() => {
|
||||
logger.error();
|
||||
logger.info();
|
||||
logger.log();
|
||||
logger.warn();
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expectedDateStr, expectedScopeStr);
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(expectedDateStr, expectedScopeStr);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expectedDateStr, expectedScopeStr);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(expectedDateStr, expectedScopeStr);
|
||||
});
|
||||
|
||||
|
||||
it('should pass all arguments to `console`', () => {
|
||||
const someString = jasmine.any(String);
|
||||
|
||||
logger.error('foo1', 'foo2');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(someString, someString, 'foo1', 'foo2');
|
||||
|
||||
logger.info('bar1', 'bar2');
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(someString, someString, 'bar1', 'bar2');
|
||||
|
||||
logger.log('baz1', 'baz2');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(someString, someString, 'baz1', 'baz2');
|
||||
|
||||
logger.warn('qux1', 'qux2');
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(someString, someString, 'qux1', 'qux2');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,6 +0,0 @@
|
||||
declare namespace jasmine {
|
||||
export interface DoneFn extends Function {
|
||||
(): void;
|
||||
fail: (message: Error | string) => void;
|
||||
}
|
||||
}
|
@ -3,5 +3,4 @@ import {runTests} from '../lib/common/run-tests';
|
||||
|
||||
// Run
|
||||
const specFiles = [`${__dirname}/**/*.spec.js`];
|
||||
const helpers = [`${__dirname}/helpers.js`];
|
||||
runTests(specFiles, helpers);
|
||||
runTests(specFiles);
|
||||
|
@ -5,20 +5,21 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {SHORT_SHA_LEN} from '../../lib/common/constants';
|
||||
import {BuildCreator} from '../../lib/upload-server/build-creator';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/upload-server/build-events';
|
||||
import {UploadError} from '../../lib/upload-server/upload-error';
|
||||
import {expectToBeUploadError} from './helpers';
|
||||
import {Logger} from '../../lib/common/utils';
|
||||
import {BuildCreator} from '../../lib/preview-server/build-creator';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/preview-server/build-events';
|
||||
import {PreviewServerError} from '../../lib/preview-server/preview-error';
|
||||
import {expectToBePreviewServerError} from './helpers';
|
||||
|
||||
// Tests
|
||||
describe('BuildCreator', () => {
|
||||
const pr = '9';
|
||||
const pr = 9;
|
||||
const sha = '9'.repeat(40);
|
||||
const shortSha = sha.substr(0, SHORT_SHA_LEN);
|
||||
const archive = 'snapshot.tar.gz';
|
||||
const buildsDir = 'builds/dir';
|
||||
const hiddenPrDir = path.join(buildsDir, `hidden--${pr}`);
|
||||
const publicPrDir = path.join(buildsDir, pr);
|
||||
const publicPrDir = path.join(buildsDir, `${pr}`);
|
||||
const hiddenShaDir = path.join(hiddenPrDir, shortSha);
|
||||
const publicShaDir = path.join(publicPrDir, shortSha);
|
||||
let bc: BuildCreator;
|
||||
@ -134,8 +135,8 @@ describe('BuildCreator', () => {
|
||||
|
||||
|
||||
it('should abort and skip further operations if changing the PR\'s visibility fails', done => {
|
||||
const mockError = new UploadError(543, 'Test');
|
||||
bcUpdatePrVisibilitySpy.and.returnValue(Promise.reject(mockError));
|
||||
const mockError = new PreviewServerError(543, 'Test');
|
||||
bcUpdatePrVisibilitySpy.and.callFake(() => Promise.reject(mockError));
|
||||
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
expect(err).toBe(mockError);
|
||||
@ -154,7 +155,7 @@ describe('BuildCreator', () => {
|
||||
existsValues[shaDir] = true;
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
const publicOrNot = isPublic ? 'public' : 'non-public';
|
||||
expectToBeUploadError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`);
|
||||
expectToBePreviewServerError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`);
|
||||
expect(shellMkdirSpy).not.toHaveBeenCalled();
|
||||
expect(bcExtractArchiveSpy).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
@ -171,7 +172,7 @@ describe('BuildCreator', () => {
|
||||
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
const publicOrNot = isPublic ? 'public' : 'non-public';
|
||||
expectToBeUploadError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`);
|
||||
expectToBePreviewServerError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`);
|
||||
expect(shellMkdirSpy).not.toHaveBeenCalled();
|
||||
expect(bcExtractArchiveSpy).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
@ -222,20 +223,20 @@ describe('BuildCreator', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should reject with an UploadError', done => {
|
||||
it('should reject with an PreviewServerError', done => {
|
||||
// tslint:disable-next-line: no-string-throw
|
||||
shellMkdirSpy.and.callFake(() => { throw 'Test'; });
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
expectToBeUploadError(err, 500, `Error while uploading to directory: ${shaDir}\nTest`);
|
||||
expectToBePreviewServerError(err, 500, `Error while creating preview at: ${shaDir}\nTest`);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should pass UploadError instances unmodified', done => {
|
||||
shellMkdirSpy.and.callFake(() => { throw new UploadError(543, 'Test'); });
|
||||
it('should pass PreviewServerError instances unmodified', done => {
|
||||
shellMkdirSpy.and.callFake(() => { throw new PreviewServerError(543, 'Test'); });
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
expectToBeUploadError(err, 543, 'Test');
|
||||
expectToBePreviewServerError(err, 543, 'Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
@ -324,7 +325,7 @@ describe('BuildCreator', () => {
|
||||
const shas = ['foo', 'bar', 'baz'];
|
||||
let emitted = false;
|
||||
|
||||
bcListShasByDate.and.returnValue(Promise.resolve(shas));
|
||||
bcListShasByDate.and.callFake(() => Promise.resolve(shas));
|
||||
bcEmitSpy.and.callFake((type: string, evt: ChangedPrVisibilityEvent) => {
|
||||
expect(bcListShasByDate).toHaveBeenCalledWith(newPrDir);
|
||||
|
||||
@ -376,7 +377,8 @@ describe('BuildCreator', () => {
|
||||
it('should abort and skip further operations if both directories exist', done => {
|
||||
bcExistsSpy.and.returnValue(true);
|
||||
bc.updatePrVisibility(pr, makePublic).catch(err => {
|
||||
expectToBeUploadError(err, 409, `Request to move '${oldPrDir}' to existing directory '${newPrDir}'.`);
|
||||
expectToBePreviewServerError(err, 409,
|
||||
`Request to move '${oldPrDir}' to existing directory '${newPrDir}'.`);
|
||||
expect(shellMvSpy).not.toHaveBeenCalled();
|
||||
expect(bcListShasByDate).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
@ -407,20 +409,21 @@ describe('BuildCreator', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should reject with an UploadError', done => {
|
||||
it('should reject with an PreviewServerError', done => {
|
||||
// tslint:disable-next-line: no-string-throw
|
||||
shellMvSpy.and.callFake(() => { throw 'Test'; });
|
||||
bc.updatePrVisibility(pr, makePublic).catch(err => {
|
||||
expectToBeUploadError(err, 500, `Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\nTest`);
|
||||
expectToBePreviewServerError(err, 500,
|
||||
`Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\nTest`);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should pass UploadError instances unmodified', done => {
|
||||
shellMvSpy.and.callFake(() => { throw new UploadError(543, 'Test'); });
|
||||
it('should pass PreviewServerError instances unmodified', done => {
|
||||
shellMvSpy.and.callFake(() => { throw new PreviewServerError(543, 'Test'); });
|
||||
bc.updatePrVisibility(pr, makePublic).catch(err => {
|
||||
expectToBeUploadError(err, 543, 'Test');
|
||||
expectToBePreviewServerError(err, 543, 'Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
@ -451,7 +454,7 @@ describe('BuildCreator', () => {
|
||||
|
||||
it('should call \'fs.access()\' with the specified argument', () => {
|
||||
(bc as any).exists('foo');
|
||||
expect(fs.access).toHaveBeenCalledWith('foo', jasmine.any(Function));
|
||||
expect(fsAccessSpy).toHaveBeenCalledWith('foo', jasmine.any(Function));
|
||||
});
|
||||
|
||||
|
||||
@ -489,7 +492,7 @@ describe('BuildCreator', () => {
|
||||
beforeEach(() => {
|
||||
cpExecCbs = [];
|
||||
|
||||
consoleWarnSpy = spyOn(console, 'warn');
|
||||
consoleWarnSpy = spyOn(Logger.prototype, 'warn');
|
||||
shellChmodSpy = spyOn(shell, 'chmod');
|
||||
shellRmSpy = spyOn(shell, 'rm');
|
||||
cpExecSpy = spyOn(cp, 'exec').and.callFake((_: string, cb: (...args: any[]) => void) => cpExecCbs.push(cb));
|
||||
@ -527,7 +530,7 @@ describe('BuildCreator', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should delete the uploaded file on success', done => {
|
||||
it('should delete the build artifact file on success', done => {
|
||||
(bc as any).extractArchive('input/file', 'output/dir').
|
||||
then(() => expect(shellRmSpy).toHaveBeenCalledWith('-f', 'input/file')).
|
||||
then(done);
|
||||
@ -567,7 +570,7 @@ describe('BuildCreator', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should abort and reject if it fails to remove the uploaded file', done => {
|
||||
it('should abort and reject if it fails to remove the build artifact file', done => {
|
||||
(bc as any).extractArchive('foo', 'bar').catch((err: any) => {
|
||||
expect(shellChmodSpy).toHaveBeenCalled();
|
||||
expect(shellRmSpy).toHaveBeenCalled();
|
||||
@ -618,7 +621,7 @@ describe('BuildCreator', () => {
|
||||
|
||||
|
||||
it('should reject if listing files fails', done => {
|
||||
shellLsSpy.and.returnValue(Promise.reject('Test'));
|
||||
shellLsSpy.and.callFake(() => Promise.reject('Test'));
|
||||
(bc as any).listShasByDate('input/dir').catch((err: string) => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
@ -627,7 +630,7 @@ describe('BuildCreator', () => {
|
||||
|
||||
|
||||
it('should return the filenames', done => {
|
||||
shellLsSpy.and.returnValue(Promise.resolve([
|
||||
shellLsSpy.and.callFake(() => Promise.resolve([
|
||||
lsResult('foo', 100),
|
||||
lsResult('bar', 200),
|
||||
lsResult('baz', 300),
|
||||
@ -640,7 +643,7 @@ describe('BuildCreator', () => {
|
||||
|
||||
|
||||
it('should sort by date', done => {
|
||||
shellLsSpy.and.returnValue(Promise.resolve([
|
||||
shellLsSpy.and.callFake(() => Promise.resolve([
|
||||
lsResult('foo', 300),
|
||||
lsResult('bar', 100),
|
||||
lsResult('baz', 200),
|
||||
@ -660,7 +663,7 @@ describe('BuildCreator', () => {
|
||||
];
|
||||
mockArray.sort = jasmine.createSpy('sort');
|
||||
|
||||
shellLsSpy.and.returnValue(Promise.resolve(mockArray));
|
||||
shellLsSpy.and.callFake(() => Promise.resolve(mockArray));
|
||||
(bc as any).listShasByDate('input/dir').
|
||||
then((shas: string[]) => {
|
||||
expect(shas).toEqual(['bar', 'baz', 'foo']);
|
||||
@ -671,7 +674,7 @@ describe('BuildCreator', () => {
|
||||
|
||||
|
||||
it('should only include directories', done => {
|
||||
shellLsSpy.and.returnValue(Promise.resolve([
|
||||
shellLsSpy.and.callFake(() => Promise.resolve([
|
||||
lsResult('foo', 100),
|
||||
lsResult('bar', 200, false),
|
||||
lsResult('baz', 300),
|
@ -1,5 +1,5 @@
|
||||
// Imports
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/upload-server/build-events';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/preview-server/build-events';
|
||||
|
||||
// Tests
|
||||
describe('ChangedPrVisibilityEvent', () => {
|
@ -0,0 +1,193 @@
|
||||
import * as fs from 'fs';
|
||||
import * as nock from 'nock';
|
||||
import {resolve as resolvePath} from 'path';
|
||||
import {BuildInfo, CircleCiApi} from '../../lib/common/circle-ci-api';
|
||||
import {Logger} from '../../lib/common/utils';
|
||||
import {BuildRetriever} from '../../lib/preview-server/build-retriever';
|
||||
|
||||
describe('BuildRetriever', () => {
|
||||
const MAX_DOWNLOAD_SIZE = 10000;
|
||||
const DOWNLOAD_DIR = resolvePath('/DOWNLOAD/DIR');
|
||||
const BASE_URL = 'http://test.com';
|
||||
const ARTIFACT_PATH = '/some/path/build.zip';
|
||||
|
||||
let api: CircleCiApi;
|
||||
let BUILD_INFO: BuildInfo;
|
||||
let WRITEFILE_RESULT: any;
|
||||
let writeFileSpy: jasmine.Spy;
|
||||
let EXISTS_RESULT: boolean;
|
||||
let existsSpy: jasmine.Spy;
|
||||
let getBuildArtifactUrlSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
BUILD_INFO = {
|
||||
branch: 'pull/777',
|
||||
build_num: 12345,
|
||||
failed: false,
|
||||
has_artifacts: true,
|
||||
outcome: 'success',
|
||||
reponame: 'REPO',
|
||||
username: 'ORG',
|
||||
vcs_revision: 'COMMIT',
|
||||
};
|
||||
|
||||
api = new CircleCiApi('ORG', 'REPO', 'TOKEN');
|
||||
spyOn(api, 'getBuildInfo').and.callFake(() => Promise.resolve(BUILD_INFO));
|
||||
getBuildArtifactUrlSpy = spyOn(api, 'getBuildArtifactUrl')
|
||||
.and.callFake(() => Promise.resolve(BASE_URL + ARTIFACT_PATH));
|
||||
|
||||
WRITEFILE_RESULT = undefined;
|
||||
writeFileSpy = spyOn(fs, 'writeFile').and.callFake(
|
||||
(_path: string, _buffer: Buffer, callback: (err?: any) => {}) => callback(WRITEFILE_RESULT),
|
||||
);
|
||||
|
||||
EXISTS_RESULT = false;
|
||||
existsSpy = spyOn(fs, 'exists').and.callFake(
|
||||
(_path: string, callback: (exists: boolean) => {}) => callback(EXISTS_RESULT),
|
||||
);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should fail if the "downloadSizeLimit" is invalid', () => {
|
||||
expect(() => new BuildRetriever(api, NaN, DOWNLOAD_DIR))
|
||||
.toThrowError(`Invalid parameter "downloadSizeLimit" should be a number greater than 0.`);
|
||||
expect(() => new BuildRetriever(api, 0, DOWNLOAD_DIR))
|
||||
.toThrowError(`Invalid parameter "downloadSizeLimit" should be a number greater than 0.`);
|
||||
expect(() => new BuildRetriever(api, -1, DOWNLOAD_DIR))
|
||||
.toThrowError(`Invalid parameter "downloadSizeLimit" should be a number greater than 0.`);
|
||||
});
|
||||
it('should fail if the "downloadDir" is missing', () => {
|
||||
expect(() => new BuildRetriever(api, MAX_DOWNLOAD_SIZE, ''))
|
||||
.toThrowError(`Missing or empty required parameter 'downloadDir'!`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('getGithubInfo', () => {
|
||||
it('should request the info from CircleCI', async () => {
|
||||
const retriever = new BuildRetriever(api, MAX_DOWNLOAD_SIZE, DOWNLOAD_DIR);
|
||||
const info = await retriever.getGithubInfo(12345);
|
||||
expect(api.getBuildInfo).toHaveBeenCalledWith(12345);
|
||||
expect(info).toEqual({org: 'ORG', pr: 777, repo: 'REPO', sha: 'COMMIT', success: true});
|
||||
});
|
||||
|
||||
it('should error if it is not possible to extract the PR number from the branch', async () => {
|
||||
const retriever = new BuildRetriever(api, MAX_DOWNLOAD_SIZE, DOWNLOAD_DIR);
|
||||
try {
|
||||
BUILD_INFO.branch = 'master';
|
||||
await retriever.getGithubInfo(12345);
|
||||
throw new Error('Exception Expected');
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual('No PR found in branch field: master');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('downloadBuildArtifact', () => {
|
||||
const ARTIFACT_CONTENTS = 'ARTIFACT CONTENTS';
|
||||
let retriever: BuildRetriever;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(Logger.prototype, 'warn');
|
||||
retriever = new BuildRetriever(api, MAX_DOWNLOAD_SIZE, DOWNLOAD_DIR);
|
||||
});
|
||||
|
||||
it('should get the artifact URL from the CircleCI API', async () => {
|
||||
const artifactRequest = nock(BASE_URL).get(ARTIFACT_PATH).reply(200, ARTIFACT_CONTENTS);
|
||||
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
|
||||
expect(api.getBuildArtifactUrl).toHaveBeenCalledWith(12345, ARTIFACT_PATH);
|
||||
artifactRequest.done();
|
||||
});
|
||||
|
||||
it('should download the artifact from its URL', async () => {
|
||||
const artifactRequest = nock(BASE_URL).get(ARTIFACT_PATH).reply(200, ARTIFACT_CONTENTS);
|
||||
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
|
||||
// The following line proves that the artifact URL fetch occurred.
|
||||
artifactRequest.done();
|
||||
});
|
||||
|
||||
it('should fail if the artifact is too large', async () => {
|
||||
const artifactRequest = nock(BASE_URL).get(ARTIFACT_PATH).reply(200, ARTIFACT_CONTENTS);
|
||||
retriever = new BuildRetriever(api, 10, DOWNLOAD_DIR);
|
||||
try {
|
||||
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
|
||||
throw new Error('Exception Expected');
|
||||
} catch (error) {
|
||||
expect(error.status).toEqual(413);
|
||||
}
|
||||
artifactRequest.done();
|
||||
});
|
||||
|
||||
it('should not download the artifact if it already exists', async () => {
|
||||
const artifactRequestInterceptor = nock(BASE_URL).get(ARTIFACT_PATH);
|
||||
const artifactRequest = artifactRequestInterceptor.reply(200, ARTIFACT_CONTENTS);
|
||||
EXISTS_RESULT = true;
|
||||
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
|
||||
expect(existsSpy).toHaveBeenCalled();
|
||||
expect(getBuildArtifactUrlSpy).not.toHaveBeenCalled();
|
||||
expect(artifactRequest.isDone()).toEqual(false);
|
||||
nock.removeInterceptor(artifactRequestInterceptor);
|
||||
});
|
||||
|
||||
it('should write the artifact file to disk', async () => {
|
||||
const artifactRequest = nock(BASE_URL).get(ARTIFACT_PATH).reply(200, ARTIFACT_CONTENTS);
|
||||
const downloadPath = resolvePath(`${DOWNLOAD_DIR}/777-COMMIT-build.zip`);
|
||||
|
||||
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
|
||||
expect(writeFileSpy).toHaveBeenCalledWith(downloadPath, jasmine.any(Buffer), jasmine.any(Function));
|
||||
|
||||
const buffer: Buffer = writeFileSpy.calls.mostRecent().args[1];
|
||||
expect(buffer.toString()).toEqual(ARTIFACT_CONTENTS);
|
||||
|
||||
artifactRequest.done();
|
||||
});
|
||||
|
||||
it('should fail if the CircleCI API fails', async () => {
|
||||
try {
|
||||
getBuildArtifactUrlSpy.and.callFake(() => Promise.reject('getBuildArtifactUrl failed'));
|
||||
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
|
||||
throw new Error('Exception Expected');
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual('CircleCI artifact download failed (getBuildArtifactUrl failed)');
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail if the URL fetch errors', async () => {
|
||||
// create a new handler that errors
|
||||
const artifactRequest = nock(BASE_URL).get(ARTIFACT_PATH).replyWithError('Artifact Request Failed');
|
||||
try {
|
||||
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
|
||||
throw new Error('Exception Expected');
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual('CircleCI artifact download failed ' +
|
||||
'(request to http://test.com/some/path/build.zip failed, reason: Artifact Request Failed)');
|
||||
}
|
||||
artifactRequest.done();
|
||||
});
|
||||
|
||||
it('should fail if the URL fetch 404s', async () => {
|
||||
// create a new handler that errors
|
||||
const artifactRequest = nock(BASE_URL).get(ARTIFACT_PATH).reply(404, 'No such artifact');
|
||||
try {
|
||||
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
|
||||
throw new Error('Exception Expected');
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual('CircleCI artifact download failed (Error 404 - Not Found)');
|
||||
}
|
||||
artifactRequest.done();
|
||||
});
|
||||
|
||||
it('should fail if file write fails', async () => {
|
||||
const artifactRequest = nock(BASE_URL).get(ARTIFACT_PATH).reply(200, ARTIFACT_CONTENTS);
|
||||
try {
|
||||
WRITEFILE_RESULT = 'Test Error';
|
||||
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
|
||||
throw new Error('Exception Expected');
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual('CircleCI artifact download failed (Test Error)');
|
||||
}
|
||||
artifactRequest.done();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,180 @@
|
||||
// Imports
|
||||
import {GithubApi} from '../../lib/common/github-api';
|
||||
import {GithubPullRequests, PullRequest} from '../../lib/common/github-pull-requests';
|
||||
import {GithubTeams} from '../../lib/common/github-teams';
|
||||
import {BuildVerifier} from '../../lib/preview-server/build-verifier';
|
||||
|
||||
// Tests
|
||||
describe('BuildVerifier', () => {
|
||||
const defaultConfig = {
|
||||
allowedTeamSlugs: ['team1', 'team2'],
|
||||
githubOrg: 'organization',
|
||||
githubRepo: 'repo',
|
||||
githubToken: 'githubToken',
|
||||
secret: 'secret',
|
||||
trustedPrLabel: 'trusted: pr-label',
|
||||
};
|
||||
let prs: GithubPullRequests;
|
||||
let bv: BuildVerifier;
|
||||
|
||||
// Helpers
|
||||
const createBuildVerifier = (partialConfig: Partial<typeof defaultConfig> = {}) => {
|
||||
const cfg = {...defaultConfig, ...partialConfig} as typeof defaultConfig;
|
||||
const api = new GithubApi(cfg.githubToken);
|
||||
prs = new GithubPullRequests(api, cfg.githubOrg, cfg.githubRepo);
|
||||
const teams = new GithubTeams(api, cfg.githubOrg);
|
||||
return new BuildVerifier(prs, teams, cfg.allowedTeamSlugs, cfg.trustedPrLabel);
|
||||
};
|
||||
|
||||
beforeEach(() => bv = createBuildVerifier());
|
||||
|
||||
|
||||
describe('constructor()', () => {
|
||||
|
||||
['githubToken', 'githubRepo', 'githubOrg', 'allowedTeamSlugs', 'trustedPrLabel'].
|
||||
forEach(param => {
|
||||
it(`should throw if '${param}' is missing or empty`, () => {
|
||||
expect(() => createBuildVerifier({[param]: ''})).
|
||||
toThrowError(`Missing or empty required parameter '${param}'!`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'allowedTeamSlugs\' is an empty array', () => {
|
||||
expect(() => createBuildVerifier({allowedTeamSlugs: []})).
|
||||
toThrowError('Missing or empty required parameter \'allowedTeamSlugs\'!');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('getSignificantFilesChanged', () => {
|
||||
it('should return false if none of the fetched files match the given pattern', async () => {
|
||||
const fetchFilesSpy = spyOn(prs, 'fetchFiles');
|
||||
fetchFilesSpy.and.callFake(() => Promise.resolve([{filename: 'a/b/c'}, {filename: 'd/e/f'}]));
|
||||
expect(await bv.getSignificantFilesChanged(777, /^x/)).toEqual(false);
|
||||
expect(fetchFilesSpy).toHaveBeenCalledWith(777);
|
||||
|
||||
fetchFilesSpy.calls.reset();
|
||||
expect(await bv.getSignificantFilesChanged(777, /^a/)).toEqual(true);
|
||||
expect(fetchFilesSpy).toHaveBeenCalledWith(777);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('getPrIsTrusted()', () => {
|
||||
const pr = 9;
|
||||
let mockPrInfo: PullRequest;
|
||||
let prsFetchSpy: jasmine.Spy;
|
||||
let teamsIsMemberBySlugSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrInfo = {
|
||||
labels: [
|
||||
{name: 'foo'},
|
||||
{name: 'bar'},
|
||||
],
|
||||
number: 9,
|
||||
user: {login: 'username'},
|
||||
};
|
||||
|
||||
prsFetchSpy = spyOn(GithubPullRequests.prototype, 'fetch').
|
||||
and.callFake(() => Promise.resolve(mockPrInfo));
|
||||
|
||||
teamsIsMemberBySlugSpy = spyOn(GithubTeams.prototype, 'isMemberBySlug').
|
||||
and.callFake(() => Promise.resolve(true));
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', done => {
|
||||
const promise = bv.getPrIsTrusted(pr);
|
||||
promise.then(done); // Do not complete the test (and release the spies) synchronously
|
||||
// to avoid running the actual `GithubTeams#isMemberBySlug()`.
|
||||
|
||||
expect(promise).toEqual(jasmine.any(Promise));
|
||||
});
|
||||
|
||||
|
||||
it('should fetch the corresponding PR', done => {
|
||||
bv.getPrIsTrusted(pr).then(() => {
|
||||
expect(prsFetchSpy).toHaveBeenCalledWith(pr);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if fetching the PR errors', done => {
|
||||
prsFetchSpy.and.callFake(() => Promise.reject('Test'));
|
||||
bv.getPrIsTrusted(pr).catch(err => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('when the PR has the "trusted PR" label', () => {
|
||||
|
||||
beforeEach(() => mockPrInfo.labels.push({name: 'trusted: pr-label'}));
|
||||
|
||||
|
||||
it('should resolve to true', done => {
|
||||
bv.getPrIsTrusted(pr).then(isTrusted => {
|
||||
expect(isTrusted).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should not try to verify the author\'s membership status', done => {
|
||||
bv.getPrIsTrusted(pr).then(() => {
|
||||
expect(teamsIsMemberBySlugSpy).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('when the PR does not have the "trusted PR" label', () => {
|
||||
|
||||
it('should verify the PR author\'s membership in the specified teams', done => {
|
||||
bv.getPrIsTrusted(pr).then(() => {
|
||||
expect(teamsIsMemberBySlugSpy).toHaveBeenCalledWith('username', ['team1', 'team2']);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if verifying membership errors', done => {
|
||||
teamsIsMemberBySlugSpy.and.callFake(() => Promise.reject('Test'));
|
||||
bv.getPrIsTrusted(pr).catch(err => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should resolve to true if the PR\'s author is a member', done => {
|
||||
teamsIsMemberBySlugSpy.and.callFake(() => Promise.resolve(true));
|
||||
|
||||
bv.getPrIsTrusted(pr).then(isTrusted => {
|
||||
expect(isTrusted).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should resolve to false if the PR\'s author is not a member', done => {
|
||||
teamsIsMemberBySlugSpy.and.callFake(() => Promise.resolve(false));
|
||||
|
||||
bv.getPrIsTrusted(pr).then(isTrusted => {
|
||||
expect(isTrusted).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
@ -0,0 +1,11 @@
|
||||
import {PreviewServerError} from '../../lib/preview-server/preview-error';
|
||||
|
||||
export const expectToBePreviewServerError = (actual: PreviewServerError, status?: number, message?: string) => {
|
||||
expect(actual).toEqual(jasmine.any(PreviewServerError));
|
||||
if (status != null) {
|
||||
expect(actual.status).toBe(status);
|
||||
}
|
||||
if (message != null) {
|
||||
expect(actual.message).toBe(message);
|
||||
}
|
||||
};
|
@ -0,0 +1,39 @@
|
||||
// Imports
|
||||
import {PreviewServerError} from '../../lib/preview-server/preview-error';
|
||||
|
||||
// Tests
|
||||
describe('PreviewServerError', () => {
|
||||
let err: PreviewServerError;
|
||||
|
||||
beforeEach(() => err = new PreviewServerError(999, 'message'));
|
||||
|
||||
|
||||
it('should extend Error', () => {
|
||||
expect(err).toEqual(jasmine.any(PreviewServerError));
|
||||
expect(err).toEqual(jasmine.any(Error));
|
||||
|
||||
expect(Object.getPrototypeOf(err)).toBe(PreviewServerError.prototype);
|
||||
});
|
||||
|
||||
|
||||
it('should have a \'status\' property', () => {
|
||||
expect(err.status).toBe(999);
|
||||
});
|
||||
|
||||
|
||||
it('should have a \'message\' property', () => {
|
||||
expect(err.message).toBe('message');
|
||||
});
|
||||
|
||||
|
||||
it('should have a 500 \'status\' by default', () => {
|
||||
expect(new PreviewServerError().status).toBe(500);
|
||||
});
|
||||
|
||||
|
||||
it('should have an empty \'message\' by default', () => {
|
||||
expect(new PreviewServerError().message).toBe('');
|
||||
expect(new PreviewServerError(999).message).toBe('');
|
||||
});
|
||||
|
||||
});
|
@ -0,0 +1,692 @@
|
||||
// Imports
|
||||
import * as express from 'express';
|
||||
import * as http from 'http';
|
||||
import * as supertest from 'supertest';
|
||||
import {CircleCiApi} from '../../lib/common/circle-ci-api';
|
||||
import {GithubApi} from '../../lib/common/github-api';
|
||||
import {GithubPullRequests} from '../../lib/common/github-pull-requests';
|
||||
import {GithubTeams} from '../../lib/common/github-teams';
|
||||
import {Logger} from '../../lib/common/utils';
|
||||
import {BuildCreator} from '../../lib/preview-server/build-creator';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/preview-server/build-events';
|
||||
import {BuildRetriever, GithubInfo} from '../../lib/preview-server/build-retriever';
|
||||
import {BuildVerifier} from '../../lib/preview-server/build-verifier';
|
||||
import {PreviewServerConfig, PreviewServerFactory} from '../../lib/preview-server/preview-server-factory';
|
||||
|
||||
interface CircleCiWebHookPayload {
|
||||
payload: {
|
||||
build_num: number;
|
||||
build_parameters: {
|
||||
CIRCLE_JOB: string;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Tests
|
||||
describe('PreviewServerFactory', () => {
|
||||
const defaultConfig: PreviewServerConfig = {
|
||||
buildArtifactPath: 'artifact/path.zip',
|
||||
buildsDir: 'builds/dir',
|
||||
circleCiToken: 'CIRCLE_CI_TOKEN',
|
||||
domainName: 'domain.name',
|
||||
downloadSizeLimit: 999,
|
||||
downloadsDir: '/tmp/aio-create-builds',
|
||||
githubOrg: 'organisation',
|
||||
githubRepo: 'repo',
|
||||
githubTeamSlugs: ['team1', 'team2'],
|
||||
githubToken: '12345',
|
||||
significantFilesPattern: '^(?:aio|packages)\\/(?!.*[._]spec\\.[jt]s$)',
|
||||
trustedPrLabel: 'trusted: pr-label',
|
||||
};
|
||||
let loggerErrorSpy: jasmine.Spy;
|
||||
let loggerInfoSpy: jasmine.Spy;
|
||||
let loggerLogSpy: jasmine.Spy;
|
||||
|
||||
// Helpers
|
||||
const createPreviewServer = (partialConfig: Partial<PreviewServerConfig> = {}) =>
|
||||
PreviewServerFactory.create({...defaultConfig, ...partialConfig});
|
||||
|
||||
beforeEach(() => {
|
||||
loggerErrorSpy = spyOn(Logger.prototype, 'error');
|
||||
loggerInfoSpy = spyOn(Logger.prototype, 'info');
|
||||
loggerLogSpy = spyOn(Logger.prototype, 'log');
|
||||
});
|
||||
|
||||
describe('create()', () => {
|
||||
let usfCreateMiddlewareSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
usfCreateMiddlewareSpy = spyOn(PreviewServerFactory, 'createMiddleware').and.callThrough();
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'buildsDir\' is missing or empty', () => {
|
||||
expect(() => createPreviewServer({buildsDir: ''})).
|
||||
toThrowError('Missing or empty required parameter \'buildsDir\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'domainName\' is missing or empty', () => {
|
||||
expect(() => createPreviewServer({domainName: ''})).
|
||||
toThrowError('Missing or empty required parameter \'domainName\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubToken\' is missing or empty', () => {
|
||||
expect(() => createPreviewServer({githubToken: ''})).
|
||||
toThrowError('Missing or empty required parameter \'githubToken\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubOrg\' is missing or empty', () => {
|
||||
expect(() => createPreviewServer({githubOrg: ''})).
|
||||
toThrowError('Missing or empty required parameter \'githubOrg\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubTeamSlugs\' is missing or empty', () => {
|
||||
expect(() => createPreviewServer({githubTeamSlugs: []})).
|
||||
toThrowError('Missing or empty required parameter \'allowedTeamSlugs\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubRepo\' is missing or empty', () => {
|
||||
expect(() => createPreviewServer({githubRepo: ''})).
|
||||
toThrowError('Missing or empty required parameter \'githubRepo\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'trustedPrLabel\' is missing or empty', () => {
|
||||
expect(() => createPreviewServer({trustedPrLabel: ''})).
|
||||
toThrowError('Missing or empty required parameter \'trustedPrLabel\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should return an http.Server', () => {
|
||||
const httpCreateServerSpy = spyOn(http, 'createServer').and.callThrough();
|
||||
const server = createPreviewServer();
|
||||
|
||||
expect(server).toBe(httpCreateServerSpy.calls.mostRecent().returnValue);
|
||||
});
|
||||
|
||||
|
||||
it('should create and use an appropriate BuildCreator', () => {
|
||||
const usfCreateBuildCreatorSpy = spyOn(PreviewServerFactory, 'createBuildCreator').and.callThrough();
|
||||
|
||||
createPreviewServer();
|
||||
const buildRetriever = jasmine.any(BuildRetriever);
|
||||
const buildVerifier = jasmine.any(BuildVerifier);
|
||||
const prs = jasmine.any(GithubPullRequests);
|
||||
const buildCreator: BuildCreator = usfCreateBuildCreatorSpy.calls.mostRecent().returnValue;
|
||||
|
||||
expect(usfCreateMiddlewareSpy).toHaveBeenCalledWith(buildRetriever, buildVerifier, buildCreator, defaultConfig);
|
||||
expect(usfCreateBuildCreatorSpy).toHaveBeenCalledWith(prs, 'builds/dir', 'domain.name');
|
||||
});
|
||||
|
||||
|
||||
it('should create and use an appropriate middleware', () => {
|
||||
const httpCreateServerSpy = spyOn(http, 'createServer').and.callThrough();
|
||||
|
||||
createPreviewServer();
|
||||
|
||||
const buildRetriever = jasmine.any(BuildRetriever);
|
||||
const buildVerifier = jasmine.any(BuildVerifier);
|
||||
const buildCreator = jasmine.any(BuildCreator);
|
||||
expect(usfCreateMiddlewareSpy).toHaveBeenCalledWith(buildRetriever, buildVerifier, buildCreator, defaultConfig);
|
||||
|
||||
const middleware: express.Express = usfCreateMiddlewareSpy.calls.mostRecent().returnValue;
|
||||
expect(httpCreateServerSpy).toHaveBeenCalledWith(middleware);
|
||||
});
|
||||
|
||||
|
||||
it('should log the server address info on \'listening\'', () => {
|
||||
const server = createPreviewServer();
|
||||
server.address = () => ({address: 'foo', family: '', port: 1337});
|
||||
|
||||
expect(loggerInfoSpy).not.toHaveBeenCalled();
|
||||
|
||||
server.emit('listening');
|
||||
expect(loggerInfoSpy).toHaveBeenCalledWith('Up and running (and listening on foo:1337)...');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// Protected methods
|
||||
|
||||
describe('createBuildCreator()', () => {
|
||||
let buildCreator: BuildCreator;
|
||||
|
||||
beforeEach(() => {
|
||||
const api = new GithubApi(defaultConfig.githubToken);
|
||||
const prs = new GithubPullRequests(api, defaultConfig.githubOrg, defaultConfig.githubRepo);
|
||||
buildCreator = PreviewServerFactory.createBuildCreator(prs, defaultConfig.buildsDir, defaultConfig.domainName);
|
||||
});
|
||||
|
||||
it('should pass the \'buildsDir\' to the BuildCreator', () => {
|
||||
expect((buildCreator as any).buildsDir).toBe('builds/dir');
|
||||
});
|
||||
|
||||
|
||||
describe('on \'build.created\'', () => {
|
||||
let prsAddCommentSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => prsAddCommentSpy = spyOn(GithubPullRequests.prototype, 'addComment'));
|
||||
|
||||
|
||||
it('should post a comment on GitHub for public previews', () => {
|
||||
const commentBody = 'You can preview 1234567890 at https://pr42-1234567890.domain.name/.';
|
||||
|
||||
buildCreator.emit(CreatedBuildEvent.type, {pr: 42, sha: '1234567890', isPublic: true});
|
||||
expect(prsAddCommentSpy).toHaveBeenCalledWith(42, commentBody);
|
||||
});
|
||||
|
||||
|
||||
it('should not post a comment on GitHub for non-public previews', () => {
|
||||
buildCreator.emit(CreatedBuildEvent.type, {pr: 42, sha: '1234567890', isPublic: false});
|
||||
expect(prsAddCommentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('on \'pr.changedVisibility\'', () => {
|
||||
let prsAddCommentSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => prsAddCommentSpy = spyOn(GithubPullRequests.prototype, 'addComment'));
|
||||
|
||||
|
||||
it('should post a comment on GitHub (for all SHAs) for PRs made public', () => {
|
||||
const commentBody = 'You can preview 12345 at https://pr42-12345.domain.name/.\n' +
|
||||
'You can preview 67890 at https://pr42-67890.domain.name/.';
|
||||
|
||||
buildCreator.emit(ChangedPrVisibilityEvent.type, {pr: 42, shas: ['12345', '67890'], isPublic: true});
|
||||
expect(prsAddCommentSpy).toHaveBeenCalledWith(42, commentBody);
|
||||
});
|
||||
|
||||
|
||||
it('should not post a comment on GitHub if no SHAs were affected', () => {
|
||||
buildCreator.emit(ChangedPrVisibilityEvent.type, {pr: 42, shas: [], isPublic: true});
|
||||
expect(prsAddCommentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should not post a comment on GitHub for PRs made non-public', () => {
|
||||
buildCreator.emit(ChangedPrVisibilityEvent.type, {pr: 42, shas: ['12345', '67890'], isPublic: false});
|
||||
expect(prsAddCommentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
it('should pass the correct parameters to GithubPullRequests', () => {
|
||||
const prsAddCommentSpy = spyOn(GithubPullRequests.prototype, 'addComment');
|
||||
|
||||
buildCreator.emit(CreatedBuildEvent.type, {pr: 42, sha: '1234567890', isPublic: true});
|
||||
buildCreator.emit(ChangedPrVisibilityEvent.type, {pr: 42, shas: ['12345', '67890'], isPublic: true});
|
||||
|
||||
const allCalls = prsAddCommentSpy.calls.all();
|
||||
const prs: GithubPullRequests = allCalls[0].object;
|
||||
|
||||
expect(prsAddCommentSpy).toHaveBeenCalledTimes(2);
|
||||
expect(prs).toBe(allCalls[1].object);
|
||||
expect(prs).toEqual(jasmine.any(GithubPullRequests));
|
||||
expect(prs.repoSlug).toBe('organisation/repo');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('createMiddleware()', () => {
|
||||
let buildRetriever: BuildRetriever;
|
||||
let buildVerifier: BuildVerifier;
|
||||
let buildCreator: BuildCreator;
|
||||
let agent: supertest.SuperTest<supertest.Test>;
|
||||
|
||||
beforeEach(() => {
|
||||
const circleCiApi = new CircleCiApi(defaultConfig.githubOrg, defaultConfig.githubRepo,
|
||||
defaultConfig.circleCiToken);
|
||||
const githubApi = new GithubApi(defaultConfig.githubToken);
|
||||
const prs = new GithubPullRequests(githubApi, defaultConfig.githubOrg, defaultConfig.githubRepo);
|
||||
const teams = new GithubTeams(githubApi, defaultConfig.githubOrg);
|
||||
|
||||
buildRetriever = new BuildRetriever(circleCiApi, defaultConfig.downloadSizeLimit, defaultConfig.downloadsDir);
|
||||
buildVerifier = new BuildVerifier(prs, teams, defaultConfig.githubTeamSlugs, defaultConfig.trustedPrLabel);
|
||||
buildCreator = new BuildCreator(defaultConfig.buildsDir);
|
||||
|
||||
const middleware = PreviewServerFactory.createMiddleware(buildRetriever, buildVerifier, buildCreator,
|
||||
defaultConfig);
|
||||
agent = supertest.agent(middleware);
|
||||
});
|
||||
|
||||
|
||||
describe('GET /health-check', () => {
|
||||
|
||||
it('should respond with 200', async () => {
|
||||
await Promise.all([
|
||||
agent.get('/health-check').expect(200),
|
||||
agent.get('/health-check/').expect(200),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for non-GET requests', async () => {
|
||||
await Promise.all([
|
||||
agent.put('/health-check').expect(404),
|
||||
agent.post('/health-check').expect(404),
|
||||
agent.patch('/health-check').expect(404),
|
||||
agent.delete('/health-check').expect(404),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 if the path does not match exactly', async () => {
|
||||
await Promise.all([
|
||||
agent.get('/health-check/foo').expect(404),
|
||||
agent.get('/health-check-foo').expect(404),
|
||||
agent.get('/health-checknfoo').expect(404),
|
||||
agent.get('/foo/health-check').expect(404),
|
||||
agent.get('/foo-health-check').expect(404),
|
||||
agent.get('/foonhealth-check').expect(404),
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('GET /can-have-public-preview/<pr>', () => {
|
||||
const baseUrl = '/can-have-public-preview';
|
||||
const pr = 777;
|
||||
const url = `${baseUrl}/${pr}`;
|
||||
let bvGetPrIsTrustedSpy: jasmine.Spy;
|
||||
let bvGetSignificantFilesChangedSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
bvGetPrIsTrustedSpy = spyOn(buildVerifier, 'getPrIsTrusted').and.returnValue(Promise.resolve(true));
|
||||
bvGetSignificantFilesChangedSpy = spyOn(buildVerifier, 'getSignificantFilesChanged').
|
||||
and.returnValue(Promise.resolve(true));
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for non-GET requests', async () => {
|
||||
await Promise.all([
|
||||
agent.put(url).expect(404),
|
||||
agent.post(url).expect(404),
|
||||
agent.patch(url).expect(404),
|
||||
agent.delete(url).expect(404),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 if the path does not match exactly', async () => {
|
||||
await Promise.all([
|
||||
agent.get('/can-have-public-preview/42/foo').expect(404),
|
||||
agent.get('/can-have-public-preview-foo/42').expect(404),
|
||||
agent.get('/can-have-public-previewnfoo/42').expect(404),
|
||||
agent.get('/foo/can-have-public-preview/42').expect(404),
|
||||
agent.get('/foo-can-have-public-preview/42').expect(404),
|
||||
agent.get('/fooncan-have-public-preview/42').expect(404),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond appropriately if the PR did not touch any significant files', async () => {
|
||||
bvGetSignificantFilesChangedSpy.and.returnValue(Promise.resolve(false));
|
||||
|
||||
const expectedResponse = {canHavePublicPreview: false, reason: 'No significant files touched.'};
|
||||
const expectedLog = `PR:${pr} - Cannot have a public preview, because it did not touch any significant files.`;
|
||||
|
||||
await agent.get(url).expect(200, expectedResponse);
|
||||
|
||||
expect(bvGetSignificantFilesChangedSpy).toHaveBeenCalledWith(pr, jasmine.any(RegExp));
|
||||
expect(bvGetPrIsTrustedSpy).not.toHaveBeenCalled();
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith(expectedLog);
|
||||
});
|
||||
|
||||
|
||||
it('should respond appropriately if the PR is not automatically verifiable as "trusted"', async () => {
|
||||
bvGetPrIsTrustedSpy.and.returnValue(Promise.resolve(false));
|
||||
|
||||
const expectedResponse = {canHavePublicPreview: false, reason: 'Not automatically verifiable as "trusted".'};
|
||||
const expectedLog =
|
||||
`PR:${pr} - Cannot have a public preview, because not automatically verifiable as "trusted".`;
|
||||
|
||||
await agent.get(url).expect(200, expectedResponse);
|
||||
|
||||
expect(bvGetSignificantFilesChangedSpy).toHaveBeenCalledWith(pr, jasmine.any(RegExp));
|
||||
expect(bvGetPrIsTrustedSpy).toHaveBeenCalledWith(pr);
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith(expectedLog);
|
||||
});
|
||||
|
||||
|
||||
it('should respond appropriately if the PR can have a preview', async () => {
|
||||
const expectedResponse = {canHavePublicPreview: true, reason: null};
|
||||
const expectedLog = `PR:${pr} - Can have a public preview.`;
|
||||
|
||||
await agent.get(url).expect(200, expectedResponse);
|
||||
|
||||
expect(bvGetSignificantFilesChangedSpy).toHaveBeenCalledWith(pr, jasmine.any(RegExp));
|
||||
expect(bvGetPrIsTrustedSpy).toHaveBeenCalledWith(pr);
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith(expectedLog);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with error if `getSignificantFilesChanged()` fails', async () => {
|
||||
bvGetSignificantFilesChangedSpy.and.callFake(() => Promise.reject('getSignificantFilesChanged error'));
|
||||
|
||||
await agent.get(url).expect(500, 'getSignificantFilesChanged error');
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith('Previewability check error', 'getSignificantFilesChanged error');
|
||||
});
|
||||
|
||||
|
||||
it('should respond with error if `getPrIsTrusted()` fails', async () => {
|
||||
const error = new Error('getPrIsTrusted error');
|
||||
bvGetPrIsTrustedSpy.and.callFake(() => { throw error; });
|
||||
|
||||
await agent.get(url).expect(500, 'getPrIsTrusted error');
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith('Previewability check error', error);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('POST /circle-build', () => {
|
||||
let getGithubInfoSpy: jasmine.Spy;
|
||||
let getSignificantFilesChangedSpy: jasmine.Spy;
|
||||
let downloadBuildArtifactSpy: jasmine.Spy;
|
||||
let getPrIsTrustedSpy: jasmine.Spy;
|
||||
let createBuildSpy: jasmine.Spy;
|
||||
let IS_PUBLIC: boolean;
|
||||
let BUILD_INFO: GithubInfo;
|
||||
let AFFECTS_SIGNIFICANT_FILES: boolean;
|
||||
let BASIC_PAYLOAD: CircleCiWebHookPayload;
|
||||
const URL = '/circle-build';
|
||||
const BUILD_NUM = 12345;
|
||||
const PR = 777;
|
||||
const SHA = 'COMMIT';
|
||||
const DOWNLOADED_ARTIFACT_PATH = 'downloads/777-COMMIT-build.zip';
|
||||
|
||||
beforeEach(() => {
|
||||
IS_PUBLIC = true;
|
||||
BUILD_INFO = {
|
||||
org: defaultConfig.githubOrg,
|
||||
pr: PR,
|
||||
repo: defaultConfig.githubRepo,
|
||||
sha: SHA,
|
||||
success: true,
|
||||
};
|
||||
BASIC_PAYLOAD = { payload: { build_num: BUILD_NUM, build_parameters: { CIRCLE_JOB: 'aio_preview' } } };
|
||||
AFFECTS_SIGNIFICANT_FILES = true;
|
||||
getGithubInfoSpy = spyOn(buildRetriever, 'getGithubInfo')
|
||||
.and.callFake(() => Promise.resolve(BUILD_INFO));
|
||||
getSignificantFilesChangedSpy = spyOn(buildVerifier, 'getSignificantFilesChanged')
|
||||
.and.callFake(() => Promise.resolve(AFFECTS_SIGNIFICANT_FILES));
|
||||
downloadBuildArtifactSpy = spyOn(buildRetriever, 'downloadBuildArtifact')
|
||||
.and.callFake(() => Promise.resolve(DOWNLOADED_ARTIFACT_PATH));
|
||||
getPrIsTrustedSpy = spyOn(buildVerifier, 'getPrIsTrusted')
|
||||
.and.callFake(() => Promise.resolve(IS_PUBLIC));
|
||||
createBuildSpy = spyOn(buildCreator, 'create');
|
||||
});
|
||||
|
||||
it('should respond with 400 if the request body is not in the correct format', async () => {
|
||||
await Promise.all([
|
||||
agent.post(URL).expect(400),
|
||||
agent.post(URL).send().expect(400),
|
||||
agent.post(URL).send({}).expect(400),
|
||||
agent.post(URL).send({ payload: {} }).expect(400),
|
||||
agent.post(URL).send({ payload: { build_num: -1 } }).expect(400),
|
||||
agent.post(URL).send({ payload: { build_num: 4000 } }).expect(400),
|
||||
agent.post(URL).send({ payload: { build_num: 4000, build_parameters: { } } }).expect(400),
|
||||
agent.post(URL).send({ payload: { build_num: 4000, build_parameters: { CIRCLE_JOB: '' } } }).expect(400),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a preview if everything is good and the build succeeded', async () => {
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(201);
|
||||
expect(getGithubInfoSpy).toHaveBeenCalledWith(BUILD_NUM);
|
||||
expect(getSignificantFilesChangedSpy).toHaveBeenCalledWith(PR, jasmine.any(RegExp));
|
||||
expect(downloadBuildArtifactSpy).toHaveBeenCalledWith(BUILD_NUM, PR, SHA, defaultConfig.buildArtifactPath);
|
||||
expect(getPrIsTrustedSpy).toHaveBeenCalledWith(PR);
|
||||
expect(createBuildSpy).toHaveBeenCalledWith(PR, SHA, DOWNLOADED_ARTIFACT_PATH, IS_PUBLIC);
|
||||
});
|
||||
|
||||
it('should respond with 204 if the reported build is not the "AIO preview" job', async () => {
|
||||
BASIC_PAYLOAD.payload.build_parameters.CIRCLE_JOB = 'lint';
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(204);
|
||||
expect(getGithubInfoSpy).not.toHaveBeenCalled();
|
||||
expect(getSignificantFilesChangedSpy).not.toHaveBeenCalled();
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith(
|
||||
'Build:12345, Job:lint -', 'Skipping preview processing because this is not the "aio_preview" job.');
|
||||
expect(downloadBuildArtifactSpy).not.toHaveBeenCalled();
|
||||
expect(getPrIsTrustedSpy).not.toHaveBeenCalled();
|
||||
expect(createBuildSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respond with 204 if the build did not affect any significant files', async () => {
|
||||
AFFECTS_SIGNIFICANT_FILES = false;
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(204);
|
||||
expect(getGithubInfoSpy).toHaveBeenCalledWith(BUILD_NUM);
|
||||
expect(getSignificantFilesChangedSpy).toHaveBeenCalledWith(PR, jasmine.any(RegExp));
|
||||
expect(loggerLogSpy).toHaveBeenCalledWith(
|
||||
'PR:777, Build:12345 - Skipping preview processing because this PR did not touch any significant files.');
|
||||
expect(downloadBuildArtifactSpy).not.toHaveBeenCalled();
|
||||
expect(getPrIsTrustedSpy).not.toHaveBeenCalled();
|
||||
expect(createBuildSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respond with 201 if the build is trusted', async () => {
|
||||
IS_PUBLIC = true;
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(201);
|
||||
});
|
||||
|
||||
it('should respond with 202 if the build is not trusted', async () => {
|
||||
IS_PUBLIC = false;
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(202);
|
||||
});
|
||||
|
||||
it('should not create a preview if the build was not successful', async () => {
|
||||
BUILD_INFO.success = false;
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(204);
|
||||
expect(getGithubInfoSpy).toHaveBeenCalledWith(BUILD_NUM);
|
||||
expect(downloadBuildArtifactSpy).not.toHaveBeenCalled();
|
||||
expect(getPrIsTrustedSpy).not.toHaveBeenCalled();
|
||||
expect(createBuildSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if the CircleCI request fails', async () => {
|
||||
// Note it is important to put the `reject` into `and.callFake`;
|
||||
// If you just `and.returnValue` the rejected promise
|
||||
// then you get an "unhandled rejection" message in the console.
|
||||
getGithubInfoSpy.and.callFake(() => Promise.reject('Test Error'));
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(500, 'Test Error');
|
||||
expect(getGithubInfoSpy).toHaveBeenCalledWith(BUILD_NUM);
|
||||
expect(downloadBuildArtifactSpy).not.toHaveBeenCalled();
|
||||
expect(getPrIsTrustedSpy).not.toHaveBeenCalled();
|
||||
expect(createBuildSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if the Github organisation of the build does not match the configured organisation', async () => {
|
||||
BUILD_INFO.org = 'bad';
|
||||
await agent.post(URL).send(BASIC_PAYLOAD)
|
||||
.expect(500, `Invalid webhook: expected "githubOrg" property to equal "organisation" but got "bad".`);
|
||||
});
|
||||
|
||||
it('should fail if the Github repo of the build does not match the configured repo', async () => {
|
||||
BUILD_INFO.repo = 'bad';
|
||||
await agent.post(URL).send(BASIC_PAYLOAD)
|
||||
.expect(500, `Invalid webhook: expected "githubRepo" property to equal "repo" but got "bad".`);
|
||||
});
|
||||
|
||||
it('should fail if the artifact fetch request fails', async () => {
|
||||
downloadBuildArtifactSpy.and.callFake(() => Promise.reject('Test Error'));
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(500, 'Test Error');
|
||||
expect(getGithubInfoSpy).toHaveBeenCalledWith(BUILD_NUM);
|
||||
expect(downloadBuildArtifactSpy).toHaveBeenCalled();
|
||||
expect(getPrIsTrustedSpy).not.toHaveBeenCalled();
|
||||
expect(createBuildSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if verifying the PR fails', async () => {
|
||||
getPrIsTrustedSpy.and.callFake(() => Promise.reject('Test Error'));
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(500, 'Test Error');
|
||||
expect(getGithubInfoSpy).toHaveBeenCalledWith(BUILD_NUM);
|
||||
expect(downloadBuildArtifactSpy).toHaveBeenCalled();
|
||||
expect(getPrIsTrustedSpy).toHaveBeenCalled();
|
||||
expect(createBuildSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if creating the preview build fails', async () => {
|
||||
createBuildSpy.and.callFake(() => Promise.reject('Test Error'));
|
||||
await agent.post(URL).send(BASIC_PAYLOAD).expect(500, 'Test Error');
|
||||
expect(getGithubInfoSpy).toHaveBeenCalledWith(BUILD_NUM);
|
||||
expect(downloadBuildArtifactSpy).toHaveBeenCalled();
|
||||
expect(getPrIsTrustedSpy).toHaveBeenCalled();
|
||||
expect(createBuildSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('POST /pr-updated', () => {
|
||||
const pr = '9';
|
||||
const url = '/pr-updated';
|
||||
let bvGetPrIsTrustedSpy: jasmine.Spy;
|
||||
let bcUpdatePrVisibilitySpy: jasmine.Spy;
|
||||
|
||||
// Helpers
|
||||
const createRequest = (num: number, action?: string) =>
|
||||
agent.post(url).send({number: num, action});
|
||||
|
||||
beforeEach(() => {
|
||||
bvGetPrIsTrustedSpy = spyOn(buildVerifier, 'getPrIsTrusted');
|
||||
bcUpdatePrVisibilitySpy = spyOn(buildCreator, 'updatePrVisibility');
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for non-POST requests', async () => {
|
||||
await Promise.all([
|
||||
agent.get(url).expect(404),
|
||||
agent.put(url).expect(404),
|
||||
agent.patch(url).expect(404),
|
||||
agent.delete(url).expect(404),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 400 for requests without a payload', async () => {
|
||||
const responseBody = `Missing or empty 'number' field in request: POST ${url} {}`;
|
||||
|
||||
const request1 = agent.post(url);
|
||||
const request2 = agent.post(url).send();
|
||||
|
||||
await Promise.all([
|
||||
request1.expect(400, responseBody),
|
||||
request2.expect(400, responseBody),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 400 for requests without a \'number\' field', async () => {
|
||||
const responseBodyPrefix = `Missing or empty 'number' field in request: POST ${url}`;
|
||||
|
||||
const request1 = agent.post(url).send({});
|
||||
const request2 = agent.post(url).send({number: null});
|
||||
|
||||
await Promise.all([
|
||||
request1.expect(400, `${responseBodyPrefix} {}`),
|
||||
request2.expect(400, `${responseBodyPrefix} {"number":null}`),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('should call \'BuildVerifier#gtPrIsTrusted()\' with the correct arguments', async () => {
|
||||
await createRequest(+pr);
|
||||
expect(bvGetPrIsTrustedSpy).toHaveBeenCalledWith(9);
|
||||
});
|
||||
|
||||
|
||||
it('should propagate errors from BuildVerifier', async () => {
|
||||
bvGetPrIsTrustedSpy.and.callFake(() => Promise.reject('Test'));
|
||||
|
||||
await createRequest(+pr).expect(500, 'Test');
|
||||
|
||||
expect(bvGetPrIsTrustedSpy).toHaveBeenCalledWith(9);
|
||||
expect(bcUpdatePrVisibilitySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should call \'BuildCreator#updatePrVisibility()\' with the correct arguments', async () => {
|
||||
bvGetPrIsTrustedSpy.and.callFake((pr2: number) => Promise.resolve(pr2 === 42));
|
||||
|
||||
await createRequest(24);
|
||||
expect(bcUpdatePrVisibilitySpy).toHaveBeenCalledWith(24, false);
|
||||
|
||||
await createRequest(42);
|
||||
expect(bcUpdatePrVisibilitySpy).toHaveBeenCalledWith(42, true);
|
||||
});
|
||||
|
||||
|
||||
it('should propagate errors from BuildCreator', async () => {
|
||||
bcUpdatePrVisibilitySpy.and.callFake(() => Promise.reject('Test'));
|
||||
await createRequest(+pr).expect(500, 'Test');
|
||||
});
|
||||
|
||||
|
||||
describe('on success', () => {
|
||||
|
||||
it('should respond with 200 (action: undefined)', async () => {
|
||||
bvGetPrIsTrustedSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false));
|
||||
|
||||
const reqs = [4, 2].map(num => createRequest(num).expect(200, http.STATUS_CODES[200]));
|
||||
await Promise.all(reqs);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 200 (action: labeled)', async () => {
|
||||
bvGetPrIsTrustedSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false));
|
||||
|
||||
const reqs = [4, 2].map(num => createRequest(num, 'labeled').expect(200, http.STATUS_CODES[200]));
|
||||
await Promise.all(reqs);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 200 (action: unlabeled)', async () => {
|
||||
bvGetPrIsTrustedSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false));
|
||||
|
||||
const reqs = [4, 2].map(num => createRequest(num, 'unlabeled').expect(200, http.STATUS_CODES[200]));
|
||||
await Promise.all(reqs);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 200 (and do nothing) if \'action\' implies no visibility change', async () => {
|
||||
const promises = ['foo', 'notlabeled'].
|
||||
map(action => createRequest(+pr, action).expect(200, http.STATUS_CODES[200]));
|
||||
|
||||
await Promise.all(promises);
|
||||
expect(bvGetPrIsTrustedSpy).not.toHaveBeenCalled();
|
||||
expect(bcUpdatePrVisibilitySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('ALL *', () => {
|
||||
|
||||
it('should respond with 404', async () => {
|
||||
const responseFor = (method: string) => `Unknown resource in request: ${method.toUpperCase()} /some/url`;
|
||||
|
||||
await Promise.all([
|
||||
agent.get('/some/url').expect(404, responseFor('get')),
|
||||
agent.put('/some/url').expect(404, responseFor('put')),
|
||||
agent.post('/some/url').expect(404, responseFor('post')),
|
||||
agent.patch('/some/url').expect(404, responseFor('patch')),
|
||||
agent.delete('/some/url').expect(404, responseFor('delete')),
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
@ -0,0 +1,49 @@
|
||||
import * as express from 'express';
|
||||
import {PreviewServerError} from '../../lib/preview-server/preview-error';
|
||||
import {respondWithError, throwRequestError} from '../../lib/preview-server/utils';
|
||||
|
||||
describe('preview-server/utils', () => {
|
||||
describe('respondWithError', () => {
|
||||
let endSpy: jasmine.Spy;
|
||||
let statusSpy: jasmine.Spy;
|
||||
let response: express.Response;
|
||||
|
||||
beforeEach(() => {
|
||||
endSpy = jasmine.createSpy('end');
|
||||
statusSpy = jasmine.createSpy('status').and.callFake(() => response);
|
||||
response = {status: statusSpy, end: endSpy} as any;
|
||||
});
|
||||
|
||||
it('should set the status on the response', () => {
|
||||
respondWithError(response, new PreviewServerError(505, 'TEST MESSAGE'));
|
||||
expect(statusSpy).toHaveBeenCalledWith(505);
|
||||
expect(endSpy).toHaveBeenCalledWith('TEST MESSAGE', jasmine.any(Function));
|
||||
});
|
||||
|
||||
it('should convert non-PreviewServerError errors to 500 PreviewServerErrors', () => {
|
||||
respondWithError(response, new Error('OTHER MESSAGE'));
|
||||
expect(statusSpy).toHaveBeenCalledWith(500);
|
||||
expect(endSpy).toHaveBeenCalledWith('OTHER MESSAGE', jasmine.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('throwRequestError', () => {
|
||||
it('should throw a suitable error', () => {
|
||||
let caught = false;
|
||||
try {
|
||||
const request = {
|
||||
body: 'The request body',
|
||||
method: 'POST',
|
||||
originalUrl: 'some.domain.com/path',
|
||||
} as express.Request;
|
||||
throwRequestError(505, 'ERROR MESSAGE', request);
|
||||
} catch (error) {
|
||||
caught = true;
|
||||
expect(error).toEqual(jasmine.any(PreviewServerError));
|
||||
expect(error.status).toEqual(505);
|
||||
expect(error.message).toEqual(`ERROR MESSAGE in request: POST some.domain.com/path "The request body"`);
|
||||
}
|
||||
expect(caught).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,303 +0,0 @@
|
||||
// Imports
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import {GithubPullRequests, PullRequest} from '../../lib/common/github-pull-requests';
|
||||
import {GithubTeams} from '../../lib/common/github-teams';
|
||||
import {BUILD_VERIFICATION_STATUS, BuildVerifier} from '../../lib/upload-server/build-verifier';
|
||||
import {expectToBeUploadError} from './helpers';
|
||||
|
||||
// Tests
|
||||
describe('BuildVerifier', () => {
|
||||
const defaultConfig = {
|
||||
allowedTeamSlugs: ['team1', 'team2'],
|
||||
githubToken: 'githubToken',
|
||||
organization: 'organization',
|
||||
repoSlug: 'repo/slug',
|
||||
secret: 'secret',
|
||||
trustedPrLabel: 'trusted: pr-label',
|
||||
};
|
||||
let bv: BuildVerifier;
|
||||
|
||||
// Helpers
|
||||
const createBuildVerifier = (partialConfig: Partial<typeof defaultConfig> = {}) => {
|
||||
const cfg = {...defaultConfig, ...partialConfig} as typeof defaultConfig;
|
||||
return new BuildVerifier(cfg.secret, cfg.githubToken, cfg.repoSlug, cfg.organization,
|
||||
cfg.allowedTeamSlugs, cfg.trustedPrLabel);
|
||||
};
|
||||
|
||||
beforeEach(() => bv = createBuildVerifier());
|
||||
|
||||
|
||||
describe('constructor()', () => {
|
||||
|
||||
['secret', 'githubToken', 'repoSlug', 'organization', 'allowedTeamSlugs', 'trustedPrLabel'].
|
||||
forEach(param => {
|
||||
it(`should throw if '${param}' is missing or empty`, () => {
|
||||
expect(() => createBuildVerifier({[param]: ''})).
|
||||
toThrowError(`Missing or empty required parameter '${param}'!`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'allowedTeamSlugs\' is an empty array', () => {
|
||||
expect(() => createBuildVerifier({allowedTeamSlugs: []})).
|
||||
toThrowError('Missing or empty required parameter \'allowedTeamSlugs\'!');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('getPrIsTrusted()', () => {
|
||||
const pr = 9;
|
||||
let mockPrInfo: PullRequest;
|
||||
let prsFetchSpy: jasmine.Spy;
|
||||
let teamsIsMemberBySlugSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrInfo = {
|
||||
labels: [
|
||||
{name: 'foo'},
|
||||
{name: 'bar'},
|
||||
],
|
||||
number: 9,
|
||||
user: {login: 'username'},
|
||||
};
|
||||
|
||||
prsFetchSpy = spyOn(GithubPullRequests.prototype, 'fetch').
|
||||
and.returnValue(Promise.resolve(mockPrInfo));
|
||||
|
||||
teamsIsMemberBySlugSpy = spyOn(GithubTeams.prototype, 'isMemberBySlug').
|
||||
and.returnValue(Promise.resolve(true));
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', done => {
|
||||
const promise = bv.getPrIsTrusted(pr);
|
||||
promise.then(done); // Do not complete the test (and release the spies) synchronously
|
||||
// to avoid running the actual `GithubTeams#isMemberBySlug()`.
|
||||
|
||||
expect(promise).toEqual(jasmine.any(Promise));
|
||||
});
|
||||
|
||||
|
||||
it('should fetch the corresponding PR', done => {
|
||||
bv.getPrIsTrusted(pr).then(() => {
|
||||
expect(prsFetchSpy).toHaveBeenCalledWith(pr);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if fetching the PR errors', done => {
|
||||
prsFetchSpy.and.callFake(() => Promise.reject('Test'));
|
||||
bv.getPrIsTrusted(pr).catch(err => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('when the PR has the "trusted PR" label', () => {
|
||||
|
||||
beforeEach(() => mockPrInfo.labels.push({name: 'trusted: pr-label'}));
|
||||
|
||||
|
||||
it('should resolve to true', done => {
|
||||
bv.getPrIsTrusted(pr).then(isTrusted => {
|
||||
expect(isTrusted).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should not try to verify the author\'s membership status', done => {
|
||||
bv.getPrIsTrusted(pr).then(() => {
|
||||
expect(teamsIsMemberBySlugSpy).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('when the PR does not have the "trusted PR" label', () => {
|
||||
|
||||
it('should verify the PR author\'s membership in the specified teams', done => {
|
||||
bv.getPrIsTrusted(pr).then(() => {
|
||||
expect(teamsIsMemberBySlugSpy).toHaveBeenCalledWith('username', ['team1', 'team2']);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if verifying membership errors', done => {
|
||||
teamsIsMemberBySlugSpy.and.callFake(() => Promise.reject('Test'));
|
||||
bv.getPrIsTrusted(pr).catch(err => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should resolve to true if the PR\'s author is a member', done => {
|
||||
teamsIsMemberBySlugSpy.and.returnValue(Promise.resolve(true));
|
||||
|
||||
bv.getPrIsTrusted(pr).then(isTrusted => {
|
||||
expect(isTrusted).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should resolve to false if the PR\'s author is not a member', done => {
|
||||
teamsIsMemberBySlugSpy.and.returnValue(Promise.resolve(false));
|
||||
|
||||
bv.getPrIsTrusted(pr).then(isTrusted => {
|
||||
expect(isTrusted).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('verify()', () => {
|
||||
const pr = 9;
|
||||
const defaultJwt = {
|
||||
'exp': Math.floor(Date.now() / 1000) + 30,
|
||||
'iat': Math.floor(Date.now() / 1000) - 30,
|
||||
'iss': 'Travis CI, GmbH',
|
||||
'pull-request': pr,
|
||||
'slug': defaultConfig.repoSlug,
|
||||
};
|
||||
let bvGetPrIsTrusted: jasmine.Spy;
|
||||
|
||||
// Heleprs
|
||||
const createAuthHeader = (partialJwt: Partial<typeof defaultJwt> = {}, secret: string = defaultConfig.secret) =>
|
||||
`Token ${jwt.sign({...defaultJwt, ...partialJwt}, secret)}`;
|
||||
|
||||
beforeEach(() => {
|
||||
bvGetPrIsTrusted = spyOn(bv, 'getPrIsTrusted').and.returnValue(Promise.resolve(true));
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', done => {
|
||||
const promise = bv.verify(pr, createAuthHeader());
|
||||
promise.then(done); // Do not complete the test (and release the spies) synchronously
|
||||
// to avoid running the actual `bvGetPrIsTrusted()`.
|
||||
|
||||
expect(promise).toEqual(jasmine.any(Promise));
|
||||
});
|
||||
|
||||
|
||||
it('should fail if the authorization header is invalid', done => {
|
||||
bv.verify(pr, 'foo').catch(err => {
|
||||
const errorMessage = 'Error while verifying upload for PR 9: jwt malformed';
|
||||
|
||||
expectToBeUploadError(err, 403, errorMessage);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if the secret is invalid', done => {
|
||||
bv.verify(pr, createAuthHeader({}, 'foo')).catch(err => {
|
||||
const errorMessage = 'Error while verifying upload for PR 9: invalid signature';
|
||||
|
||||
expectToBeUploadError(err, 403, errorMessage);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if the issuer is invalid', done => {
|
||||
bv.verify(pr, createAuthHeader({iss: 'not valid'})).catch(err => {
|
||||
const errorMessage = 'Error while verifying upload for PR 9: ' +
|
||||
`jwt issuer invalid. expected: ${defaultJwt.iss}`;
|
||||
|
||||
expectToBeUploadError(err, 403, errorMessage);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if the token has expired', done => {
|
||||
bv.verify(pr, createAuthHeader({exp: 0})).catch(err => {
|
||||
const errorMessage = 'Error while verifying upload for PR 9: jwt expired';
|
||||
|
||||
expectToBeUploadError(err, 403, errorMessage);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if the repo slug does not match', done => {
|
||||
bv.verify(pr, createAuthHeader({slug: 'foo/bar'})).catch(err => {
|
||||
const errorMessage = 'Error while verifying upload for PR 9: ' +
|
||||
`jwt slug invalid. expected: ${defaultConfig.repoSlug}`;
|
||||
|
||||
expectToBeUploadError(err, 403, errorMessage);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if the PR does not match', done => {
|
||||
bv.verify(pr, createAuthHeader({'pull-request': 1337})).catch(err => {
|
||||
const errorMessage = 'Error while verifying upload for PR 9: ' +
|
||||
`jwt pull-request invalid. expected: ${pr}`;
|
||||
|
||||
expectToBeUploadError(err, 403, errorMessage);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should not fail if the token is valid', done => {
|
||||
bv.verify(pr, createAuthHeader()).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should not fail even if the token has been issued in the future', done => {
|
||||
const in30s = Math.floor(Date.now() / 1000) + 30;
|
||||
bv.verify(pr, createAuthHeader({iat: in30s})).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should call \'getPrIsTrusted()\' if the token is valid', done => {
|
||||
bv.verify(pr, createAuthHeader()).then(() => {
|
||||
expect(bvGetPrIsTrusted).toHaveBeenCalledWith(pr);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if \'getPrIsTrusted()\' rejects', done => {
|
||||
bvGetPrIsTrusted.and.callFake(() => Promise.reject('Test'));
|
||||
bv.verify(pr, createAuthHeader()).catch(err => {
|
||||
expectToBeUploadError(err, 403, `Error while verifying upload for PR ${pr}: Test`);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should resolve to `verifiedNotTrusted` if \'getPrIsTrusted()\' returns false', done => {
|
||||
bvGetPrIsTrusted.and.returnValue(Promise.resolve(false));
|
||||
bv.verify(pr, createAuthHeader()).then(value => {
|
||||
expect(value).toBe(BUILD_VERIFICATION_STATUS.verifiedNotTrusted);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should resolve to `verifiedAndTrusted` if \'getPrIsTrusted()\' returns true', done => {
|
||||
bv.verify(pr, createAuthHeader()).then(value => {
|
||||
expect(value).toBe(BUILD_VERIFICATION_STATUS.verifiedAndTrusted);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
@ -1,11 +0,0 @@
|
||||
import {UploadError} from '../../lib/upload-server/upload-error';
|
||||
|
||||
export const expectToBeUploadError = (actual: UploadError, status?: number, message?: string) => {
|
||||
expect(actual).toEqual(jasmine.any(UploadError));
|
||||
if (status != null) {
|
||||
expect(actual.status).toBe(status);
|
||||
}
|
||||
if (message != null) {
|
||||
expect(actual.message).toBe(message);
|
||||
}
|
||||
};
|
@ -1,39 +0,0 @@
|
||||
// Imports
|
||||
import {UploadError} from '../../lib/upload-server/upload-error';
|
||||
|
||||
// Tests
|
||||
describe('UploadError', () => {
|
||||
let err: UploadError;
|
||||
|
||||
beforeEach(() => err = new UploadError(999, 'message'));
|
||||
|
||||
|
||||
it('should extend Error', () => {
|
||||
expect(err).toEqual(jasmine.any(UploadError));
|
||||
expect(err).toEqual(jasmine.any(Error));
|
||||
|
||||
expect(Object.getPrototypeOf(err)).toBe(UploadError.prototype);
|
||||
});
|
||||
|
||||
|
||||
it('should have a \'status\' property', () => {
|
||||
expect(err.status).toBe(999);
|
||||
});
|
||||
|
||||
|
||||
it('should have a \'message\' property', () => {
|
||||
expect(err.message).toBe('message');
|
||||
});
|
||||
|
||||
|
||||
it('should have a 500 \'status\' by default', () => {
|
||||
expect(new UploadError().status).toBe(500);
|
||||
});
|
||||
|
||||
|
||||
it('should have an empty \'message\' by default', () => {
|
||||
expect(new UploadError().message).toBe('');
|
||||
expect(new UploadError(999).message).toBe('');
|
||||
});
|
||||
|
||||
});
|
@ -1,603 +0,0 @@
|
||||
// Imports
|
||||
import * as express from 'express';
|
||||
import * as http from 'http';
|
||||
import * as supertest from 'supertest';
|
||||
import {GithubPullRequests} from '../../lib/common/github-pull-requests';
|
||||
import {BuildCreator} from '../../lib/upload-server/build-creator';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/upload-server/build-events';
|
||||
import {BUILD_VERIFICATION_STATUS, BuildVerifier} from '../../lib/upload-server/build-verifier';
|
||||
import {uploadServerFactory as usf} from '../../lib/upload-server/upload-server-factory';
|
||||
|
||||
// Tests
|
||||
describe('uploadServerFactory', () => {
|
||||
const defaultConfig = {
|
||||
buildsDir: 'builds/dir',
|
||||
domainName: 'domain.name',
|
||||
githubOrganization: 'organization',
|
||||
githubTeamSlugs: ['team1', 'team2'],
|
||||
githubToken: '12345',
|
||||
repoSlug: 'repo/slug',
|
||||
secret: 'secret',
|
||||
trustedPrLabel: 'trusted: pr-label',
|
||||
};
|
||||
|
||||
// Helpers
|
||||
const createUploadServer = (partialConfig: Partial<typeof defaultConfig> = {}) =>
|
||||
usf.create({...defaultConfig, ...partialConfig} as typeof defaultConfig);
|
||||
|
||||
|
||||
describe('create()', () => {
|
||||
let usfCreateMiddlewareSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
usfCreateMiddlewareSpy = spyOn(usf as any, 'createMiddleware').and.callThrough();
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'buildsDir\' is missing or empty', () => {
|
||||
expect(() => createUploadServer({buildsDir: ''})).
|
||||
toThrowError('Missing or empty required parameter \'buildsDir\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'domainName\' is missing or empty', () => {
|
||||
expect(() => createUploadServer({domainName: ''})).
|
||||
toThrowError('Missing or empty required parameter \'domainName\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubToken\' is missing or empty', () => {
|
||||
expect(() => createUploadServer({githubToken: ''})).
|
||||
toThrowError('Missing or empty required parameter \'githubToken\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubOrganization\' is missing or empty', () => {
|
||||
expect(() => createUploadServer({githubOrganization: ''})).
|
||||
toThrowError('Missing or empty required parameter \'organization\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'githubTeamSlugs\' is missing or empty', () => {
|
||||
expect(() => createUploadServer({githubTeamSlugs: []})).
|
||||
toThrowError('Missing or empty required parameter \'allowedTeamSlugs\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'repoSlug\' is missing or empty', () => {
|
||||
expect(() => createUploadServer({repoSlug: ''})).
|
||||
toThrowError('Missing or empty required parameter \'repoSlug\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'secret\' is missing or empty', () => {
|
||||
expect(() => createUploadServer({secret: ''})).
|
||||
toThrowError('Missing or empty required parameter \'secret\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should throw if \'trustedPrLabel\' is missing or empty', () => {
|
||||
expect(() => createUploadServer({trustedPrLabel: ''})).
|
||||
toThrowError('Missing or empty required parameter \'trustedPrLabel\'!');
|
||||
});
|
||||
|
||||
|
||||
it('should return an http.Server', () => {
|
||||
const httpCreateServerSpy = spyOn(http, 'createServer').and.callThrough();
|
||||
const server = createUploadServer();
|
||||
|
||||
expect(server).toBe(httpCreateServerSpy.calls.mostRecent().returnValue);
|
||||
});
|
||||
|
||||
|
||||
it('should create and use an appropriate BuildCreator', () => {
|
||||
const usfCreateBuildCreatorSpy = spyOn(usf as any, 'createBuildCreator').and.callThrough();
|
||||
|
||||
createUploadServer();
|
||||
const buildCreator: BuildCreator = usfCreateBuildCreatorSpy.calls.mostRecent().returnValue;
|
||||
|
||||
expect(usfCreateMiddlewareSpy).toHaveBeenCalledWith(jasmine.any(BuildVerifier), buildCreator);
|
||||
expect(usfCreateBuildCreatorSpy).toHaveBeenCalledWith('builds/dir', '12345', 'repo/slug', 'domain.name');
|
||||
});
|
||||
|
||||
|
||||
it('should create and use an appropriate middleware', () => {
|
||||
const httpCreateServerSpy = spyOn(http, 'createServer').and.callThrough();
|
||||
|
||||
createUploadServer();
|
||||
const middleware: express.Express = usfCreateMiddlewareSpy.calls.mostRecent().returnValue;
|
||||
const buildVerifier = jasmine.any(BuildVerifier);
|
||||
const buildCreator = jasmine.any(BuildCreator);
|
||||
|
||||
expect(httpCreateServerSpy).toHaveBeenCalledWith(middleware);
|
||||
expect(usfCreateMiddlewareSpy).toHaveBeenCalledWith(buildVerifier, buildCreator);
|
||||
});
|
||||
|
||||
|
||||
it('should log the server address info on \'listening\'', () => {
|
||||
const consoleInfoSpy = spyOn(console, 'info');
|
||||
const server = createUploadServer();
|
||||
server.address = () => ({address: 'foo', family: '', port: 1337});
|
||||
|
||||
expect(consoleInfoSpy).not.toHaveBeenCalled();
|
||||
|
||||
server.emit('listening');
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith('Up and running (and listening on foo:1337)...');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// Protected methods
|
||||
|
||||
describe('createBuildCreator()', () => {
|
||||
let buildCreator: BuildCreator;
|
||||
|
||||
beforeEach(() => {
|
||||
buildCreator = (usf as any).createBuildCreator(
|
||||
defaultConfig.buildsDir,
|
||||
defaultConfig.githubToken,
|
||||
defaultConfig.repoSlug,
|
||||
defaultConfig.domainName,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('should pass the \'buildsDir\' to the BuildCreator', () => {
|
||||
expect((buildCreator as any).buildsDir).toBe('builds/dir');
|
||||
});
|
||||
|
||||
|
||||
describe('on \'build.created\'', () => {
|
||||
let prsAddCommentSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => prsAddCommentSpy = spyOn(GithubPullRequests.prototype, 'addComment'));
|
||||
|
||||
|
||||
it('should post a comment on GitHub for public previews', () => {
|
||||
const commentBody = 'You can preview 1234567890 at https://pr42-1234567890.domain.name/.';
|
||||
|
||||
buildCreator.emit(CreatedBuildEvent.type, {pr: 42, sha: '1234567890', isPublic: true});
|
||||
expect(prsAddCommentSpy).toHaveBeenCalledWith(42, commentBody);
|
||||
});
|
||||
|
||||
|
||||
it('should not post a comment on GitHub for non-public previews', () => {
|
||||
buildCreator.emit(CreatedBuildEvent.type, {pr: 42, sha: '1234567890', isPublic: false});
|
||||
expect(prsAddCommentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('on \'pr.changedVisibility\'', () => {
|
||||
let prsAddCommentSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => prsAddCommentSpy = spyOn(GithubPullRequests.prototype, 'addComment'));
|
||||
|
||||
|
||||
it('should post a comment on GitHub (for all SHAs) for PRs made public', () => {
|
||||
const commentBody = 'You can preview 12345 at https://pr42-12345.domain.name/.\n' +
|
||||
'You can preview 67890 at https://pr42-67890.domain.name/.';
|
||||
|
||||
buildCreator.emit(ChangedPrVisibilityEvent.type, {pr: 42, shas: ['12345', '67890'], isPublic: true});
|
||||
expect(prsAddCommentSpy).toHaveBeenCalledWith(42, commentBody);
|
||||
});
|
||||
|
||||
|
||||
it('should not post a comment on GitHub if no SHAs were affected', () => {
|
||||
buildCreator.emit(ChangedPrVisibilityEvent.type, {pr: 42, shas: [], isPublic: true});
|
||||
expect(prsAddCommentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should not post a comment on GitHub for PRs made non-public', () => {
|
||||
buildCreator.emit(ChangedPrVisibilityEvent.type, {pr: 42, shas: ['12345', '67890'], isPublic: false});
|
||||
expect(prsAddCommentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
it('should pass the correct \'githubToken\' and \'repoSlug\' to GithubPullRequests', () => {
|
||||
const prsAddCommentSpy = spyOn(GithubPullRequests.prototype, 'addComment');
|
||||
|
||||
buildCreator.emit(CreatedBuildEvent.type, {pr: 42, sha: '1234567890', isPublic: true});
|
||||
buildCreator.emit(ChangedPrVisibilityEvent.type, {pr: 42, shas: ['12345', '67890'], isPublic: true});
|
||||
|
||||
const allCalls = prsAddCommentSpy.calls.all();
|
||||
const prs = allCalls[0].object;
|
||||
|
||||
expect(prsAddCommentSpy).toHaveBeenCalledTimes(2);
|
||||
expect(prs).toBe(allCalls[1].object);
|
||||
expect(prs).toEqual(jasmine.any(GithubPullRequests));
|
||||
expect(prs.repoSlug).toBe('repo/slug');
|
||||
expect(prs.requestHeaders.Authorization).toContain('12345');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('createMiddleware()', () => {
|
||||
let buildVerifier: BuildVerifier;
|
||||
let buildCreator: BuildCreator;
|
||||
let agent: supertest.SuperTest<supertest.Test>;
|
||||
|
||||
// Helpers
|
||||
const promisifyRequest = (req: supertest.Request) =>
|
||||
new Promise((resolve, reject) => req.end(err => err ? reject(err) : resolve()));
|
||||
const verifyRequests = (reqs: supertest.Request[], done: jasmine.DoneFn) =>
|
||||
Promise.all(reqs.map(promisifyRequest)).then(done, done.fail);
|
||||
|
||||
beforeEach(() => {
|
||||
buildVerifier = new BuildVerifier(
|
||||
defaultConfig.secret,
|
||||
defaultConfig.githubToken,
|
||||
defaultConfig.repoSlug,
|
||||
defaultConfig.githubOrganization,
|
||||
defaultConfig.githubTeamSlugs,
|
||||
defaultConfig.trustedPrLabel,
|
||||
);
|
||||
buildCreator = new BuildCreator(defaultConfig.buildsDir);
|
||||
agent = supertest.agent((usf as any).createMiddleware(buildVerifier, buildCreator));
|
||||
|
||||
spyOn(console, 'error');
|
||||
});
|
||||
|
||||
|
||||
describe('GET /create-build/<pr>/<sha>', () => {
|
||||
const pr = '9';
|
||||
const sha = '9'.repeat(40);
|
||||
let buildVerifierVerifySpy: jasmine.Spy;
|
||||
let buildCreatorCreateSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
const verStatus = BUILD_VERIFICATION_STATUS.verifiedAndTrusted;
|
||||
buildVerifierVerifySpy = spyOn(buildVerifier, 'verify').and.returnValue(Promise.resolve(verStatus));
|
||||
buildCreatorCreateSpy = spyOn(buildCreator, 'create').and.returnValue(Promise.resolve());
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for non-GET requests', done => {
|
||||
verifyRequests([
|
||||
agent.put(`/create-build/${pr}/${sha}`).expect(404),
|
||||
agent.post(`/create-build/${pr}/${sha}`).expect(404),
|
||||
agent.patch(`/create-build/${pr}/${sha}`).expect(404),
|
||||
agent.delete(`/create-build/${pr}/${sha}`).expect(404),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 401 for requests without an \'AUTHORIZATION\' header', done => {
|
||||
const url = `/create-build/${pr}/${sha}`;
|
||||
const responseBody = `Missing or empty 'AUTHORIZATION' header in request: GET ${url}`;
|
||||
|
||||
verifyRequests([
|
||||
agent.get(url).expect(401, responseBody),
|
||||
agent.get(url).set('AUTHORIZATION', '').expect(401, responseBody),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 400 for requests without an \'X-FILE\' header', done => {
|
||||
const url = `/create-build/${pr}/${sha}`;
|
||||
const responseBody = `Missing or empty 'X-FILE' header in request: GET ${url}`;
|
||||
|
||||
const request1 = agent.get(url).set('AUTHORIZATION', 'foo');
|
||||
const request2 = agent.get(url).set('AUTHORIZATION', 'foo').set('X-FILE', '');
|
||||
|
||||
verifyRequests([
|
||||
request1.expect(400, responseBody),
|
||||
request2.expect(400, responseBody),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown paths', done => {
|
||||
verifyRequests([
|
||||
agent.get(`/foo/create-build/${pr}/${sha}`).expect(404),
|
||||
agent.get(`/foo-create-build/${pr}/${sha}`).expect(404),
|
||||
agent.get(`/fooncreate-build/${pr}/${sha}`).expect(404),
|
||||
agent.get(`/create-build/foo/${pr}/${sha}`).expect(404),
|
||||
agent.get(`/create-build-foo/${pr}/${sha}`).expect(404),
|
||||
agent.get(`/create-buildnfoo/${pr}/${sha}`).expect(404),
|
||||
agent.get(`/create-build/pr${pr}/${sha}`).expect(404),
|
||||
agent.get(`/create-build/${pr}/${sha}42`).expect(404),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
||||
it('should call \'BuildVerifier#verify()\' with the correct arguments', done => {
|
||||
const req = agent.
|
||||
get(`/create-build/${pr}/${sha}`).
|
||||
set('AUTHORIZATION', 'foo').
|
||||
set('X-FILE', 'bar');
|
||||
|
||||
promisifyRequest(req).
|
||||
then(() => expect(buildVerifierVerifySpy).toHaveBeenCalledWith(9, 'foo')).
|
||||
then(done, done.fail);
|
||||
});
|
||||
|
||||
|
||||
it('should propagate errors from BuildVerifier', done => {
|
||||
buildVerifierVerifySpy.and.callFake(() => Promise.reject('Test'));
|
||||
|
||||
const req = agent.
|
||||
get(`/create-build/${pr}/${sha}`).
|
||||
set('AUTHORIZATION', 'foo').
|
||||
set('X-FILE', 'bar').
|
||||
expect(500, 'Test');
|
||||
|
||||
promisifyRequest(req).
|
||||
then(() => {
|
||||
expect(buildVerifierVerifySpy).toHaveBeenCalledWith(9, 'foo');
|
||||
expect(buildCreatorCreateSpy).not.toHaveBeenCalled();
|
||||
}).
|
||||
then(done, done.fail);
|
||||
});
|
||||
|
||||
|
||||
it('should call \'BuildCreator#create()\' with the correct arguments', done => {
|
||||
buildVerifierVerifySpy.and.returnValues(
|
||||
Promise.resolve(BUILD_VERIFICATION_STATUS.verifiedAndTrusted),
|
||||
Promise.resolve(BUILD_VERIFICATION_STATUS.verifiedNotTrusted));
|
||||
|
||||
const req1 = agent.get(`/create-build/${pr}/${sha}`).set('AUTHORIZATION', 'foo').set('X-FILE', 'bar');
|
||||
const req2 = agent.get(`/create-build/${pr}/${sha}`).set('AUTHORIZATION', 'foo').set('X-FILE', 'bar');
|
||||
|
||||
Promise.all([
|
||||
promisifyRequest(req1).then(() => expect(buildCreatorCreateSpy).toHaveBeenCalledWith(pr, sha, 'bar', true)),
|
||||
promisifyRequest(req2).then(() => expect(buildCreatorCreateSpy).toHaveBeenCalledWith(pr, sha, 'bar', false)),
|
||||
]).then(done, done.fail);
|
||||
});
|
||||
|
||||
|
||||
it('should propagate errors from BuildCreator', done => {
|
||||
buildCreatorCreateSpy.and.callFake(() => Promise.reject('Test'));
|
||||
const req = agent.
|
||||
get(`/create-build/${pr}/${sha}`).
|
||||
set('AUTHORIZATION', 'foo').
|
||||
set('X-FILE', 'bar').
|
||||
expect(500, 'Test');
|
||||
|
||||
verifyRequests([req], done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 201 on successful upload (for public builds)', done => {
|
||||
const req = agent.
|
||||
get(`/create-build/${pr}/${sha}`).
|
||||
set('AUTHORIZATION', 'foo').
|
||||
set('X-FILE', 'bar').
|
||||
expect(201, http.STATUS_CODES[201]);
|
||||
|
||||
verifyRequests([req], done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 202 on successful upload (for hidden builds)', done => {
|
||||
buildVerifierVerifySpy.and.returnValue(Promise.resolve(BUILD_VERIFICATION_STATUS.verifiedNotTrusted));
|
||||
const req = agent.
|
||||
get(`/create-build/${pr}/${sha}`).
|
||||
set('AUTHORIZATION', 'foo').
|
||||
set('X-FILE', 'bar').
|
||||
expect(202, http.STATUS_CODES[202]);
|
||||
|
||||
verifyRequests([req], done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject PRs with leading zeros', done => {
|
||||
verifyRequests([agent.get(`/create-build/0${pr}/${sha}`).expect(404)], done);
|
||||
});
|
||||
|
||||
|
||||
it('should accept SHAs with leading zeros (but not trim the zeros)', done => {
|
||||
const sha40 = '0'.repeat(40);
|
||||
const sha41 = `0${sha40}`;
|
||||
|
||||
const request40 = agent.get(`/create-build/${pr}/${sha40}`).set('AUTHORIZATION', 'foo').set('X-FILE', 'bar');
|
||||
const request41 = agent.get(`/create-build/${pr}/${sha41}`).set('AUTHORIZATION', 'baz').set('X-FILE', 'qux');
|
||||
|
||||
Promise.all([
|
||||
promisifyRequest(request40.expect(201)),
|
||||
promisifyRequest(request41.expect(404)),
|
||||
]).then(done, done.fail);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('GET /health-check', () => {
|
||||
|
||||
it('should respond with 200', done => {
|
||||
verifyRequests([
|
||||
agent.get('/health-check').expect(200),
|
||||
agent.get('/health-check/').expect(200),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for non-GET requests', done => {
|
||||
verifyRequests([
|
||||
agent.put('/health-check').expect(404),
|
||||
agent.post('/health-check').expect(404),
|
||||
agent.patch('/health-check').expect(404),
|
||||
agent.delete('/health-check').expect(404),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 if the path does not match exactly', done => {
|
||||
verifyRequests([
|
||||
agent.get('/health-check/foo').expect(404),
|
||||
agent.get('/health-check-foo').expect(404),
|
||||
agent.get('/health-checknfoo').expect(404),
|
||||
agent.get('/foo/health-check').expect(404),
|
||||
agent.get('/foo-health-check').expect(404),
|
||||
agent.get('/foonhealth-check').expect(404),
|
||||
], done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('POST /pr-updated', () => {
|
||||
const pr = '9';
|
||||
const url = '/pr-updated';
|
||||
let bvGetPrIsTrustedSpy: jasmine.Spy;
|
||||
let bcUpdatePrVisibilitySpy: jasmine.Spy;
|
||||
|
||||
// Helpers
|
||||
const createRequest = (num: number, action?: string) =>
|
||||
agent.post(url).send({number: num, action});
|
||||
|
||||
beforeEach(() => {
|
||||
bvGetPrIsTrustedSpy = spyOn(buildVerifier, 'getPrIsTrusted');
|
||||
bcUpdatePrVisibilitySpy = spyOn(buildCreator, 'updatePrVisibility');
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for non-POST requests', done => {
|
||||
verifyRequests([
|
||||
agent.get(url).expect(404),
|
||||
agent.put(url).expect(404),
|
||||
agent.patch(url).expect(404),
|
||||
agent.delete(url).expect(404),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 400 for requests without a payload', done => {
|
||||
const responseBody = `Missing or empty 'number' field in request: POST ${url} {}`;
|
||||
|
||||
const request1 = agent.post(url);
|
||||
const request2 = agent.post(url).send();
|
||||
|
||||
verifyRequests([
|
||||
request1.expect(400, responseBody),
|
||||
request2.expect(400, responseBody),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 400 for requests without a \'number\' field', done => {
|
||||
const responseBodyPrefix = `Missing or empty 'number' field in request: POST ${url}`;
|
||||
|
||||
const request1 = agent.post(url).send({});
|
||||
const request2 = agent.post(url).send({number: null});
|
||||
|
||||
verifyRequests([
|
||||
request1.expect(400, `${responseBodyPrefix} {}`),
|
||||
request2.expect(400, `${responseBodyPrefix} {"number":null}`),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
||||
it('should call \'BuildVerifier#gtPrIsTrusted()\' with the correct arguments', done => {
|
||||
const req = createRequest(+pr);
|
||||
|
||||
promisifyRequest(req).
|
||||
then(() => expect(bvGetPrIsTrustedSpy).toHaveBeenCalledWith(9)).
|
||||
then(done, done.fail);
|
||||
});
|
||||
|
||||
|
||||
it('should propagate errors from BuildVerifier', done => {
|
||||
bvGetPrIsTrustedSpy.and.callFake(() => Promise.reject('Test'));
|
||||
|
||||
const req = createRequest(+pr).expect(500, 'Test');
|
||||
|
||||
promisifyRequest(req).
|
||||
then(() => {
|
||||
expect(bvGetPrIsTrustedSpy).toHaveBeenCalledWith(9);
|
||||
expect(bcUpdatePrVisibilitySpy).not.toHaveBeenCalled();
|
||||
}).
|
||||
then(done, done.fail);
|
||||
});
|
||||
|
||||
|
||||
it('should call \'BuildCreator#updatePrVisibility()\' with the correct arguments', done => {
|
||||
bvGetPrIsTrustedSpy.and.callFake((pr2: number) => Promise.resolve(pr2 === 42));
|
||||
|
||||
const req1 = createRequest(24);
|
||||
const req2 = createRequest(42);
|
||||
|
||||
Promise.all([
|
||||
promisifyRequest(req1).then(() => expect(bcUpdatePrVisibilitySpy).toHaveBeenCalledWith('24', false)),
|
||||
promisifyRequest(req2).then(() => expect(bcUpdatePrVisibilitySpy).toHaveBeenCalledWith('42', true)),
|
||||
]).then(done, done.fail);
|
||||
});
|
||||
|
||||
|
||||
it('should propagate errors from BuildCreator', done => {
|
||||
bcUpdatePrVisibilitySpy.and.callFake(() => Promise.reject('Test'));
|
||||
|
||||
const req = createRequest(+pr).expect(500, 'Test');
|
||||
verifyRequests([req], done);
|
||||
});
|
||||
|
||||
|
||||
describe('on success', () => {
|
||||
|
||||
it('should respond with 200 (action: undefined)', done => {
|
||||
bvGetPrIsTrustedSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false));
|
||||
|
||||
const reqs = [4, 2].map(num => createRequest(num).expect(200, http.STATUS_CODES[200]));
|
||||
verifyRequests(reqs, done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 200 (action: labeled)', done => {
|
||||
bvGetPrIsTrustedSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false));
|
||||
|
||||
const reqs = [4, 2].map(num => createRequest(num, 'labeled').expect(200, http.STATUS_CODES[200]));
|
||||
verifyRequests(reqs, done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 200 (action: unlabeled)', done => {
|
||||
bvGetPrIsTrustedSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false));
|
||||
|
||||
const reqs = [4, 2].map(num => createRequest(num, 'unlabeled').expect(200, http.STATUS_CODES[200]));
|
||||
verifyRequests(reqs, done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 200 (and do nothing) if \'action\' implies no visibility change', done => {
|
||||
const promises = ['foo', 'notlabeled'].
|
||||
map(action => createRequest(+pr, action).expect(200, http.STATUS_CODES[200])).
|
||||
map(promisifyRequest);
|
||||
|
||||
Promise.all(promises).
|
||||
then(() => {
|
||||
expect(bvGetPrIsTrustedSpy).not.toHaveBeenCalled();
|
||||
expect(bcUpdatePrVisibilitySpy).not.toHaveBeenCalled();
|
||||
}).
|
||||
then(done, done.fail);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('ALL *', () => {
|
||||
|
||||
it('should respond with 404', done => {
|
||||
const responseFor = (method: string) => `Unknown resource in request: ${method.toUpperCase()} /some/url`;
|
||||
|
||||
verifyRequests([
|
||||
agent.get('/some/url').expect(404, responseFor('get')),
|
||||
agent.put('/some/url').expect(404, responseFor('put')),
|
||||
agent.post('/some/url').expect(404, responseFor('post')),
|
||||
agent.patch('/some/url').expect(404, responseFor('patch')),
|
||||
agent.delete('/some/url').expect(404, responseFor('delete')),
|
||||
], done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
File diff suppressed because it is too large
Load Diff
1
aio/aio-builds-setup/dockerbuild/scripts-sh/clean-up.sh
Executable file → Normal file
1
aio/aio-builds-setup/dockerbuild/scripts-sh/clean-up.sh
Executable file → Normal file
@ -2,6 +2,7 @@
|
||||
set -eu -o pipefail
|
||||
|
||||
# Set up env variables
|
||||
export AIO_CIRCLE_CI_TOKEN=UNUSED_CIRCLE_CI_TOKEN
|
||||
export AIO_GITHUB_TOKEN=$(head -c -1 /aio-secrets/GITHUB_TOKEN 2>/dev/null)
|
||||
|
||||
# Run the clean-up
|
||||
|
12
aio/aio-builds-setup/dockerbuild/scripts-sh/dev-mode.sh
Normal file
12
aio/aio-builds-setup/dockerbuild/scripts-sh/dev-mode.sh
Normal file
@ -0,0 +1,12 @@
|
||||
# Link the scripts on the host to the scripts in the container
|
||||
# - the host scripts are mounted as a volume at `/dockerbuild`)
|
||||
# - the original scripts are moved to `..._prod` in case they are needed later
|
||||
# See `aio/aio-builds-setup/docs/misc--debug-docker-container.md` for more info
|
||||
|
||||
mv $AIO_SCRIPTS_SH_DIR ${AIO_SCRIPTS_SH_DIR}_prod
|
||||
ln -s /dockerbuild/scripts-sh $AIO_SCRIPTS_SH_DIR
|
||||
chmod a+x $AIO_SCRIPTS_SH_DIR/*
|
||||
|
||||
mv $AIO_SCRIPTS_JS_DIR ${AIO_SCRIPTS_JS_DIR}_prod
|
||||
ln -s /dockerbuild/scripts-js $AIO_SCRIPTS_JS_DIR
|
||||
|
@ -30,7 +30,7 @@ done
|
||||
|
||||
# Check servers
|
||||
origins=(
|
||||
http://$AIO_UPLOAD_HOSTNAME:$AIO_UPLOAD_PORT
|
||||
http://$AIO_PREVIEW_SERVER_HOSTNAME:$AIO_PREVIEW_SERVER_PORT
|
||||
http://$AIO_NGINX_HOSTNAME:$AIO_NGINX_PORT_HTTP
|
||||
https://$AIO_NGINX_HOSTNAME:$AIO_NGINX_PORT_HTTPS
|
||||
)
|
||||
|
2
aio/aio-builds-setup/dockerbuild/scripts-sh/init.sh
Executable file → Normal file
2
aio/aio-builds-setup/dockerbuild/scripts-sh/init.sh
Executable file → Normal file
@ -14,5 +14,5 @@ service cron start
|
||||
service dnsmasq start
|
||||
service nginx start
|
||||
service pm2-root start
|
||||
aio-upload-server-prod start
|
||||
aio-preview-server-prod start
|
||||
echo [`date`] - Services started successfully.
|
||||
|
@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -eu -o pipefail
|
||||
|
||||
# Set up env variables for production
|
||||
export AIO_CIRCLE_CI_TOKEN=$(head -c -1 /aio-secrets/CIRCLE_CI_TOKEN 2>/dev/null || echo "MISSING_CIRCLE_CI_TOKEN")
|
||||
export AIO_GITHUB_TOKEN=$(head -c -1 /aio-secrets/GITHUB_TOKEN 2>/dev/null || echo "MISSING_GITHUB_TOKEN")
|
||||
|
||||
# Start the preview-server instance
|
||||
action=$([ "$1" == "stop" ] && echo "stop" || echo "start")
|
||||
pm2 $action $AIO_SCRIPTS_JS_DIR/dist/lib/preview-server \
|
||||
--uid $AIO_WWW_USER \
|
||||
--log /var/log/aio/preview-server-prod.log \
|
||||
--name aio-preview-server-prod \
|
||||
${@:2}
|
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -eu -o pipefail
|
||||
|
||||
# Start the preview-server instance
|
||||
appName=aio-preview-server-test
|
||||
if [[ "$1" == "stop" ]]; then
|
||||
pm2 delete $appName
|
||||
else
|
||||
source aio-test-env
|
||||
pm2 start $AIO_SCRIPTS_JS_DIR/dist/lib/verify-setup/start-test-preview-server.js \
|
||||
--uid $AIO_WWW_USER \
|
||||
--log /var/log/aio/preview-server-test.log \
|
||||
--name $appName \
|
||||
--no-autorestart \
|
||||
${@:2}
|
||||
fi
|
19
aio/aio-builds-setup/dockerbuild/scripts-sh/test-env.sh
Normal file
19
aio/aio-builds-setup/dockerbuild/scripts-sh/test-env.sh
Normal file
@ -0,0 +1,19 @@
|
||||
# Set up env variables for testing
|
||||
export AIO_NGINX_HOSTNAME=$TEST_AIO_NGINX_HOSTNAME
|
||||
export AIO_NGINX_PORT_HTTP=$TEST_AIO_NGINX_PORT_HTTP
|
||||
export AIO_NGINX_PORT_HTTPS=$TEST_AIO_NGINX_PORT_HTTPS
|
||||
|
||||
export AIO_ARTIFACT_PATH=$TEST_AIO_ARTIFACT_PATH
|
||||
export AIO_BUILDS_DIR=$TEST_AIO_BUILDS_DIR
|
||||
export AIO_DOMAIN_NAME=$TEST_AIO_DOMAIN_NAME
|
||||
export AIO_GITHUB_ORGANIZATION=$TEST_AIO_GITHUB_ORGANIZATION
|
||||
export AIO_GITHUB_REPO=$TEST_AIO_GITHUB_REPO
|
||||
export AIO_GITHUB_TEAM_SLUGS=$TEST_AIO_GITHUB_TEAM_SLUGS
|
||||
export AIO_SIGNIFICANT_FILES_PATTERN=$TEST_AIO_SIGNIFICANT_FILES_PATTERN
|
||||
export AIO_TRUSTED_PR_LABEL=$TEST_AIO_TRUSTED_PR_LABEL
|
||||
export AIO_PREVIEW_SERVER_HOSTNAME=$TEST_AIO_PREVIEW_SERVER_HOSTNAME
|
||||
export AIO_PREVIEW_SERVER_PORT=$TEST_AIO_PREVIEW_SERVER_PORT
|
||||
export AIO_ARTIFACT_MAX_SIZE=$TEST_AIO_ARTIFACT_MAX_SIZE
|
||||
|
||||
export AIO_CIRCLE_CI_TOKEN=TEST_CIRCLE_CI_TOKEN
|
||||
export AIO_GITHUB_TOKEN=TEST_GITHUB_TOKEN
|
@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -eu -o pipefail
|
||||
|
||||
# Set up env variables for production
|
||||
export AIO_GITHUB_TOKEN=$(head -c -1 /aio-secrets/GITHUB_TOKEN 2>/dev/null || echo "MISSING_GITHUB_TOKEN")
|
||||
export AIO_PREVIEW_DEPLOYMENT_TOKEN=$(head -c -1 /aio-secrets/PREVIEW_DEPLOYMENT_TOKEN 2>/dev/null || echo "MISSING_PREVIEW_DEPLOYMENT_TOKEN")
|
||||
|
||||
# Start the upload-server instance
|
||||
action=$([ "$1" == "stop" ] && echo "stop" || echo "start")
|
||||
pm2 $action $AIO_SCRIPTS_JS_DIR/dist/lib/upload-server \
|
||||
--uid $AIO_WWW_USER \
|
||||
--log /var/log/aio/upload-server-prod.log \
|
||||
--name aio-upload-server-prod \
|
||||
${@:2}
|
@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -eu -o pipefail
|
||||
|
||||
# Set up env variables for testing
|
||||
export AIO_BUILDS_DIR=$TEST_AIO_BUILDS_DIR
|
||||
export AIO_DOMAIN_NAME=$TEST_AIO_DOMAIN_NAME
|
||||
export AIO_GITHUB_ORGANIZATION=$TEST_AIO_GITHUB_ORGANIZATION
|
||||
export AIO_GITHUB_TEAM_SLUGS=$TEST_AIO_GITHUB_TEAM_SLUGS
|
||||
export AIO_REPO_SLUG=$TEST_AIO_REPO_SLUG
|
||||
export AIO_TRUSTED_PR_LABEL=$TEST_AIO_TRUSTED_PR_LABEL
|
||||
export AIO_UPLOAD_HOSTNAME=$TEST_AIO_UPLOAD_HOSTNAME
|
||||
export AIO_UPLOAD_PORT=$TEST_AIO_UPLOAD_PORT
|
||||
|
||||
export AIO_GITHUB_TOKEN=$(head -c -1 /aio-secrets/TEST_GITHUB_TOKEN 2>/dev/null || echo "TEST_GITHUB_TOKEN")
|
||||
export AIO_PREVIEW_DEPLOYMENT_TOKEN=$(head -c -1 /aio-secrets/TEST_PREVIEW_DEPLOYMENT_TOKEN 2>/dev/null || echo "TEST_PREVIEW_DEPLOYMENT_TOKEN")
|
||||
|
||||
# Start the upload-server instance
|
||||
appName=aio-upload-server-test
|
||||
if [[ "$1" == "stop" ]]; then
|
||||
pm2 delete $appName
|
||||
else
|
||||
pm2 start $AIO_SCRIPTS_JS_DIR/dist/lib/verify-setup/start-test-upload-server.js \
|
||||
--uid $AIO_WWW_USER \
|
||||
--log /var/log/aio/upload-server-test.log \
|
||||
--name $appName \
|
||||
--no-autorestart \
|
||||
${@:2}
|
||||
fi
|
@ -0,0 +1,2 @@
|
||||
aio-verify-setup
|
||||
ls -t /var/log/aio/preview-server-verify* | head -1 | xargs cat
|
@ -2,7 +2,7 @@
|
||||
set -eu -o pipefail
|
||||
|
||||
logFile=/var/log/aio/verify-setup.log
|
||||
uploadServerLogFile=/var/log/aio/upload-server-verify-setup.log
|
||||
previewServerLogFile=/var/log/aio/preview-server-verify-setup.log
|
||||
|
||||
exec 3>&1
|
||||
exec >> $logFile
|
||||
@ -23,18 +23,22 @@ function countdown {
|
||||
}
|
||||
|
||||
function onExit {
|
||||
aio-upload-server-test stop
|
||||
echo -e "Stopping Test Server"
|
||||
aio-preview-server-test stop
|
||||
echo -e "Full logs in '$logFile'.\n" > /dev/fd/3
|
||||
}
|
||||
|
||||
# Setup EXIT trap
|
||||
trap 'onExit' EXIT
|
||||
|
||||
# Start an upload-server instance for testing
|
||||
aio-upload-server-test start --log $uploadServerLogFile
|
||||
# Start an preview-server instance for testing
|
||||
echo -e "Starting Test Server"
|
||||
aio-preview-server-test start --log $previewServerLogFile
|
||||
|
||||
# Give the upload-server some time to start :(
|
||||
# Give the preview-server some time to start :(
|
||||
countdown "Starting" 5 > /dev/fd/3
|
||||
|
||||
# Run the tests
|
||||
echo Running the tests
|
||||
source aio-test-env
|
||||
node $AIO_SCRIPTS_JS_DIR/dist/lib/verify-setup | tee /dev/fd/3
|
||||
|
@ -10,18 +10,26 @@ environment variables and their default values can be found in the
|
||||
Each variable has a `TEST_` prefixed counterpart, which is used for testing purposes. In most cases
|
||||
you don't need to specify values for those.
|
||||
|
||||
- `AIO_ARTIFACT_PATH`:
|
||||
The path used to identify the AIO build artifact on the CircleCI servers. This should be equal to
|
||||
the path given in the `.circleci/config.yml` file for the
|
||||
`aio_preview->steps->store_artifacts->destination` key.
|
||||
|
||||
- `AIO_BUILDS_DIR`:
|
||||
The directory (inside the container) where the uploaded build artifacts are kept.
|
||||
The directory (inside the container) where the hosted build artifacts are kept.
|
||||
|
||||
- `AIO_DOMAIN_NAME`:
|
||||
The domain name of the server.
|
||||
|
||||
- `AIO_GITHUB_ORGANIZATION`:
|
||||
The GitHub organization whose teams are whitelisted for accepting uploads.
|
||||
The GitHub organization whose teams are whitelisted for accepting build artifacts.
|
||||
See also `AIO_GITHUB_TEAM_SLUGS`.
|
||||
|
||||
- `AIO_GITHUB_REPO`:
|
||||
The Github repository for which PRs will be hosted.
|
||||
|
||||
- `AIO_GITHUB_TEAM_SLUGS`:
|
||||
A comma-separated list of teams, whose authors are allowed to upload PRs.
|
||||
A comma-separated list of teams, whose authors are allowed to preview PRs.
|
||||
See also `AIO_GITHUB_ORGANIZATION`.
|
||||
|
||||
- `AIO_NGINX_HOSTNAME`:
|
||||
@ -36,22 +44,24 @@ you don't need to specify values for those.
|
||||
The port number on which nginx listens for HTTPS connections. This should be mapped to the
|
||||
corresponding port on the host VM (as described [here](vm-setup--start-docker-container.md)).
|
||||
|
||||
- `AIO_REPO_SLUG`:
|
||||
The repository slug (in the form `<user>/<repo>`) for which PRs will be uploaded.
|
||||
- `AIO_SIGNIFICANT_FILES_PATTERN`:
|
||||
The RegExp that determines whether a changed file indicates that a new preview needs to
|
||||
be deployed. For example, if there is a changed file in the `/packages` directory then
|
||||
some of the API docs might have changed, so we need to create a new preview.
|
||||
|
||||
- `AIO_TRUSTED_PR_LABEL`:
|
||||
The PR whose presence indicates the PR has been manually verified and is allowed to have its
|
||||
build artifacts publicly served. This is useful for enabling previews for any PR (not only those
|
||||
from trusted authors).
|
||||
|
||||
- `AIO_UPLOAD_HOSTNAME`:
|
||||
The internal hostname for accessing the Node.js upload-server. This is used by nginx for
|
||||
delegating upload requests and also for performing a periodic health-check.
|
||||
- `AIO_PREVIEW_SERVER_HOSTNAME`:
|
||||
The internal hostname for accessing the Node.js preview-server. This is used by nginx for
|
||||
delegating web-hook requests and also for performing a periodic health-check.
|
||||
|
||||
- `AIO_UPLOAD_MAX_SIZE`:
|
||||
The maximum allowed size for the uploaded gzip archive containing the build artifacts. Files
|
||||
larger than this will be rejected.
|
||||
- `AIO_ARTIFACT_MAX_SIZE`:
|
||||
The maximum allowed size for the gzip archive containing the build artifacts.
|
||||
Files larger than this will be rejected.
|
||||
|
||||
- `AIO_UPLOAD_PORT`:
|
||||
The port number on which the Node.js upload-server listens for HTTP connections. This is used by
|
||||
nginx for delegating upload requests and also for performing a periodic health-check.
|
||||
- `AIO_PREVIEW_SERVER_PORT`:
|
||||
The port number on which the Node.js preview-server listens for HTTP connections. This is used by
|
||||
nginx for delegating web-hook requests and also for performing a periodic health-check.
|
||||
|
@ -5,8 +5,44 @@ TODO (gkalpak): Add docs. Mention:
|
||||
- `aio-health-check`
|
||||
- `aio-verify-setup`
|
||||
- Test nginx accessible at:
|
||||
- `http://$TEST_AIO_NGINX_HOTNAME:$TEST_AIO_NGINX_PORT_HTTP`
|
||||
- `https://$TEST_AIO_NGINX_HOTNAME:$TEST_AIO_NGINX_PORT_HTTPS`
|
||||
- Test upload-server accessible at:
|
||||
- `http://$TEST_AIO_UPLOAD_HOTNAME:$TEST_AIO_UPLOAD_PORT`
|
||||
- `http://$TEST_AIO_NGINX_HOSTNAME:$TEST_AIO_NGINX_PORT_HTTP`
|
||||
- `https://$TEST_AIO_NGINX_HOSTNAME:$TEST_AIO_NGINX_PORT_HTTPS`
|
||||
- Test preview-server accessible at:
|
||||
- `http://$TEST_AIO_PREVIEW_SERVER_HOSTNAME:$TEST_AIO_PREVIEW_SERVER_PORT`
|
||||
- Local DNS (via dnsmasq) maps the above hostnames to 127.0.0.1
|
||||
|
||||
|
||||
## Developing the preview server TypeScript files
|
||||
|
||||
If you are running Docker on OS/X then you can benefit from linking the built TypeScript
|
||||
files (i.e. `script-js/dist`) to the JavaScript files inside the Docker container.
|
||||
|
||||
First start watching and building the TypeScript files (in the host):
|
||||
|
||||
```bash
|
||||
yarn build-watch
|
||||
```
|
||||
|
||||
Now build, start and attach to the Docker container. See "Setting up the VM"
|
||||
section in [TOC](_TOC.md). Then link the JavaScript folders (in the container):
|
||||
|
||||
```bash
|
||||
aio-dev-mode
|
||||
```
|
||||
|
||||
Now whenever you make changes to the TypeScript, it will be automatically built
|
||||
in the host, and the changes are automatically available in the container.
|
||||
You can then run the unit tests (in the container):
|
||||
|
||||
```bash
|
||||
aio-verify-setup
|
||||
```
|
||||
|
||||
Sometimes, the errors in the unit test log are not enough to tell you what went wrong.
|
||||
In that case you can also look at the log of the preview-server itself.
|
||||
A helper script that runs the unit tests (i.e. `aio-verify-setup`) and displays the
|
||||
last relevant test-preview-server log is:
|
||||
|
||||
```bash
|
||||
aio-verify-setup-and-log
|
||||
```
|
||||
|
@ -2,10 +2,8 @@
|
||||
|
||||
|
||||
TODO (gkalpak): Add docs. Mention:
|
||||
- Travis' JWT addon (+ limitations).
|
||||
Relevant files: `.travis.yml`, `scripts/ci/env.sh`
|
||||
- Testing on CI.
|
||||
Relevant files: `scripts/ci/test-aio.sh`, `aio/aio-builds-setup/scripts/test.sh`
|
||||
- Deploying from CI.
|
||||
Relevant files: `scripts/ci/deploy.sh`, `aio/scripts/deploy-preview.sh`,
|
||||
Relevant files: `.circleci/config.yml`, `scripts/ci/deploy.sh`, `aio/scripts/build-artifacts.sh`,
|
||||
`aio/scripts/deploy-to-firebase.sh`
|
||||
|
@ -2,9 +2,10 @@
|
||||
|
||||
|
||||
## Objective
|
||||
Whenever a PR job is run on Travis, we want to build `angular.io` and upload the build artifacts to
|
||||
a publicly accessible server so that collaborators (developers, designers, authors, etc) can preview
|
||||
the changes without having to checkout and build the app locally.
|
||||
Whenever a PR job is run on the CI infrastructure (e.g. CircleCI), we want to build `angular.io`
|
||||
and host the build artifacts on a publicly accessible server so that collaborators (developers,
|
||||
designers, authors, etc) can preview the changes without having to checkout and build the app
|
||||
locally.
|
||||
|
||||
|
||||
## Source code
|
||||
@ -32,48 +33,35 @@ This section gives a brief summary of the several operations performed on CI and
|
||||
container:
|
||||
|
||||
|
||||
### On CI (Travis)
|
||||
- Build job completes successfully.
|
||||
- The CI script checks whether the build job was initiated by a PR against the angular/angular
|
||||
master branch.
|
||||
- The CI script checks whether the PR has touched any files that might affect the angular.io app
|
||||
(currently the `aio/` or `packages/` directories, ignoring spec files).
|
||||
- Optionally, the CI script can check whether the PR can be automatically verified (i.e. if the
|
||||
author of the PR is a member of one of the whitelisted GitHub teams or the PR has the specified
|
||||
"trusted PR" label).
|
||||
**Note:**
|
||||
For security reasons, the same checks will be performed on the server as well. This is an optional
|
||||
step that can be used in case one wants to apply special logic depending on the outcome of the
|
||||
pre-verification. For example:
|
||||
1. One might want to deploy automatically verified PRs only. In that case, the pre-verification
|
||||
helps avoid the wasted overhead associated with uploads that are going to be rejected (e.g.
|
||||
building the artifacts, sending them to the server, running checks on the server, detecting the
|
||||
reasons of deployment failure and whether to fail the build, etc).
|
||||
2. One might want to apply additional logic (e.g. different tests) depending on whether the PR is
|
||||
automatically verified or not).
|
||||
- The CI script gzips and uploads the build artifacts to the server.
|
||||
### On CI (CircleCI)
|
||||
- The CI script builds the angular.io project.
|
||||
- The CI script gzips and stores the build artifacts in the CI infrastructure.
|
||||
- When the build completes, CircleCI triggers a webhook on the preview-server.
|
||||
|
||||
More info on how to set things up on CI can be found [here](misc--integrate-with-ci.md).
|
||||
|
||||
|
||||
### Uploading build artifacts
|
||||
- nginx receives the upload request.
|
||||
- nginx checks that the uploaded gzip archive does not exceed the specified max file size, stores it
|
||||
in a temporary location and passes the filepath to the Node.js upload-server.
|
||||
- The upload-server runs several checks to determine whether the request should be accepted and
|
||||
whether it should be publicly accessible or stored for later verification (more details can be
|
||||
found [here](overview--security-model.md)).
|
||||
- The upload-server changes the "visibility" of the associated PR, if necessary. For example, if
|
||||
### Hosting build artifacts
|
||||
- nginx receives the webhook trigger and passes it through to the preview server.
|
||||
- The preview-server runs several preliminary checks to determine whether the request is valid and
|
||||
whether the corresponding PR can have a (public or non-public) preview (more details can be found
|
||||
[here](overview--security-model.md)).
|
||||
- The preview-server makes a request to CircleCI for the URL of the AIO build artifacts.
|
||||
- The preview-server makes a request to this URL to receive the artifact - failing if the size
|
||||
exceeds the specified max file size - and stores it in a temporary location.
|
||||
- The preview-server runs more checks to determine whether the preview should be publicly accessible
|
||||
or stored for later verification (more details can be found [here](overview--security-model.md)).
|
||||
- The preview-server changes the "visibility" of the associated PR, if necessary. For example, if
|
||||
builds for the same PR had been previously deployed as non-public and the current build has been
|
||||
automatically verified, all previous builds are made public as well.
|
||||
If the PR transitions from "non-public" to "public", the upload-server posts a comment on the
|
||||
If the PR transitions from "non-public" to "public", the preview-server posts a comment on the
|
||||
corresponding PR on GitHub mentioning the SHAs and the links where the previews can be found.
|
||||
- The upload-server verifies that the uploaded file is not trying to overwrite an existing build.
|
||||
- The upload-server deploys the artifacts to a sub-directory named after the PR number and the first
|
||||
few characters of the SHA: `<PR>/<SHA>/`
|
||||
- The preview-server verifies that it is not trying to overwrite an existing build.
|
||||
- The preview-server deploys the artifacts to a sub-directory named after the PR number and the
|
||||
first few characters of the SHA: `<PR>/<SHA>/`
|
||||
(Non-publicly accessible PRs will be stored in a different location, but again derived from the PR
|
||||
number and SHA.)
|
||||
- If the PR is publicly accessible, the upload-server posts a comment on the corresponding PR on
|
||||
- If the PR is publicly accessible, the preview-server posts a comment on the corresponding PR on
|
||||
GitHub mentioning the SHA and the link where the preview can be found.
|
||||
|
||||
More info on the possible HTTP status codes and their meaning can be found
|
||||
@ -82,24 +70,24 @@ More info on the possible HTTP status codes and their meaning can be found
|
||||
|
||||
### Updating PR visibility
|
||||
- nginx receives a natification that a PR has been updated and passes it through to the
|
||||
upload-server. This could, for example, be sent by a GitHub webhook every time a PR's labels
|
||||
preview-server. This could, for example, be sent by a GitHub webhook every time a PR's labels
|
||||
change.
|
||||
E.g.: `ngbuilds.io/pr-updated` (payload: `{"number":<PR>,"action":"labeled"}`)
|
||||
- The request contains the PR number (as `number`) and optionally the action that triggered the
|
||||
request (as `action`) in the payload.
|
||||
- The upload-server verifies the payload and determines whether the `action` (if specified) could
|
||||
- The preview-server verifies the payload and determines whether the `action` (if specified) could
|
||||
have led to PR visibility changes. Only requests that omit the `action` field altogether or
|
||||
specify an action that can affect visibility are further processed.
|
||||
(Currently, the only actions that are considered capable of affecting visibility are `labeled` and
|
||||
`unlabeled`.)
|
||||
- The upload-server re-checks and if necessary updates the PR's visibility.
|
||||
- The preview-server re-checks and if necessary updates the PR's visibility.
|
||||
|
||||
More info on the possible HTTP status codes and their meaning can be found
|
||||
[here](overview--http-status-codes.md).
|
||||
|
||||
|
||||
### Serving build artifacts
|
||||
- nginx receives a request for an uploaded resource on a subdomain corresponding to the PR and SHA.
|
||||
- nginx receives a request for a hosted preview resource on a subdomain corresponding to the PR and SHA.
|
||||
E.g.: `pr<PR>-<SHA>.ngbuilds.io/path/to/resource`
|
||||
- nginx maps the subdomain to the correct sub-directory and serves the resource.
|
||||
E.g.: `/<PR>/<SHA>/path/to/resource`
|
||||
@ -110,11 +98,11 @@ More info on the possible HTTP status codes and their meaning can be found
|
||||
|
||||
### Removing obsolete artifacts
|
||||
In order to avoid flooding the disk with unnecessary build artifacts, there is a cronjob that runs a
|
||||
clean-up tasks once a day. The task retrieves all open PRs from GitHub and removes all directories
|
||||
that do not correspond with an open PR.
|
||||
clean-up task once a day. The task retrieves all open PRs from GitHub and removes all directories
|
||||
that do not correspond to an open PR.
|
||||
|
||||
|
||||
### Health-check
|
||||
The docker service runs a periodic health-check that verifies the running conditions of the
|
||||
container. This includes verifying the status of specific system services, the responsiveness of
|
||||
nginx and the upload-server and internet connectivity.
|
||||
nginx and the preview-server and internet connectivity.
|
||||
|
@ -1,8 +1,8 @@
|
||||
# Overview - HTTP Status Codes
|
||||
|
||||
|
||||
This is a list of all the possible HTTP status codes returned by the nginx anf upload servers, along
|
||||
with a bried explanation of what they mean:
|
||||
This is a list of all the possible HTTP status codes returned by the nginx and preview servers,
|
||||
along with a brief explanation of what they mean:
|
||||
|
||||
|
||||
## `http://*.ngbuilds.io/*`
|
||||
@ -25,7 +25,24 @@ with a bried explanation of what they mean:
|
||||
File not found.
|
||||
|
||||
|
||||
## `https://ngbuilds.io/create-build/<pr>/<sha>`
|
||||
## `https://ngbuilds.io/can-have-public-preview/<pr>`
|
||||
|
||||
- **200 (OK)**:
|
||||
Whether the PR can have a public preview (based on its author, label, changed files).
|
||||
_Response type:_ JSON
|
||||
_Response format:_
|
||||
```ts
|
||||
{
|
||||
canHavePublicPreview: boolean,
|
||||
reason: string | null,
|
||||
}
|
||||
```
|
||||
|
||||
- **405 (Method Not Allowed)**:
|
||||
Request method other than GET.
|
||||
|
||||
|
||||
## `https://ngbuilds.io/circle-build`
|
||||
|
||||
- **201 (Created)**:
|
||||
Build deployed successfully and is publicly available.
|
||||
@ -33,14 +50,14 @@ with a bried explanation of what they mean:
|
||||
- **202 (Accepted)**:
|
||||
Build not automatically verifiable. Stored for later deployment (after re-verification).
|
||||
|
||||
- **400 (Bad Request)**:
|
||||
No payload.
|
||||
- **204 (No Content)**:
|
||||
Build was not successful, so no further action is being taken.
|
||||
|
||||
- **401 (Unauthorized)**:
|
||||
No `AUTHORIZATION` header.
|
||||
- **400 (Bad Request)**:
|
||||
Invalid payload.
|
||||
|
||||
- **403 (Forbidden)**:
|
||||
Unable to verify build (e.g. invalid JWT token, or unable to talk to 3rd-party APIs, etc).
|
||||
Unable to talk to 3rd-party APIs.
|
||||
|
||||
- **405 (Method Not Allowed)**:
|
||||
Request method other than POST.
|
||||
@ -49,9 +66,6 @@ with a bried explanation of what they mean:
|
||||
Request to overwrite existing (public or non-public) directory (e.g. deploy existing build or
|
||||
change PR visibility when the destination directory does already exist).
|
||||
|
||||
- **413 (Payload Too Large)**:
|
||||
Payload larger than size specified in `AIO_UPLOAD_MAX_SIZE`.
|
||||
|
||||
|
||||
## `https://ngbuilds.io/health-check`
|
||||
|
||||
|
@ -21,7 +21,7 @@ available:
|
||||
from a git repository. See [here](vm-setup--update-docker-container.md) for more info.
|
||||
|
||||
|
||||
## Commands
|
||||
## Production Commands
|
||||
The following commands are available globally from inside the docker container. They are either used
|
||||
by the container to perform its various operations or can be used ad-hoc, mainly for testing
|
||||
purposes. Each command is backed by a corresponding script inside
|
||||
@ -40,14 +40,27 @@ purposes. Each command is backed by a corresponding script inside
|
||||
Initializes the container (mainly by starting the necessary services).
|
||||
_It is run (by default) when starting the container._
|
||||
|
||||
- `aio-upload-server-prod`:
|
||||
Spins up a Node.js upload-server instance.
|
||||
- `aio-preview-server-prod`:
|
||||
Spins up a Node.js preview-server instance.
|
||||
_It is used in `aio-init` (see above) during initialization._
|
||||
|
||||
- `aio-upload-server-test`:
|
||||
Spins up a Node.js upload-server instance for tests.
|
||||
|
||||
## Developer Commands
|
||||
|
||||
- `aio-preview-server-test`:
|
||||
Spins up a Node.js preview-server instance for tests.
|
||||
_It is used in `aio-verify-setup` (see below) for running tests._
|
||||
|
||||
- `aio-verify-setup`:
|
||||
Runs a suite of e2e-like tests, mainly verifying the correct (inter)operation of nginx and the
|
||||
Node.js upload-server.
|
||||
Node.js preview-server.
|
||||
|
||||
- `aio-verify-setup-and-log`:
|
||||
Runs the `aio-verify-setup` command but also then dumps the logs from the preview server, which
|
||||
gives additional useful debugging information. See the [debugging docs](misc--debug-docker-container.md)
|
||||
for more info.
|
||||
|
||||
- `aio-dev-mode`:
|
||||
Links external source files (from the Docker host) to interal source files (in the Docker
|
||||
container). This makes it easier to use an IDE to edit files in the host that are then
|
||||
tested in the container. See the [debugging docs](misc--debug-docker-container.md) for more info.
|
@ -1,27 +1,27 @@
|
||||
# Overview - Security model
|
||||
|
||||
|
||||
Whenever a PR job is run on Travis, we want to build `angular.io` and upload the build artifacts to
|
||||
Whenever a PR job is run on CircleCI, we want to build `angular.io` and host the build artifacts on
|
||||
a publicly accessible server so that collaborators (developers, designers, authors, etc) can preview
|
||||
the changes without having to checkout and build the app locally.
|
||||
|
||||
This document discusses the security considerations associated with uploading build artifacts as
|
||||
part of the CI setup and serving them publicly.
|
||||
This document discusses the security considerations associated with moving build artifacts as
|
||||
part of the CI process and serving them publicly.
|
||||
|
||||
|
||||
## Security objectives
|
||||
|
||||
- **Prevent uploading arbitrary content to our servers.**
|
||||
Since there is no restriction on who can submit a PR, we cannot allow any PR's build artifacts to
|
||||
be uploaded.
|
||||
- **Prevent hosting arbitrary content on our servers.**
|
||||
Since there is no restriction on who can submit a PR, we cannot allow arbitrary, untrusted PRs'
|
||||
build artifacts to be hosted.
|
||||
|
||||
- **Prevent overwriting other peoples uploaded content.**
|
||||
There needs to be a mechanism in place to ensure that the uploaded content does indeed correspond
|
||||
- **Prevent overwriting other people's hosted build artifacts.**
|
||||
There needs to be a mechanism in place to ensure that the hosted content does indeed correspond
|
||||
to the PR indicated by its URL.
|
||||
|
||||
- **Prevent arbitrary access on the server.**
|
||||
Since the PR author has full access over the build artifacts that would be uploaded, we must
|
||||
ensure that the uploaded files will not enable arbitrary access to the server or expose sensitive
|
||||
Since the PR author has full access over the build artifacts that would be hosted, we must
|
||||
ensure that the build artifacts will not have arbitrary access to the server or expose sensitive
|
||||
info.
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ part of the CI setup and serving them publicly.
|
||||
- Because the PR author can change the scripts run on CI, any security mechanisms must be immune to
|
||||
such changes.
|
||||
|
||||
- For security reasons, encrypted Travis variables are not available to PRs, so we can't rely on
|
||||
- For security reasons, encrypted CircleCI variables are not available to PRs, so we can't rely on
|
||||
them to implement security.
|
||||
|
||||
|
||||
@ -40,41 +40,57 @@ part of the CI setup and serving them publicly.
|
||||
### In a nutshell
|
||||
The implemented approach can be broken up to the following sub-tasks:
|
||||
|
||||
1. Verify which PR the uploaded artifacts correspond to.
|
||||
2. Fetch the PR's metadata, including author and labels.
|
||||
3. Check whether the PR can be automatically verified as "trusted" (based on its author or labels).
|
||||
4. If necessary, update the corresponding PR's verification status.
|
||||
5. Deploy the artifacts to the corresponding PR's directory.
|
||||
6. Prevent overwriting previously deployed artifacts (which ensures that the guarantees established
|
||||
1. Receive notification from CircleCI of a completed build.
|
||||
2. Verify that the build is valid and can have a preview.
|
||||
3. Download the build artifact.
|
||||
4. Fetch the PR's metadata, including author and labels.
|
||||
5. Check whether the PR can be automatically verified as "trusted" (based on its author or labels).
|
||||
6. If necessary, update the corresponding PR's verification status.
|
||||
7. Deploy the artifacts to the corresponding PR's directory.
|
||||
8. Prevent overwriting previously deployed artifacts (which ensures that the guarantees established
|
||||
during deployment will remain valid until the artifacts are removed).
|
||||
7. Prevent uploaded files from accessing anything outside their directory.
|
||||
9. Prevent hosted preview files from accessing anything outside their directory.
|
||||
|
||||
|
||||
### Implementation details
|
||||
This section describes how each of the aforementioned sub-tasks is accomplished:
|
||||
|
||||
1. **Verify which PR the uploaded artifacts correspond to.**
|
||||
1. **Receive notification from CircleCI of a completed build**
|
||||
|
||||
We are taking advantage of Travis' [JWT addon](https://docs.travis-ci.com/user/jwt). By sharing
|
||||
a secret between Travis (which keeps it private but uses it to sign a JWT) and the server (which
|
||||
uses it to verify the authenticity of the JWT), we can accomplish the following:
|
||||
a. Verify that the upload request comes from Travis.
|
||||
b. Determine the PR that these artifacts correspond to (since Travis puts that information into
|
||||
the JWT, without the PR author being able to modify it).
|
||||
CircleCI is configured to trigger a webhook on our preview-server whenever a build completes.
|
||||
The payload contains the number of the build that completed.
|
||||
|
||||
_Note:_
|
||||
_There are currently certain limitation in the implementation of the JWT addon._
|
||||
_See the next section for more details._
|
||||
2. **Verify that the build is valid and can have a preview.**
|
||||
|
||||
2. **Fetch the PR's metadata, including author and labels**.
|
||||
We cannot trust that the data in the webhook trigger is authentic, so we only extract the build
|
||||
number and then run a direct query against the CircleCI API to get hold of the real data for
|
||||
the given build number.
|
||||
|
||||
Once we have securely associated the uploaded artifacts to a PR, we retrieve the PR's metadata -
|
||||
We perform a number of preliminary checks:
|
||||
- Was the webhook triggered by the designated CircleCI job (currently `aio_preview`)?
|
||||
- Was the build successful?
|
||||
- Are the associated GitHub organisation and repository what we expect (e.g. `angular/angular`)?
|
||||
- Has the PR touched any files that might affect the angular.io app (currently the `aio/` or
|
||||
`packages/` directories, ignoring spec files)?
|
||||
|
||||
If any of the preliminary checks fails, the process is aborted and not preview is generated.
|
||||
|
||||
3. **Download the build artifact.**
|
||||
|
||||
Next we make another call to the CircleCI API to get a list of the URLs for artifacts of that
|
||||
build. If there is one that matches the configured artifact path, we download the contents of the
|
||||
build artifact and store it in a local folder. This download has a maximum size limit to prevent
|
||||
PRs from producing artifacts that are so large they would cause the preview server to crash.
|
||||
|
||||
4. **Fetch the PR's metadata, including author and labels**.
|
||||
|
||||
Once we have securely downloaded the artifact for a build, we retrieve the PR's metadata -
|
||||
including the author's username and the labels - using the
|
||||
[GitHub API](https://developer.github.com/v3/).
|
||||
To avoid rate-limit restrictions, we use a Personal Access Token (issued by
|
||||
[@mary-poppins](https://github.com/mary-poppins)).
|
||||
|
||||
3. **Check whether the PR can be automatically verified as "trusted"**.
|
||||
5. **Check whether the PR can be automatically verified as "trusted"**.
|
||||
|
||||
"Trusted" means that we are confident that the build artifacts are suitable for being deployed
|
||||
and publicly accessible on the preview server. There are two ways to check that:
|
||||
@ -86,53 +102,48 @@ This section describes how each of the aforementioned sub-tasks is accomplished:
|
||||
`read:org` scope issued by a user that can "see" the specified GitHub organization.
|
||||
Here too, we use the token by @mary-poppins.
|
||||
|
||||
4. **If necessary update the corresponding PR's verification status**.
|
||||
6. **If necessary update the corresponding PR's verification status**.
|
||||
|
||||
Once we have determined whether the PR is considered "trusted", we update its "visibility" (i.e.
|
||||
whether it is publicly accessible or not), based on the new verification status. For example, if
|
||||
a PR was initially considered "not trusted" but the check triggered by a new build determined
|
||||
otherwise, the PR (and all the previously uploaded previews) are made public. It works the same
|
||||
otherwise, the PR (and all the previously downloaded previews) are made public. It works the same
|
||||
way if a PR has gone from "trusted" to "not trusted".
|
||||
|
||||
5. **Deploy the artifacts to the corresponding PR's directory.**
|
||||
7. **Deploy the artifacts to the corresponding PR's directory.**
|
||||
|
||||
With the preceding steps, we have verified that the uploaded artifacts have been uploaded by
|
||||
Travis. Additionally, we have determined whether the PR can be trusted to have its previews
|
||||
publicly accessible or whether further verification is necessary. The artifacts will be stored to
|
||||
the PR's directory, but will not be publicly accessible unless the PR has been verified.
|
||||
Essentially, as long as sub-tasks 1, 2 and 3 can be securely accomplished, it is possible to
|
||||
"project" the trust we have in a team's members through the PR and Travis to the build artifacts.
|
||||
With the preceding steps, we have verified that the build artifacts are valid. Additionally, we
|
||||
have determined whether the PR can be trusted to have its previews publicly accessible or whether
|
||||
further verification is necessary.
|
||||
|
||||
6. **Prevent overwriting previously deployed artifacts**.
|
||||
The artifacts will be stored to the PR's directory, but will not be publicly accessible unless
|
||||
the PR has been verified. Essentially, as long as sub-tasks 2, 3, 4 and 5 can be securely
|
||||
accomplished, it is possible to "project" the trust we have in a team's members through the PR to
|
||||
the build artifacts.
|
||||
|
||||
8. **Prevent overwriting previously deployed artifacts**.
|
||||
|
||||
In order to enforce this restriction (and ensure that the deployed artifacts' validity is
|
||||
preserved throughout their "lifetime"), the server that handles the upload (currently a Node.js
|
||||
Express server) rejects uploads that target an existing directory.
|
||||
_Note: A PR can contain multiple uploads; one for each SHA that was built on Travis._
|
||||
preserved throughout their "lifetime"), the server that handles the artifacts (currently a Node.js Express server) rejects builds that have already been handled.
|
||||
_Note: A PR can contain multiple builds; one for each SHA that was built on CircleCI._
|
||||
|
||||
7. **Prevent uploaded files from accessing anything outside their directory.**
|
||||
9. **Prevent hosted preview files from accessing anything outside their directory.**
|
||||
|
||||
Nginx (which is used to serve the uploaded artifacts) has been configured to not follow symlinks
|
||||
outside of the directory where the build artifacts are stored.
|
||||
Nginx (which is used to serve the hosted preview) has been configured to not follow symlinks
|
||||
outside of the directory where the preview files are stored.
|
||||
|
||||
|
||||
## Assumptions / Things to keep in mind
|
||||
|
||||
- Each trusted PR author has full control over the content that is uploaded for their PRs. Part of
|
||||
the security model relies on the trustworthiness of these authors.
|
||||
- Other than the initial webhook trigger, which provides a build number, all requests for data come
|
||||
from the preview-server making requests to well defined API endpoints (e.g. CircleCI and Github).
|
||||
This means that any secret access keys need only be stored on the preview-server and not on any of
|
||||
the CI build infrastructure (e.g. CircleCI).
|
||||
|
||||
- Adding the specified label on a PR and marking it as trusted, gives the author full control over
|
||||
the content that is uploaded for the specific PR (e.g. by pushing more commits to it). The user
|
||||
- Each trusted PR author has full control over the content that is hosted as a preview for their
|
||||
PRs. Part of the security model relies on the trustworthiness of these authors.
|
||||
|
||||
- Adding the specified label on a PR to mark it as trusted, gives the author full control over the
|
||||
content that is hosted for the specific PR preview (e.g. by pushing more commits to it). The user
|
||||
adding the label is responsible for ensuring that this control is not abused and that the PR is
|
||||
either closed (one way of another) or the access is revoked.
|
||||
|
||||
- If anyone gets access to the `PREVIEW_DEPLOYMENT_TOKEN` (a.k.a. `NGBUILDS_IO_KEY` on
|
||||
angular/angular) variable generated for each Travis job, they will be able to impersonate the
|
||||
corresponding PR's author on the preview server for as long as the token is valid (currently 90
|
||||
mins). Because of this, the value of the `PREVIEW_DEPLOYMENT_TOKEN` should not be made publicly
|
||||
accessible (e.g. by printing it on the Travis job log).
|
||||
|
||||
- Travis does only allow specific whitelisted property names to be used with the JWT addon. The only
|
||||
known such property at the time is `SAUCE_ACCESS_KEY` (used for integration with SauceLabs). In
|
||||
order to be able to actually use the JWT addon we had to name the encrypted variable
|
||||
`SAUCE_ACCESS_KEY` (which we later re-assign to `NGBUILDS_IO_KEY`).
|
||||
|
@ -1,6 +1,12 @@
|
||||
# VM setup - Create docker image
|
||||
|
||||
|
||||
## Install node and yarn
|
||||
- Install [nvm](https://github.com/creationix/nvm#installation).
|
||||
- Install node.js: `nvm install 8`
|
||||
- Install yarn: `npm -g install yarn`
|
||||
|
||||
|
||||
## Checkout repository
|
||||
- `git clone <repo-url>`
|
||||
|
||||
@ -21,7 +27,7 @@ The following commands would create a docker image from GitHub repo `foo/bar` to
|
||||
- `git clone https://github.com/foo/bar.git foobar`
|
||||
- Run:
|
||||
```
|
||||
./foobar/aio-builds-setup/scripts/build.sh foobar-builds \
|
||||
./foobar/aio-builds-setup/scripts/create-image.sh foobar-builds \
|
||||
--build-arg AIO_REPO_SLUG=foo/bar \
|
||||
--build-arg AIO_DOMAIN_NAME=foobar-builds.io \
|
||||
--build-arg AIO_GITHUB_ORGANIZATION=foo \
|
||||
|
@ -12,8 +12,8 @@ More info on how to create `secrets` directory and files can be found
|
||||
|
||||
|
||||
## Create directory for build artifacts
|
||||
The uploaded build artifacts should be kept on a directory outside the docker container, so it is
|
||||
easier to replace the container without losing the uploaded builds. For portability across VMs a
|
||||
The build artifacts should be kept on a directory outside the docker container, so it is
|
||||
easier to replace the container without losing the builds. For portability across VMs a
|
||||
persistent disk can be used (as described [here](vm-setup--attach-persistent-disk.md)).
|
||||
|
||||
**Note:** The directories created inside that directory will be owned by user `www-data`.
|
||||
@ -21,7 +21,7 @@ persistent disk can be used (as described [here](vm-setup--attach-persistent-dis
|
||||
|
||||
## Create SSL certificates (Optional for dev)
|
||||
The host VM can attach a directory containing the SSL certificate and key to be used by the nginx
|
||||
server for serving the uploaded build artifacts. More info on how to attach the directory when
|
||||
server for serving the hosted previews. More info on how to attach the directory when
|
||||
starting the container can be found [here](vm-setup--start-docker-container.md).
|
||||
|
||||
In order for the container to be able to find the certificate and key, they should be named
|
||||
@ -61,15 +61,15 @@ The following log files are kept in this directory:
|
||||
used when running tests locally from inside the container, e.g. with the `aio-verify-setup`
|
||||
command. (See [here](overview--scripts-and-commands.md) for more info.)
|
||||
|
||||
- `upload-server-{prod,test,verify-setup}-*.log`:
|
||||
The logs produced by the Node.js upload-server while serving either:
|
||||
- `preview-server-{prod,test,verify-setup}-*.log`:
|
||||
The logs produced by the Node.js preview-server while serving either:
|
||||
- `-prod`: "Production" files (g.g during normal operation).
|
||||
- `-test`: "Test" files (e.g. when a test instance is started with the `aio-upload-server-test`
|
||||
- `-test`: "Test" files (e.g. when a test instance is started with the `aio-preview-server-test`
|
||||
command).
|
||||
- `-verify-setup`: "Test" files, but while running `aio-verify-setup`.
|
||||
|
||||
(See [here](overview--scripts-and-commands.md) for more info the commands mentioned above.)
|
||||
|
||||
- `verify-setup.log`:
|
||||
The output of the `aio-verify-setup` command (e.g. Jasmine output), except for upload-server
|
||||
output which is logged to `upload-server-verify-setup-*.log` (see above).
|
||||
The output of the `aio-verify-setup` command (e.g. Jasmine output), except for preview-server
|
||||
output which is logged to `preview-server-verify-setup-*.log` (see above).
|
||||
|
@ -8,18 +8,14 @@ Necessary secrets:
|
||||
1. `GITHUB_TOKEN`
|
||||
- Used for:
|
||||
- Retrieving open PRs without rate-limiting.
|
||||
- Retrieving PR author.
|
||||
- Retrieving PR info, such as author, labels, changed files.
|
||||
- Retrieving members of the trusted GitHub teams.
|
||||
- Posting comments with preview links on PRs.
|
||||
|
||||
2. `PREVIEW_DEPLOYMENT_TOKEN`
|
||||
2. `CIRCLE_CI_TOKEN`
|
||||
- Used for:
|
||||
- Decoding the JWT tokens received with `/create-build` requests.
|
||||
|
||||
**Note:**
|
||||
`TEST_GITHUB_TOKEN` and `TEST_PREVIEW_DEPLOYMENT_TOKEN` can also be created similar to their
|
||||
non-TEST counterparts and they will be loaded when running `aio-verify-setup`, but it is currently
|
||||
not clear if/how they can be used in tests.
|
||||
- Retrieving build information.
|
||||
- Downloading build artifacts.
|
||||
|
||||
|
||||
## Create secrets
|
||||
@ -28,18 +24,9 @@ not clear if/how they can be used in tests.
|
||||
- Visit https://github.com/settings/tokens.
|
||||
- Generate new token with the `public_repo` scope.
|
||||
|
||||
2. `PREVIEW_DEPLOYMENT_TOKEN`
|
||||
- Just generate a hard-to-guess character sequence.
|
||||
- Add it to `.travis.yml` under `addons -> jwt -> secure`.
|
||||
Can be added automatically with: `travis encrypt --add addons.jwt PREVIEW_DEPLOYMENT_TOKEN=<access-key>`
|
||||
|
||||
**Note:**
|
||||
Due to [travis-ci/travis-ci#7223](https://github.com/travis-ci/travis-ci/issues/7223) it is not
|
||||
currently possible to use the JWT addon (as described above) for anything other than the
|
||||
`SAUCE_ACCESS_KEY` variable. You can get creative, though...
|
||||
|
||||
**WARNING**
|
||||
TO avoid arbitrary uploads, make sure the `PREVIEW_DEPLOYMENT_TOKEN` is NOT printed in the Travis log.
|
||||
2. `CIRCLE_CI_TOKEN`
|
||||
- Visit https://circleci.com/gh/angular/angular/edit#api.
|
||||
- Create an API token with `Build Artifacts` scope.
|
||||
|
||||
|
||||
## Save secrets on the VM
|
||||
@ -47,6 +34,6 @@ TO avoid arbitrary uploads, make sure the `PREVIEW_DEPLOYMENT_TOKEN` is NOT prin
|
||||
- `sudo mkdir /aio-secrets`
|
||||
- `sudo touch /aio-secrets/GITHUB_TOKEN`
|
||||
- Insert `<github-token>` into `/aio-secrets/GITHUB_TOKEN`.
|
||||
- `sudo touch /aio-secrets/PREVIEW_DEPLOYMENT_TOKEN`
|
||||
- Insert `<access-token>` into `/aio-secrets/PREVIEW_DEPLOYMENT_TOKEN`.
|
||||
- `sudo touch /aio-secrets/CIRCLE_CI_TOKEN`
|
||||
- Insert `<access-token>` into `/aio-secrets/CIRCLE_CI_TOKEN`.
|
||||
- `sudo chmod 400 /aio-secrets/*`
|
||||
|
@ -13,14 +13,15 @@ sudo docker run \
|
||||
--publish 80:80 \
|
||||
--publish 443:443 \
|
||||
--restart unless-stopped \
|
||||
[--volume <host-cert-dir>:/etc/ssl/localcerts:ro] \
|
||||
--volume <host-secrets-dir>:/aio-secrets:ro \
|
||||
--volume <host-builds-dir>:/var/www/aio-builds \
|
||||
[--volume <host-cert-dir>:/etc/ssl/localcerts:ro] \
|
||||
[--volume <host-logs-dir>:/var/log/aio] \
|
||||
[--volume <host-dockerbuild-dir>:/dockerbuild] \
|
||||
<name>[:<tag>]
|
||||
```
|
||||
|
||||
Below is the same command with inline comments explaining each option. The aPI docs for `docker run`
|
||||
Below is the same command with inline comments explaining each option. The API docs for `docker run`
|
||||
can be found [here](https://docs.docker.com/engine/reference/run/).
|
||||
|
||||
```
|
||||
@ -30,7 +31,7 @@ sudo docker run \
|
||||
--detach \
|
||||
|
||||
# Use the local DNS server.
|
||||
# (This is necessary for mapping internal URLs, e.g. for the Node.js upload-server.)
|
||||
# (This is necessary for mapping internal URLs, e.g. for the Node.js preview-server.)
|
||||
--dns 127.0.0.1 \
|
||||
|
||||
# USe `<instance-name>` as an alias for the container.
|
||||
@ -45,28 +46,32 @@ sudo docker run \
|
||||
# (This ensures that the container will be automatically started on boot.)
|
||||
--restart unless-stopped \
|
||||
|
||||
# The directory the contains the SSL certificates.
|
||||
# (See [here](vm-setup--create-host-dirs-and-files.md) for more info.)
|
||||
# If not provided, the container will use self-signed certificates.
|
||||
[--volume <host-cert-dir>:/etc/ssl/localcerts:ro] \
|
||||
|
||||
# The directory the contains the secrets (e.g. GitHub token, JWT secret, etc).
|
||||
# (See [here](vm-setup--set-up-secrets.md) for more info.)
|
||||
--volume <host-secrets-dir>:/aio-secrets:ro \
|
||||
|
||||
# The uploaded build artifacts will stored to and served from this directory.
|
||||
# The build artifacts and hosted previews will stored to and served from this directory.
|
||||
# (If you are using a persistent disk - as described [here](vm-setup--attach-persistent-disk.md) -
|
||||
# this will be a directory inside the disk.)
|
||||
--volume <host-builds-dir>:/var/www/aio-builds \
|
||||
|
||||
# The directory the contains the SSL certificates.
|
||||
# (See [here](vm-setup--create-host-dirs-and-files.md) for more info.)
|
||||
# If not provided, the container will use self-signed certificates.
|
||||
[--volume <host-cert-dir>:/etc/ssl/localcerts:ro] \
|
||||
|
||||
# The directory where the logs are being kept.
|
||||
# (See [here](vm-setup--create-host-dirs-and-files.md) for more info.)
|
||||
# If not provided, the logs will be kept inside the container, which means they will be lost
|
||||
# whenever a new container is created.
|
||||
[--volume <host-logs-dir>:/var/log/aio] \
|
||||
|
||||
# This directory allows you to share the source scripts between the host and the container when
|
||||
# debugging. (See [here](misc--debug-docker-container.md) for how to set this up.)
|
||||
[--volume <host-dockerbuild-dir>:/dockerbuild] \
|
||||
|
||||
# The name of the docker image to use (and an optional tag; defaults to `latest`).
|
||||
# (See [here](vm-setup--create-docker-image.md) for instructions on how to create the iamge.)
|
||||
# (See [here](vm-setup--create-docker-image.md) for instructions on how to create the image.)
|
||||
<name>[:<tag>]
|
||||
```
|
||||
|
||||
@ -74,7 +79,8 @@ sudo docker run \
|
||||
## Example
|
||||
The following command would start a docker container based on the previously created `foobar-builds`
|
||||
docker image, alias it as 'foobar-builds-1' and map predefined directories on the host VM to be used
|
||||
by the container for accessing secrets and SSL certificates and keeping the build artifacts and logs.
|
||||
by the container for accessing secrets and SSL certificates and keeping the build artifacts and logs;
|
||||
and will map the source scripts from the host to the container.
|
||||
|
||||
```
|
||||
sudo docker run \
|
||||
@ -84,9 +90,10 @@ sudo docker run \
|
||||
--publish 80:80 \
|
||||
--publish 443:443 \
|
||||
--restart unless-stopped \
|
||||
--volume /etc/ssl/localcerts:/etc/ssl/localcerts:ro \
|
||||
--volume /foobar-secrets:/aio-secrets:ro \
|
||||
--volume /mnt/disks/foobar-builds:/var/www/aio-builds \
|
||||
--volume /etc/ssl/localcerts:/etc/ssl/localcerts:ro \
|
||||
--volume /foobar-logs:/var/log/aio \
|
||||
--volume ~/angular/aio/aio-builds-setup/dockerbuild:/dockerbuild \
|
||||
foobar-builds
|
||||
```
|
||||
|
@ -33,7 +33,6 @@
|
||||
"src/assets",
|
||||
"src/generated",
|
||||
"src/app/search/search-worker.js",
|
||||
"src/favicon.ico",
|
||||
"src/pwa-manifest.json",
|
||||
"src/google385281288605d160.html",
|
||||
{
|
||||
@ -62,7 +61,8 @@
|
||||
"src": "src/environments/environment.ts",
|
||||
"replaceWith": "src/environments/environment.next.ts"
|
||||
}
|
||||
]
|
||||
],
|
||||
"serviceWorker": true
|
||||
},
|
||||
"stable": {
|
||||
"fileReplacements": [
|
||||
@ -70,7 +70,8 @@
|
||||
"src": "src/environments/environment.ts",
|
||||
"replaceWith": "src/environments/environment.stable.ts"
|
||||
}
|
||||
]
|
||||
],
|
||||
"serviceWorker": true
|
||||
},
|
||||
"archive": {
|
||||
"fileReplacements": [
|
||||
@ -78,7 +79,8 @@
|
||||
"src": "src/environments/environment.ts",
|
||||
"replaceWith": "src/environments/environment.archive.ts"
|
||||
}
|
||||
]
|
||||
],
|
||||
"serviceWorker": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -123,7 +125,6 @@
|
||||
"src/assets",
|
||||
"src/generated",
|
||||
"src/app/search/search-worker.js",
|
||||
"src/favicon.ico",
|
||||
"src/pwa-manifest.json",
|
||||
"src/google385281288605d160.html",
|
||||
{
|
||||
|
@ -1,351 +1,259 @@
|
||||
'use strict'; // necessary for es6 output in node
|
||||
|
||||
import { browser, element, by, ElementFinder } from 'protractor';
|
||||
import { logging, promise } from 'selenium-webdriver';
|
||||
import { browser } from 'protractor';
|
||||
import { logging } from 'selenium-webdriver';
|
||||
import * as openClose from './open-close.po';
|
||||
import * as statusSlider from './status-slider.po';
|
||||
import * as toggle from './toggle.po';
|
||||
import * as enterLeave from './enter-leave.po';
|
||||
import * as auto from './auto.po';
|
||||
import * as filterStagger from './filter-stagger.po';
|
||||
import * as heroGroups from './hero-groups';
|
||||
import { getLinkById, sleepFor } from './util';
|
||||
|
||||
/**
|
||||
* The tests here basically just checking that the end styles
|
||||
* of each animation are in effect.
|
||||
*
|
||||
* Relies on the Angular testability only becoming stable once
|
||||
* animation(s) have finished.
|
||||
*
|
||||
* Ideally we'd use https://developer.mozilla.org/en-US/docs/Web/API/Document/getAnimations
|
||||
* but they're not supported in Chrome at the moment. The upcoming nganimate polyfill
|
||||
* may also add some introspection support.
|
||||
*/
|
||||
describe('Animation Tests', () => {
|
||||
const openCloseHref = getLinkById('open-close');
|
||||
const statusSliderHref = getLinkById('status');
|
||||
const toggleHref = getLinkById('toggle');
|
||||
const enterLeaveHref = getLinkById('enter-leave');
|
||||
const autoHref = getLinkById('auto');
|
||||
const filterHref = getLinkById('heroes');
|
||||
const heroGroupsHref = getLinkById('hero-groups');
|
||||
|
||||
const INACTIVE_COLOR = 'rgba(238, 238, 238, 1)';
|
||||
const ACTIVE_COLOR = 'rgba(207, 216, 220, 1)';
|
||||
const NO_TRANSFORM_MATRIX_REGEX = /matrix\(1,\s*0,\s*0,\s*1,\s*0,\s*0\)/;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeAll(() => {
|
||||
browser.get('');
|
||||
});
|
||||
|
||||
describe('basic states', () => {
|
||||
describe('Open/Close Component', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(() => {
|
||||
host = element(by.css('app-hero-list-basic'));
|
||||
beforeAll(async () => {
|
||||
await openCloseHref.click();
|
||||
sleepFor();
|
||||
});
|
||||
|
||||
it('animates between active and inactive', () => {
|
||||
addInactiveHero();
|
||||
it('should be open', async () => {
|
||||
let text = await openClose.getComponentText();
|
||||
const toggleButton = openClose.getToggleButton();
|
||||
const container = openClose.getComponentContainer();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
if (text.includes('Closed')) {
|
||||
await toggleButton.click();
|
||||
sleepFor();
|
||||
}
|
||||
|
||||
expect(getScaleX(li)).toBe(1.0);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(INACTIVE_COLOR);
|
||||
text = await openClose.getComponentText();
|
||||
const containerHeight = await container.getCssValue('height');
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.1);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(ACTIVE_COLOR);
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.0);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(INACTIVE_COLOR);
|
||||
expect(text).toContain('The box is now Open!');
|
||||
expect(containerHeight).toBe('200px');
|
||||
});
|
||||
|
||||
it('should be closed', async () => {
|
||||
let text = await openClose.getComponentText();
|
||||
const toggleButton = openClose.getToggleButton();
|
||||
const container = openClose.getComponentContainer();
|
||||
|
||||
if (text.includes('Open')) {
|
||||
await toggleButton.click();
|
||||
sleepFor();
|
||||
}
|
||||
|
||||
text = await openClose.getComponentText();
|
||||
const containerHeight = await container.getCssValue('height');
|
||||
|
||||
expect(text).toContain('The box is now Closed!');
|
||||
expect(containerHeight).toBe('100px');
|
||||
});
|
||||
|
||||
describe('styles inline in transitions', () => {
|
||||
it('should log animation events', async () => {
|
||||
const toggleButton = openClose.getToggleButton();
|
||||
const loggingCheckbox = openClose.getLoggingCheckbox();
|
||||
await loggingCheckbox.click();
|
||||
await toggleButton.click();
|
||||
|
||||
let host: ElementFinder;
|
||||
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
|
||||
|
||||
beforeEach(function() {
|
||||
host = element(by.css('app-hero-list-inline-styles'));
|
||||
});
|
||||
|
||||
it('are not kept after animation', () => {
|
||||
addInactiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.0);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(INACTIVE_COLOR);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('combined transition syntax', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(() => {
|
||||
host = element(by.css('app-hero-list-combined-transitions'));
|
||||
});
|
||||
|
||||
it('animates between active and inactive', () => {
|
||||
addInactiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
|
||||
expect(getScaleX(li)).toBe(1.0);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(INACTIVE_COLOR);
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.1);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(ACTIVE_COLOR);
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.0);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(INACTIVE_COLOR);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('two-way transition syntax', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(() => {
|
||||
host = element(by.css('app-hero-list-twoway'));
|
||||
});
|
||||
|
||||
it('animates between active and inactive', () => {
|
||||
addInactiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
|
||||
expect(getScaleX(li)).toBe(1.0);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(INACTIVE_COLOR);
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.1);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(ACTIVE_COLOR);
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.0);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(INACTIVE_COLOR);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('enter & leave', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(() => {
|
||||
host = element(by.css('app-hero-list-enter-leave'));
|
||||
});
|
||||
|
||||
it('adds and removes element', () => {
|
||||
addInactiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
expect(li.getCssValue('transform')).toMatch(NO_TRANSFORM_MATRIX_REGEX);
|
||||
|
||||
removeHero();
|
||||
expect(li.isPresent()).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('enter & leave & states', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(function() {
|
||||
host = element(by.css('app-hero-list-enter-leave-states'));
|
||||
});
|
||||
|
||||
it('adds and removes and animates between active and inactive', () => {
|
||||
addInactiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
|
||||
expect(li.getCssValue('transform')).toMatch(NO_TRANSFORM_MATRIX_REGEX);
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.1);
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(li.getCssValue('transform')).toMatch(NO_TRANSFORM_MATRIX_REGEX);
|
||||
|
||||
removeHero();
|
||||
expect(li.isPresent()).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('auto style calc', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(function() {
|
||||
host = element(by.css('app-hero-list-auto'));
|
||||
});
|
||||
|
||||
it('adds and removes element', () => {
|
||||
addInactiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
expect(li.getCssValue('height')).toBe('50px');
|
||||
|
||||
removeHero();
|
||||
expect(li.isPresent()).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('different timings', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(() => {
|
||||
host = element(by.css('app-hero-list-timings'));
|
||||
});
|
||||
|
||||
it('adds and removes element', () => {
|
||||
addInactiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
expect(li.getCssValue('transform')).toMatch(NO_TRANSFORM_MATRIX_REGEX);
|
||||
expect(li.getCssValue('opacity')).toMatch('1');
|
||||
|
||||
removeHero();
|
||||
expect(li.isPresent()).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('multiple keyframes', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(() => {
|
||||
host = element(by.css('app-hero-list-multistep'));
|
||||
});
|
||||
|
||||
it('adds and removes element', () => {
|
||||
addInactiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
expect(li.getCssValue('transform')).toMatch(NO_TRANSFORM_MATRIX_REGEX);
|
||||
expect(li.getCssValue('opacity')).toMatch('1');
|
||||
|
||||
removeHero();
|
||||
expect(li.isPresent()).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('parallel groups', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(() => {
|
||||
host = element(by.css('app-hero-list-groups'));
|
||||
});
|
||||
|
||||
it('adds and removes element', () => {
|
||||
addInactiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
expect(li.getCssValue('transform')).toMatch(NO_TRANSFORM_MATRIX_REGEX);
|
||||
expect(li.getCssValue('opacity')).toMatch('1');
|
||||
|
||||
removeHero(700);
|
||||
expect(li.isPresent()).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('adding active heroes', () => {
|
||||
|
||||
let host: ElementFinder;
|
||||
|
||||
beforeEach(() => {
|
||||
host = element(by.css('app-hero-list-basic'));
|
||||
});
|
||||
|
||||
it('animates between active and inactive', () => {
|
||||
addActiveHero();
|
||||
|
||||
let li = host.element(by.css('li'));
|
||||
|
||||
expect(getScaleX(li)).toBe(1.1);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(ACTIVE_COLOR);
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.0);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(INACTIVE_COLOR);
|
||||
|
||||
li.click();
|
||||
browser.driver.sleep(300);
|
||||
expect(getScaleX(li)).toBe(1.1);
|
||||
expect(li.getCssValue('backgroundColor')).toBe(ACTIVE_COLOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('fires a callback on start and done', () => {
|
||||
addActiveHero();
|
||||
browser.manage().logs().get(logging.Type.BROWSER)
|
||||
.then((logs: logging.Entry[]) => {
|
||||
const animationMessages = logs.filter((log) => {
|
||||
return log.message.indexOf('Animation') !== -1 ? true : false;
|
||||
});
|
||||
const animationMessages = logs.filter(({ message }) => message.indexOf('Animation') !== -1 ? true : false);
|
||||
|
||||
expect(animationMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Slider Component', () => {
|
||||
const activeColor = 'rgba(255, 165, 0, 1)';
|
||||
const inactiveColor = 'rgba(0, 0, 255, 1)';
|
||||
|
||||
beforeAll(async () => {
|
||||
await statusSliderHref.click();
|
||||
sleepFor(2000);
|
||||
});
|
||||
|
||||
function addActiveHero(sleep?: number) {
|
||||
sleep = sleep || 500;
|
||||
element(by.buttonText('Add active hero')).click();
|
||||
browser.driver.sleep(sleep);
|
||||
it('should be inactive with an orange background', async () => {
|
||||
let text = await statusSlider.getComponentText();
|
||||
const toggleButton = statusSlider.getToggleButton();
|
||||
const container = statusSlider.getComponentContainer();
|
||||
|
||||
if (text === 'Active') {
|
||||
await toggleButton.click();
|
||||
sleepFor(2000);
|
||||
}
|
||||
|
||||
function addInactiveHero(sleep?: number) {
|
||||
sleep = sleep || 500;
|
||||
element(by.buttonText('Add inactive hero')).click();
|
||||
browser.driver.sleep(sleep);
|
||||
}
|
||||
text = await statusSlider.getComponentText();
|
||||
const bgColor = await container.getCssValue('backgroundColor');
|
||||
|
||||
function removeHero(sleep?: number) {
|
||||
sleep = sleep || 500;
|
||||
element(by.buttonText('Remove hero')).click();
|
||||
browser.driver.sleep(sleep);
|
||||
}
|
||||
|
||||
function getScaleX(el: ElementFinder) {
|
||||
return Promise.all([
|
||||
getBoundingClientWidth(el),
|
||||
getOffsetWidth(el)
|
||||
]).then(function(promiseResolutions) {
|
||||
let clientWidth = promiseResolutions[0];
|
||||
let offsetWidth = promiseResolutions[1];
|
||||
return clientWidth / offsetWidth;
|
||||
expect(text).toBe('Inactive');
|
||||
expect(bgColor).toBe(inactiveColor);
|
||||
});
|
||||
|
||||
it('should be active with a blue background', async () => {
|
||||
let text = await statusSlider.getComponentText();
|
||||
const toggleButton = statusSlider.getToggleButton();
|
||||
const container = statusSlider.getComponentContainer();
|
||||
|
||||
if (text === 'Inactive') {
|
||||
await toggleButton.click();
|
||||
sleepFor(2000);
|
||||
}
|
||||
|
||||
function getBoundingClientWidth(el: ElementFinder) {
|
||||
return browser.executeScript(
|
||||
'return arguments[0].getBoundingClientRect().width',
|
||||
el.getWebElement()
|
||||
) as PromiseLike<number>;
|
||||
}
|
||||
text = await statusSlider.getComponentText();
|
||||
const bgColor = await container.getCssValue('backgroundColor');
|
||||
|
||||
function getOffsetWidth(el: ElementFinder) {
|
||||
return browser.executeScript(
|
||||
'return arguments[0].offsetWidth',
|
||||
el.getWebElement()
|
||||
) as PromiseLike<number>;
|
||||
}
|
||||
expect(text).toBe('Active');
|
||||
expect(bgColor).toBe(activeColor);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Toggle Animations Component', () => {
|
||||
beforeAll(async () => {
|
||||
await toggleHref.click();
|
||||
sleepFor();
|
||||
});
|
||||
|
||||
it('should disabled animations on the child element', async () => {
|
||||
const toggleButton = toggle.getToggleAnimationsButton();
|
||||
|
||||
await toggleButton.click();
|
||||
|
||||
const container = toggle.getComponentContainer();
|
||||
const cssClasses = await container.getAttribute('class');
|
||||
|
||||
expect(cssClasses).toContain('ng-animate-disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enter/Leave Component', () => {
|
||||
beforeAll(async () => {
|
||||
await enterLeaveHref.click();
|
||||
sleepFor(100);
|
||||
});
|
||||
|
||||
it('should attach a flyInOut trigger to the list of items', async () => {
|
||||
const heroesList = enterLeave.getHeroesList();
|
||||
const hero = heroesList.get(0);
|
||||
const cssClasses = await hero.getAttribute('class');
|
||||
const transform = await hero.getCssValue('transform');
|
||||
|
||||
expect(cssClasses).toContain('ng-trigger-flyInOut');
|
||||
expect(transform).toBe('matrix(1, 0, 0, 1, 0, 0)');
|
||||
});
|
||||
|
||||
it('should remove the hero from the list when clicked', async () => {
|
||||
const heroesList = enterLeave.getHeroesList();
|
||||
const total = await heroesList.count();
|
||||
const hero = heroesList.get(0);
|
||||
|
||||
await hero.click();
|
||||
await sleepFor(100);
|
||||
const newTotal = await heroesList.count();
|
||||
|
||||
expect(newTotal).toBeLessThan(total);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto Calculation Component', () => {
|
||||
beforeAll(async () => {
|
||||
await autoHref.click();
|
||||
sleepFor(0);
|
||||
});
|
||||
|
||||
it('should attach a shrinkOut trigger to the list of items', async () => {
|
||||
const heroesList = auto.getHeroesList();
|
||||
const hero = heroesList.get(0);
|
||||
const cssClasses = await hero.getAttribute('class');
|
||||
|
||||
expect(cssClasses).toContain('ng-trigger-shrinkOut');
|
||||
});
|
||||
|
||||
it('should remove the hero from the list when clicked', async () => {
|
||||
const heroesList = auto.getHeroesList();
|
||||
const total = await heroesList.count();
|
||||
const hero = heroesList.get(0);
|
||||
|
||||
await hero.click();
|
||||
await sleepFor(250);
|
||||
const newTotal = await heroesList.count();
|
||||
|
||||
expect(newTotal).toBeLessThan(total);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter/Stagger Component', () => {
|
||||
beforeAll(async () => {
|
||||
await filterHref.click();
|
||||
sleepFor();
|
||||
});
|
||||
|
||||
it('should attach a filterAnimations trigger to the list container', async () => {
|
||||
const heroesList = filterStagger.getComponentContainer();
|
||||
const cssClasses = await heroesList.getAttribute('class');
|
||||
|
||||
expect(cssClasses).toContain('ng-trigger-filterAnimation');
|
||||
});
|
||||
|
||||
it('should filter down the list when a search is performed', async () => {
|
||||
const heroesList = filterStagger.getHeroesList();
|
||||
const total = await heroesList.count();
|
||||
const formInput = filterStagger.getFormInput();
|
||||
|
||||
await formInput.sendKeys('Mag');
|
||||
await sleepFor(500);
|
||||
const newTotal = await heroesList.count();
|
||||
|
||||
expect(newTotal).toBeLessThan(total);
|
||||
expect(newTotal).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hero Groups Component', () => {
|
||||
beforeAll(async () => {
|
||||
await heroGroupsHref.click();
|
||||
sleepFor(300);
|
||||
});
|
||||
|
||||
it('should attach a flyInOut trigger to the list of items', async () => {
|
||||
const heroesList = heroGroups.getHeroesList();
|
||||
const hero = heroesList.get(0);
|
||||
const cssClasses = await hero.getAttribute('class');
|
||||
const transform = await hero.getCssValue('transform');
|
||||
const opacity = await hero.getCssValue('opacity');
|
||||
|
||||
expect(cssClasses).toContain('ng-trigger-flyInOut');
|
||||
expect(transform).toBe('matrix(1, 0, 0, 1, 0, 0)');
|
||||
expect(opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('should remove the hero from the list when clicked', async () => {
|
||||
const heroesList = heroGroups.getHeroesList();
|
||||
const total = await heroesList.count();
|
||||
const hero = heroesList.get(0);
|
||||
|
||||
await hero.click();
|
||||
await sleepFor(300);
|
||||
const newTotal = await heroesList.count();
|
||||
|
||||
expect(newTotal).toBeLessThan(total);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
19
aio/content/examples/animations/e2e/src/auto.po.ts
Normal file
19
aio/content/examples/animations/e2e/src/auto.po.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { by } from 'protractor';
|
||||
import { locate } from './util';
|
||||
|
||||
export function getPage() {
|
||||
return by.css('app-hero-list-auto-page');
|
||||
}
|
||||
|
||||
export function getComponent() {
|
||||
return by.css('app-hero-list-auto');
|
||||
}
|
||||
|
||||
export function getComponentContainer() {
|
||||
const findContainer = () => by.css('ul');
|
||||
return locate(getComponent(), findContainer());
|
||||
}
|
||||
|
||||
export function getHeroesList() {
|
||||
return getComponentContainer().all(by.css('li'));
|
||||
}
|
19
aio/content/examples/animations/e2e/src/enter-leave.po.ts
Normal file
19
aio/content/examples/animations/e2e/src/enter-leave.po.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { by } from 'protractor';
|
||||
import { locate } from './util';
|
||||
|
||||
export function getPage() {
|
||||
return by.css('app-hero-list-enter-leave-page');
|
||||
}
|
||||
|
||||
export function getComponent() {
|
||||
return by.css('app-hero-list-enter-leave');
|
||||
}
|
||||
|
||||
export function getComponentContainer() {
|
||||
const findContainer = () => by.css('ul');
|
||||
return locate(getComponent(), findContainer());
|
||||
}
|
||||
|
||||
export function getHeroesList() {
|
||||
return getComponentContainer().all(by.css('li'));
|
||||
}
|
20
aio/content/examples/animations/e2e/src/filter-stagger.po.ts
Normal file
20
aio/content/examples/animations/e2e/src/filter-stagger.po.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { by } from 'protractor';
|
||||
import { locate } from './util';
|
||||
|
||||
export function getPage() {
|
||||
return by.css('app-hero-list-page');
|
||||
}
|
||||
|
||||
export function getComponentContainer() {
|
||||
const findContainer = () => by.css('ul');
|
||||
return locate(getPage(), findContainer());
|
||||
}
|
||||
|
||||
export function getHeroesList() {
|
||||
return getComponentContainer().all(by.css('li'));
|
||||
}
|
||||
|
||||
export function getFormInput() {
|
||||
const formInput = () => by.css('form > input');
|
||||
return locate(getPage(), formInput());
|
||||
}
|
19
aio/content/examples/animations/e2e/src/hero-groups.ts
Normal file
19
aio/content/examples/animations/e2e/src/hero-groups.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { by } from 'protractor';
|
||||
import { locate } from './util';
|
||||
|
||||
export function getPage() {
|
||||
return by.css('app-hero-list-groups-page');
|
||||
}
|
||||
|
||||
export function getComponent() {
|
||||
return by.css('app-hero-list-groups');
|
||||
}
|
||||
|
||||
export function getComponentContainer() {
|
||||
const findContainer = () => by.css('ul');
|
||||
return locate(getComponent(), findContainer());
|
||||
}
|
||||
|
||||
export function getHeroesList() {
|
||||
return getComponentContainer().all(by.css('li'));
|
||||
}
|
33
aio/content/examples/animations/e2e/src/open-close.po.ts
Normal file
33
aio/content/examples/animations/e2e/src/open-close.po.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { by } from 'protractor';
|
||||
import { locate } from './util';
|
||||
|
||||
export function getPage() {
|
||||
return by.css('app-open-close-page');
|
||||
}
|
||||
|
||||
export function getComponent() {
|
||||
return by.css('app-open-close');
|
||||
}
|
||||
|
||||
export function getToggleButton() {
|
||||
const toggleButton = () => by.buttonText('Toggle Open/Close');
|
||||
return locate(getComponent(), toggleButton());
|
||||
}
|
||||
|
||||
export function getLoggingCheckbox() {
|
||||
const loggingCheckbox = () => by.css('section > input[type="checkbox"]');
|
||||
return locate(getPage(), loggingCheckbox());
|
||||
}
|
||||
|
||||
export function getComponentContainer() {
|
||||
const findContainer = () => by.css('div');
|
||||
return locate(getComponent(), findContainer());
|
||||
}
|
||||
|
||||
export async function getComponentText() {
|
||||
const findContainerText = () => by.css('div');
|
||||
const contents = locate(getComponent(), findContainerText());
|
||||
const componentText = await contents.getText();
|
||||
|
||||
return componentText;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user