Compare commits

..

128 Commits
6.1.5 ... 6.0.1

Author SHA1 Message Date
bd3dddce4b release: cut the v6.0.1 release 2018-05-10 22:22:49 -07:00
1336a9451f build: update to latest TypeScript rules (#23828)
Fixes #23810

PR Close #23828
2018-05-10 16:45:38 -07:00
d280077412 fix(elements): always check to create strategy (#23825)
PR Close #23825
2018-05-10 16:07:11 -07:00
2b578f5c61 docs(elements): add angular element term to glossary (#23807)
PR Close #23807
2018-05-10 15:50:00 -07:00
12dcb313af style: remove empty comments (#23404)
PR Close #23404
2018-05-10 15:48:13 -07:00
f576851ecc docs(elements): emphasize future direction, update link (#23806)
PR Close #23806
2018-05-10 15:46:53 -07:00
ca6cb66c32 docs: change release_schedule.md to link to new angular release page in docs (#23808)
PR Close #23808
2018-05-10 15:45:28 -07:00
484233f6c1 docs(aio): add Alain Chautard in GDE list (#23783)
PR Close #23783
2018-05-10 12:07:10 -07:00
3d8799b3a2 fix(router): avoid freezing queryParams in-place (#22663)
The recognizer code used to call Object.freeze() on queryParams before
using them to construct ActivatedRoutes, with the intent being to help
avoid common invalid usage. Unfortunately, Object.freeze() works
in-place, so this was also freezing the queryParams on the actual
UrlTree object, making it more difficult to manipulate UrlTrees in
things like UrlHandlingStrategy.

This change simply shallow-copies the queryParams before freezing them.

Fixes #22617

PR Close #22663
2018-05-10 07:54:11 -07:00
8733843c11 fix(router): correct the segment parsing so it won't break on ampersand (#23684)
PR Close #23684
2018-05-10 07:53:54 -07:00
f1097914c5 ci: add config for size plugin of the github rebot (#23665)
PR Close #23665
2018-05-10 07:53:34 -07:00
06776d1d10 fix(aio): fix error in import after RxJS 6 migration (#22886)
PR Close #22886
2018-05-09 11:52:04 -07:00
2b31b6dc3f refactor(service-worker): sort manifest url/hashTable entries (#23586)
This makes it easier to quickly check whether a specific file ended up
in the manifest, for example when debugging.

PR Close #23586
2018-05-09 11:51:23 -07:00
2254ac23e4 fix(service-worker): correctly handle requests with empty clientId (#23625)
Requests from clients that are not assigned a client ID by the browser
will produce `fetch` events with `null` or empty (`''`) `clientId`s.

Previously, the ServiceWorker only handled `null` values correctly. Yet
empty strings are also valid (see for example [here][1] and [there][2]).
With this commit, the SW will interpret _all_ falsy `clientId` values
the same (i.e. "no client ID assigned") and handle them appropriately.

Related Chromium issue/discussion: [#832105][3]

[1]: 4cc72bd0f1/docs/index.bs (L1392)
[2]: https://w3c.github.io/ServiceWorker/#fetchevent-interface
[3]: https://bugs.chromium.org/p/chromium/issues/detail?id=832105

Fixes #23526

PR Close #23625
2018-05-09 11:50:03 -07:00
38c678fdcd test(service-worker): support mock requests with null/empty client ID (#23625)
PR Close #23625
2018-05-09 11:50:03 -07:00
1a655836cb test(service-worker): improve adding clients in SwTestHarness (#23625)
This commits changes how clients are added in `SwTestHarness`, so that
the behavior in tests closer mimics what would happen in an actual
ServiceWorker.
It also removes auto-adding clients when calling `clients.get()`, which
could hide bugs related to non-existing clients.

PR Close #23625
2018-05-09 11:50:03 -07:00
2e466f4bea build(bazel): update to rules_typescript 0.12.3 (#23617)
PR Close #23617
2018-05-09 11:47:11 -07:00
7b06fa88ab build(aio): include navigation.json changes in docs-watch (#23698)
Closes #23582

PR Close #23698
2018-05-09 11:45:19 -07:00
3d712894ae fix(aio): add link to v5 docs (#23794)
Fixes #23781

PR Close #23794
2018-05-09 11:44:45 -07:00
75b8edae03 ci: Remove Chuck from pullapprove (#23798)
Jason takes over his role on core, Keen for everything else

PR Close #23798
2018-05-09 11:43:47 -07:00
fe7f48c1d5 docs(aio): Upgrade example dependencies to Angular V6 (#23660)
PR Close #23660
2018-05-08 13:56:48 -07:00
29600cbb19 docs(aio): Update i18n example to Angular V6 (#23660)
PR Close #23660
2018-05-08 13:56:48 -07:00
5581e97d2a fix(core): call ngOnDestroy on all services that have it (#23755)
Previously, ngOnDestroy was only called on services which were statically
determined to have ngOnDestroy methods. In some cases, such as with services
instantiated via factory functions, it's not statically known that the service
has an ngOnDestroy method.

This commit changes the runtime to look for ngOnDestroy when instantiating
all DI tokens, and to call the method if it's present.

Fixes #22466
Fixes #22240
Fixes #14818

PR Close #23755
2018-05-08 13:55:29 -07:00
19262d9f90 Revert "style(animations): fix short param names (#23668)"
This reverts commit e3518967ad.

This PR accidentaly introduces a breaking change:
https://github.com/angular/angular/pull/23668#discussion_r186265055
2018-05-05 08:40:35 -07:00
1eb1c6315d Revert "docs(animations): fix content errors (#23668)"
This reverts commit 005dc8f68b.

The PR accidently introduced a breaking change
https://github.com/angular/angular/pull/23668#discussion_r186265055
2018-05-05 08:38:13 -07:00
3807599a4d style(animations): fix short param names (#23668)
PR Close #23668
2018-05-05 08:17:02 -07:00
2ed41d9e38 docs(animations): fix content errors (#23668)
PR Close #23668
2018-05-05 08:17:02 -07:00
5eb9c01bb6 ci: hide encryption key from circleci logs (#23585)
PR Close #23585
2018-05-04 16:33:59 -07:00
09d9662386 build: serve ivy todo app with real http-server (#23446)
PR Close #23446
2018-05-04 16:33:53 -07:00
844cbd9774 ci: publish build snapshots from Bazel/CircleCI (#23512)
This uses a new script and CircleCI job called "build-packages-dist"
which shims the new Bazel build to produce outputs matching the legacy
build. We'll use this to get AIO testing onto CircleCI as well.

We move the integration tests to a new circleCI job that depends on this
one, as well as the build publishing job.

Note that every PR will have a trivial green publishing status, because
we always create this job even for PRs. We'd rather not - see
https://discuss.circleci.com/t/workflows-pull-request-filter/14396/4

PR Close #23512
2018-05-04 16:33:39 -07:00
373a47dda9 test: fix firebase deployment script test
When I fixed the project id in 2c4850dc58,
I didn't realize we had a test that verified the wrong behavior.
2018-05-04 15:40:50 -07:00
83f12f3047 build: update to latest nodejs bazel rules (#23683)
PR Close #23683
2018-05-04 15:29:03 -07:00
bbc416cdcd build: update bazel to 0.13 (#23623)
PR Close #23623
2018-05-04 15:23:56 -07:00
07f2098655 feat(aio): add v6 release notification (#23690)
PR Close #23690
2018-05-04 15:23:36 -07:00
9b53a6e779 fix(aio): remove main background color when printing (#23538)
PR Close #23538
2018-05-04 15:21:14 -07:00
65f8505fb6 fix(aio): fix code-example print styles when printing backgrounds (#23538)
Fixes #23431

PR Close #23538
2018-05-04 15:21:14 -07:00
5a5ea45c40 refactor(aio): use the same selectors for screen and print styles (#23538)
PR Close #23538
2018-05-04 15:21:14 -07:00
52a3657b48 refactor(aio): include print styles last to overwrite other styles (#23538)
PR Close #23538
2018-05-04 15:21:14 -07:00
3824e3f858 fix(animations): properly clean up queried element styles in safari/edge (#23686)
Prior to this patch, if an element is queried and animated for 0 seconds
(just a style() call and nothing else) then the styles applied would not
be properly cleaned up due to their camelCased nature.

PR Close #23686
2018-05-04 15:04:20 -07:00
05aa5e0179 fix(animations): retain state styling for nodes that are moved around (#23686)
PR Close #23686
2018-05-04 15:04:20 -07:00
4ddeb030e7 build(aio): use Angular 6.0.0 (#23687)
PR Close #23687
2018-05-03 16:05:34 -07:00
afe6380429 build(aio): update to Angular CLI 6.0.0 (#23687)
PR Close #23687
2018-05-03 16:05:34 -07:00
947ea17a09 build: update the scripts/release/post-check script for 6.0.x 2018-05-03 15:38:52 -07:00
a190c45a64 fix(aio): correct project id for deployment of archive sites 2018-05-03 15:05:38 -07:00
902781803f docs(aio): Upgrade server-side rendering example to Angular V6 (#23649)
PR Close #23649
2018-05-03 14:24:44 -07:00
0d480ac0dc docs: add new info about angular update policies and resources (#23551)
PR Close #23551
2018-05-03 14:24:31 -07:00
6934bf4863 docs: add information on when not to use tree-shakable providers (#23634)
PR Close #23634
2018-05-03 14:24:16 -07:00
7e7ea33fb0 docs: add doc to include updates to the index.html with the new ng add command (#23616)
PR Close #23616
2018-05-03 14:23:30 -07:00
5bd8c6887e docs: improve the GitHub README.md, update links, etc 2018-05-03 13:26:52 -07:00
d28ab372c8 docs: add link to the v6 release announcement to our changelog 2018-05-03 13:26:46 -07:00
d0ccf5f169 release: cut the v6.0.0 release 2018-05-03 12:17:26 -07:00
ecde15298a build: update to rxjs@6.0.0 (#23679)
PR Close #23679
2018-05-03 10:53:39 -07:00
983e5f2d7e fix(aio): correctly route embedded live-example URLs from SW (#23637)
Partially addresses #23626.

PR Close #23637
2018-05-02 15:55:23 -07:00
5fc4299e0a refactor(aio): move right margin from .home image to .home anchor (#23624)
This makes the outline of `.home` symmetric.

PR Close #23624
2018-05-02 15:54:15 -07:00
1823d5dd1c style(aio): add space between .home and .hamburger (#23624)
When the `.hamburger` icon is clicked, it's background is drawn until
the very edge of `.home`'s image, leaving no space.

PR Close #23624
2018-05-02 15:54:15 -07:00
91d4da0d2f docs: add missing link to bootstrapping section (#23214)
PR Close #23214
2018-05-02 15:53:02 -07:00
22eb8e26fc build(aio): align stackblitz files with Angular CLI V6 (#23521)
Also cleans up legacy references to `.angular-cli.json`

PR Close #23521
2018-05-02 15:00:57 -07:00
f6002c1702 docs(forms): Fixed a typo in the reactive form (From 'address' to 'secretLairs') section (#23221)
PR Close #23221
2018-05-02 15:00:28 -07:00
14138f6382 docs(elements): add intro connecting angular elements to custom elements (#23638)
PR Close #23638
2018-05-02 14:57:20 -07:00
f11daa2031 docs(aio): update Egghead.io URL (#23598)
Closes #23597
PR Close #23598
2018-05-01 10:27:16 -07:00
31a435ef5b docs: fix typo in tag name (my-child --> app-child) (#23606)
Fixes #23599

PR Close #23606
2018-05-01 10:26:50 -07:00
3e92b22258 test: add i18n to cli-hello-world integration test (#23527)
PR Close #23527
2018-04-27 11:26:50 -07:00
2e5457c824 docs(aio): update docs error in guide/http (#23567)
Updates documentation to include examples for both req.flush and
req.error in http testing examples.

PR Close #23567
2018-04-27 11:26:27 -07:00
1ab5fba92e build(aio): add support for faster, unoptimized serve (#23569)
When running `yarn start` and `yarn serve-and-sync`, we are usually
more interested in faster re-build times than optimized builds. This was
also the behavior, before upgrading to @angular/cli@6 (fc5af69fb).

This commit introduces a new configuration (`fast`), which is used by
`yarn start` and `yarn serve-and-sync` to restore the faster,
unoptimized builds.
Other commands, such as `ng serve` and `ng e2e`, remain unchanged (using
slower, optimized builds).

PR Close #23569
2018-04-27 11:26:22 -07:00
e1e57ddaa7 docs: correct more typos (#23565)
PR Close #23565
2018-04-27 11:26:17 -07:00
ee7cb48877 docs: correct typos (#23565)
PR Close #23565
2018-04-27 11:26:10 -07:00
a30c57090a docs: correct node.js version and usage (#23565)
PR Close #23565
2018-04-27 11:26:04 -07:00
8a49ec4f27 ci: add Brandon Roberts as an aio approver (#23417)
PR Close #23417
2018-04-27 11:25:59 -07:00
697b6c040c fix(core): avoid eager providers re-initialization (#23559)
Fix a corner case where eager providers were getting constructed twice if the provider was requested before the initialization of the NgModule is complete.

PR Close #23559
2018-04-27 11:25:47 -07:00
4008e36e80 release: cut the v6.0.0-rc.6 release 2018-04-27 10:47:56 -07:00
e47bb52084 Revert "refactor(core): tree-shake application_module providers (#23477)"
This reverts commit ac2b530f4b.

The change is breaking targets in g3 see cl/194336387.
2018-04-27 07:13:56 -07:00
ac2b530f4b refactor(core): tree-shake application_module providers (#23477)
PR Close #23477
2018-04-25 15:54:41 -07:00
64bf6edf00 docs: update glossary architectural terms (#23045)
PR Close #23045
2018-04-25 13:21:52 -07:00
adf6235479 docs: corrected spelling of "ambient". 2018-04-24 15:05:31 -07:00
04c18ac1aa docs: fix typo (#23514)
PR Close #23514
2018-04-24 14:43:34 -07:00
1b26dd8cdb docs(benchpress): fix typo in README (#23471) (#23488)
PR Close #23488
2018-04-24 14:37:03 -07:00
f721b06bde style: format code 2018-04-24 14:36:30 -07:00
0bc8443e12 fix(compiler): avoid a crash in ngc-wrapped. (#23468)
`ng.performCompilation` can return an `undefined` program, which is not handled by ngc-wrapped.

Avoid crashing by checking for the error return and returning the diagnostics.
PR Close #23468
2018-04-24 13:57:04 -07:00
db17231597 ci(aio): fix deploy-to-firebase script (#23470)
Temporary workaround for angular/angular-cli#10398.
The behavior of `yarn build` remains the same, but building for a
specific deployment env (e.g. archive, next) requires
`yarn build-for $deployEnv`.

PR Close #23470
2018-04-24 11:15:35 -07:00
540626a3a6 build(common): mark locales files as side-effect-full (#23509)
Fixes https://github.com/angular/angular-cli/issues/10322
PR Close #23509
2018-04-24 11:14:52 -07:00
391bfcede5 docs(aio): Add UpgradingAngularJS to education resources (#23169)
PR Close #23169
2018-04-23 13:36:48 -07:00
d8de6488dd fix(router): cache route handle if found (#22475)
When asking the route reuse strategy to retrieve a detached route handle, store the
return value in a local variable for further processing instead of asking again later.

resolves #22474

PR Close #22475
2018-04-23 13:35:59 -07:00
151fb66848 ci: add alxhub as owner to a few packages (#23510)
PR Close #23510
2018-04-23 10:08:25 -07:00
02424ff0d0 Revert "style(compiler): fix lint issues (#23480)"
This reverts commit f0925d9705.
2018-04-22 12:21:24 -07:00
f0925d9705 style(compiler): fix lint issues (#23480)
PR Close #23480
2018-04-22 11:55:33 -07:00
212b806eda build: make commit validation accept typical Revert messages (#23480)
fixes #23479

PR Close #23480
2018-04-22 11:49:49 -07:00
b9431e88fb fix(compiler): handle undefined annotation metadata (#23349)
In certain cases seen in production, simplify() can returned
undefined when simplifying decorator metadata. This has proven tricky
to reproduce in an isolated test, but the fix is simple and low-risk:
don't attempt to spread an undefined set of annotations in the first
place.

PR Close #23349
2018-04-19 18:57:22 -07:00
7790cfa0d0 Revert "fix(compiler): Pretty print object instead of [Object object] (#22689)" (#23442)
This reverts commit 8555a3a3cd.

Reverted because of https://github.com/angular/angular/issues/23440

PR Close #23442
2018-04-19 14:52:49 -07:00
41b5149509 docs(aio): add front page campaign for the ng-conf live stream (#23391)
PR Close #23391
2018-04-17 14:13:43 -07:00
06f865640d docs(aio): Cleanup examples with edits from Igor/George (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
824f74f27b build(aio): turn on webpack's stats.json generation for debugging purposes (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
5301c43eed build(aio): add @angular/language-service (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
f280d1aef1 docs(aio): Bump shared yarn.lock file for examples (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
5e741d42a6 docs(aio): Bump shared dependencies to RC5 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
0035d41030 docs(aio): Fix failing upgrade-module tests (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
08f447ceec docs(aio): Fix failing boilerplate tests (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
8953f123e3 docs(aio): Upgrade examples to Angular 6 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
6274007e3b test(aio): fix failing tests (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
ee76be7783 docs(aio): update yarn test command in README.md (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
a8c720bc3a build(aio): update to @angular/material@6.0.0-rc.11 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
ac47a3cd93 test(aio): move reflect-metadata polyfills to test.ts (#23234)
This resolves https://github.com/angular/angular-cli/issues/10333 and nicely cleans up the code.

PR Close #23234
2018-04-17 14:09:04 -07:00
041458a3d2 build(aio): update to angular/core@6.0.0-rc.5 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
2cb74b748f style(aio): lint fixes for examples (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
cb0bfe7a43 test(aio): fix tests and update testing infra (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
8ee11aeaa6 build(aio): update tslint and codelyzer (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
bf89fcb361 build(aio): fix deployment script (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
4f70c5b6f7 build(aio): upgrade @angular/cli to 6.0.0-rc.4 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
ad3ebec2a5 build(aio): upgrade @angular/* to 6.0.0-rc.4 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
3f5d61f2dd build(aio): remove redundant flags from cli commands (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
11ada7f78b build(aio): switch to webpack-cli for IE polyfills (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
0b96bc7456 build(aio): upgrade rxjs to 6.0.0-turbo-rc.4 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
fefadadff3 ci: chown bazel-built packages when running integration tests (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
946057ae29 build: update to rxjs@6.0.0-uncanny-rc.7 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
d4293aaaaa build: remove a postinstall-patch to fix rxjs (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
e3dcc227f6 build(aio): reorder entries in package.json (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
c9230dd90e build: fix angular.json that was missing keys due to cli bugs (#23234)
https://github.com/angular/angular-cli/issues/10225
https://github.com/angular/angular-cli/issues/10226

PR Close #23234
2018-04-17 14:09:04 -07:00
234af9ba59 build(aio): update to @angular/material@6.0.0-rc.1 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
7204028d3e build(aio): update to @angular/material@5.2.4 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
f7c55952bf build: update to rxjs@6.0.0-tactical-rc.1 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
e38e3bd135 test: simplify config for cli-hello-world (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
cd20c01ba1 test: update cli-hello-world to cli@6.0.0-rc.2 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
12a191ef3f build(aio): update to @angular/cli@6.0.0-rc.2 + project layout update (#23234)
project layout was updated using:
yarn ng update @angular/cli --migrate-only --from=1.7.3

PR Close #23234
2018-04-17 14:09:04 -07:00
65e67b3c3a build(aio): upgrade to @angular/*@6.0.0-rc.3 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
32e57f6197 build(aio): upgrade to @angular/*@6.0.0-rc.2 (#23234)
PR Close #23234
2018-04-17 14:09:04 -07:00
7de69ba29b ci(aio): fix aio-monitoring tests (#23390)
Previously, we were running the e2e tests from master against
`https://angular.io` (deployed from the stable branch). Often the e2e
tests from master do not apply to the stable branch, since the app has
deviated slightly.

This commit fixes this by stop running the full e2e tests against the
deployed versions, but a smaller set of "smoke tests", which check basic
functionality that is less likely to change between versions.

PR Close #23390
2018-04-17 13:45:38 -07:00
44193c0b94 refactor(aio): rename directory (tests/deployment-config --> tests/deployment) (#23390)
PR Close #23390
2018-04-17 13:45:38 -07:00
6db8241ffa refactor(aio): rename spec file (#23390)
PR Close #23390
2018-04-17 13:45:38 -07:00
33c594516c refactor(aio): rename yarn script (deployment-config-test --> redirects-test) (#23390)
PR Close #23390
2018-04-17 13:45:38 -07:00
1413 changed files with 26647 additions and 63766 deletions

View File

@ -3,10 +3,7 @@
# See remote cache documentation in /docs/BAZEL.md # See remote cache documentation in /docs/BAZEL.md
# Don't be spammy in the logs # Don't be spammy in the logs
# TODO(gmagolan): Hide progress again once build performance improves build --noshow_progress
# Presently, CircleCI can timeout during bazel test ... with the following
# error: Too long with no output (exceeded 10m0s)
# build --noshow_progress
# Don't run manual tests # Don't run manual tests
test --test_tag_filters=-manual test --test_tag_filters=-manual

View File

@ -12,8 +12,8 @@
## IMPORTANT ## IMPORTANT
# If you change the `docker_image` version, also change the `cache_key` suffix and the version of # 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. # `com_github_bazelbuild_buildtools` in the `/WORKSPACE` file.
var_1: &docker_image angular/ngcontainer:0.4.0 var_1: &docker_image angular/ngcontainer:0.3.0
var_2: &cache_key v2-angular-{{ .Branch }}-{{ checksum "yarn.lock" }}-0.4.0 var_2: &cache_key v2-angular-{{ .Branch }}-{{ checksum "yarn.lock" }}-0.3.0
# Define common ENV vars # Define common ENV vars
var_3: &define_env_vars var_3: &define_env_vars
@ -80,12 +80,12 @@ jobs:
- run: ls /home/circleci/bazel_repository_cache || true - run: ls /home/circleci/bazel_repository_cache || true
- run: bazel info release - run: bazel info release
- run: bazel run @nodejs//:yarn - run: bazel run @yarn//:yarn
# Use bazel query so that we explicitly ask for all buildable targets to be built as well # Use bazel query so that we explicitly ask for all buildable targets to be built as well
# This avoids waiting for the slowest build target to finish before running the first test # This avoids waiting for the slowest build target to finish before running the first test
# See https://github.com/bazelbuild/bazel/issues/4257 # See https://github.com/bazelbuild/bazel/issues/4257
# NOTE: Angular developers should typically just bazel build //packages/... or bazel test //packages/... # NOTE: Angular developers should typically just bazel build //packages/... or bazel test //packages/...
- run: bazel query --output=label //... | xargs bazel test --build_tag_filters=-ivy-only --test_tag_filters=-manual,-ivy-only - run: bazel query --output=label //... | xargs bazel test
# CircleCI will allow us to go back and view/download these artifacts from past builds. # CircleCI will allow us to go back and view/download these artifacts from past builds.
# Also we can use a service like https://buildsize.org/ to automatically track binary size of these artifacts. # Also we can use a service like https://buildsize.org/ to automatically track binary size of these artifacts.
@ -111,59 +111,6 @@ jobs:
paths: paths:
- "node_modules" - "node_modules"
- "~/bazel_repository_cache" - "~/bazel_repository_cache"
# Temporary job to test what will happen when we flip the Ivy flag to true
test_ivy_jit:
<<: *job_defaults
resource_class: xlarge
steps:
- *define_env_vars
- checkout:
<<: *post_checkout
# See remote cache documentation in /docs/BAZEL.md
- run: .circleci/setup_cache.sh
- run: sudo cp .circleci/bazel.rc /etc/bazel.bazelrc
- *setup-bazel-remote-cache
- restore_cache:
key: *cache_key
- run: bazel run @yarn//:yarn
- run: bazel query --output=label //... | xargs bazel test --define=compile=jit --build_tag_filters=ivy-jit --test_tag_filters=-manual,ivy-jit
test_ivy_aot:
<<: *job_defaults
resource_class: xlarge
steps:
- *define_env_vars
- checkout:
<<: *post_checkout
# See remote cache documentation in /docs/BAZEL.md
- run: .circleci/setup_cache.sh
- run: sudo cp .circleci/bazel.rc /etc/bazel.bazelrc
- *setup-bazel-remote-cache
- restore_cache:
key: *cache_key
- 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
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
- 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 exists only for backwards-compatibility with old scripts and tests # This job exists only for backwards-compatibility with old scripts and tests
# that rely on the pre-Bazel dist/packages-dist layout. # that rely on the pre-Bazel dist/packages-dist layout.
@ -184,7 +131,7 @@ jobs:
- run: sudo cp .circleci/bazel.rc /etc/bazel.bazelrc - run: sudo cp .circleci/bazel.rc /etc/bazel.bazelrc
- *setup-bazel-remote-cache - *setup-bazel-remote-cache
- run: bazel run @nodejs//:yarn - run: bazel run @yarn//:yarn
- run: scripts/build-packages-dist.sh - run: scripts/build-packages-dist.sh
# Save the npm packages from //packages/... for other workflow jobs to read # Save the npm packages from //packages/... for other workflow jobs to read
@ -193,8 +140,6 @@ jobs:
root: dist root: dist
paths: paths:
- packages-dist - packages-dist
- packages-dist-ivy-jit
- packages-dist-ivy-local
# We run the integration tests outside of Bazel for now. # We run the integration tests outside of Bazel for now.
# They are a separate workflow job so that they can be easily re-run. # They are a separate workflow job so that they can be easily re-run.
@ -204,10 +149,6 @@ jobs:
# See comments inside the integration/run_tests.sh script. # See comments inside the integration/run_tests.sh script.
integration_test: integration_test:
<<: *job_defaults <<: *job_defaults
# Note: we run Bazel in one of the integration tests, and it can consume >2G
# of memory. Together with the system under test, this can exhaust the RAM
# on a 4G worker so we use a larger machine here too.
resource_class: xlarge
steps: steps:
- *define_env_vars - *define_env_vars
- checkout: - checkout:
@ -259,10 +200,7 @@ workflows:
jobs: jobs:
- lint - lint
- test - test
- test_ivy_jit
- test_ivy_aot
- build-packages-dist - build-packages-dist
- aio_preview
- integration_test: - integration_test:
requires: requires:
- build-packages-dist - build-packages-dist
@ -274,8 +212,6 @@ workflows:
requires: requires:
# Only publish if tests and integration tests pass # Only publish if tests and integration tests pass
- test - test
- test_ivy_jit
- test_ivy_aot
- integration_test - integration_test
# Get the artifacts to publish from the build-packages-dist job # Get the artifacts to publish from the build-packages-dist job
# since the publishing script expects the legacy outputs layout. # since the publishing script expects the legacy outputs layout.
@ -291,6 +227,3 @@ workflows:
branches: branches:
only: only:
- master - master
notify:
webhooks:
- url: https://ngbuilds.io/circle-build

View File

@ -4,7 +4,6 @@
size: size:
disabled: false disabled: false
maxSizeIncrease: 1000 maxSizeIncrease: 1000
circleCiStatusName: "ci/circleci: build-packages-dist"
status: status:
disabled: false disabled: false
context: "ci/angular: size" context: "ci/angular: size"
@ -45,12 +44,10 @@ merge:
- "packages/language-service/**" - "packages/language-service/**"
- "**/.gitignore" - "**/.gitignore"
- "**/.gitkeep" - "**/.gitkeep"
- "**/package.json"
- "**/tsconfig-build.json" - "**/tsconfig-build.json"
- "**/tsconfig.json" - "**/tsconfig.json"
- "**/rollup.config.js" - "**/rollup.config.js"
- "**/BUILD.bazel" - "**/BUILD.bazel"
- "packages/**/integrationtest/**"
- "packages/**/test/**" - "packages/**/test/**"
# comment that will be added to a PR when there is a conflict, leave empty or set to false to disable # comment that will be added to a PR when there is a conflict, leave empty or set to false to disable
@ -62,13 +59,6 @@ merge:
# list of checks that will determine if the merge label can be added # list of checks that will determine if the merge label can be added
checks: checks:
# require that the PR has reviews from all requested reviewers
#
# This enables us to request reviews from both eng and tech writers, or multiple eng folks, and prevents accidental merges.
# Rather than merging PRs with pending reviews, if all PullApprove requirements are satisfied and additional reviews are not needed pending reviewers should be removed via GitHub UI (this also leaves an audit trail behind these decisions).
requireReviews: true,
# whether the PR shouldn't have a conflict with the base branch # whether the PR shouldn't have a conflict with the base branch
noConflict: true noConflict: true
# list of labels that a PR needs to have, checked with a regexp (e.g. "PR target:" will work for the label "PR target: master") # list of labels that a PR needs to have, checked with a regexp (e.g. "PR target:" will work for the label "PR target: master")

View File

@ -8,7 +8,6 @@
# alexeagle - Alex Eagle # alexeagle - Alex Eagle
# alxhub - Alex Rickabaugh # alxhub - Alex Rickabaugh
# andrewseguin - Andrew Seguin # andrewseguin - Andrew Seguin
# benlesh - Ben Lesh
# brandonroberts - Brandon Roberts # brandonroberts - Brandon Roberts
# brocco - Mike Brocchi # brocco - Mike Brocchi
# filipesilva - Filipe Silva # filipesilva - Filipe Silva
@ -16,7 +15,7 @@
# hansl - Hans Larsen # hansl - Hans Larsen
# IgorMinar - Igor Minar # IgorMinar - Igor Minar
# jasonaden - Jason Aden # jasonaden - Jason Aden
# jenniferfell - Jennifer Fell # kapunahelewong - Kapunahele Wong
# kara - Kara Erickson # kara - Kara Erickson
# kyliau - Keen Yee Liau # kyliau - Keen Yee Liau
# matsko - Matias Niemelä # matsko - Matias Niemelä
@ -24,6 +23,8 @@
# petebacondarwin - Pete Bacon Darwin # petebacondarwin - Pete Bacon Darwin
# pkozlowski-opensource - Pawel Kozlowski # pkozlowski-opensource - Pawel Kozlowski
# robwormald - Rob Wormald # robwormald - Rob Wormald
# tinayuangao - Tina Gao
# vicb - Victor Berchet
# vikerman - Vikram Subramanian # vikerman - Vikram Subramanian
@ -91,7 +92,6 @@ groups:
- "*.bzl" - "*.bzl"
- "packages/bazel/*" - "packages/bazel/*"
- "tools/bazel.rc" - "tools/bazel.rc"
- "/docs/BAZEL.md"
users: users:
- alexeagle #primary - alexeagle #primary
- kyliau - kyliau
@ -124,116 +124,49 @@ groups:
users: users:
- alexeagle - alexeagle
- mhevery - mhevery
- vicb
- IgorMinar #fallback - IgorMinar #fallback
core: core:
conditions: conditions:
files: files:
- "packages/core/*" - "packages/core/*"
- "aio/content/guide/bootstrapping.md"
- "aio/content/examples/bootstrapping/*"
- "aio/content/guide/attribute-directives.md"
- "aio/content/examples/attribute-directives/*"
- "aio/content/images/guide/attribute-directives/*"
- "aio/content/guide/structural-directives.md"
- "aio/content/examples/structural-directives/*"
- "aio/content/images/guide/structural-directives/*"
- "aio/content/guide/dynamic-component-loader.md"
- "aio/content/examples/dynamic-component-loader/*"
- "aio/content/images/guide/dynamic-component-loader/*"
- "aio/content/guide/template-syntax.md"
- "aio/content/examples/template-syntax/*"
- "aio/content/images/guide/template-syntax/*"
- "aio/content/guide/dependency-injection.md"
- "aio/content/examples/dependency-injection/*"
- "aio/content/images/guide/dependency-injection/*"
- "aio/content/guide/dependency-injection-in-action.md"
- "aio/content/examples/dependency-injection-in-action/*"
- "aio/content/images/guide/dependency-injection-in-action/*"
- "aio/content/guide/hierarchical-dependency-injection.md"
- "aio/content/examples/hierarchical-dependency-injection/*"
- "aio/content/guide/singleton-services.md"
- "aio/content/guide/dependency-injection-pattern.md"
- "aio/content/guide/providers.md"
- "aio/content/examples/providers/*"
- "aio/content/guide/component-interaction.md"
- "aio/content/examples/component-interaction/*"
- "aio/content/images/guide/component-interaction/*"
- "aio/content/guide/component-styles.md"
- "aio/content/examples/component-styles/*"
- "aio/content/guide/lifecycle-hooks.md"
- "aio/content/examples/lifecycle-hooks/*"
- "aio/content/images/guide/lifecycle-hooks/*"
- "aio/content/examples/ngcontainer/*"
- "aio/content/images/guide/ngcontainer/*"
- "aio/content/guide/pipes.md"
- "aio/content/examples/pipes/*"
- "aio/content/images/guide/pipes/*"
- "aio/content/guide/entry-components.md"
- "aio/content/guide/set-document-title.md"
- "aio/content/examples/set-document-title/*"
- "aio/content/images/guide/set-document-title/*"
- "aio/content/guide/ngmodules.md"
- "aio/content/examples/ngmodules/*"
- "aio/content/examples/ngmodule/*"
- "aio/content/images/guide/ngmodule/*"
- "aio/content/guide/ngmodule-faq.md"
- "aio/content/examples/ngmodule-faq/*"
- "aio/content/guide/module-types.md"
- "aio/content/guide/sharing-ngmodules.md"
- "aio/content/guide/frequent-ngmodules.md"
- "aio/content/images/guide/frequent-ngmodules/*"
- "aio/content/guide/ngmodule-api.md"
- "aio/content/guide/ngmodule-vs-jsmodule.md"
- "aio/content/guide/feature-modules.md"
- "aio/content/examples/feature-modules/*"
- "aio/content/images/guide/feature-modules/*"
- "aio/content/guide/lazy-loading-ngmodules.md"
- "aio/content/examples/lazy-loading-ngmodules/*"
- "aio/content/images/guide/lazy-loading-ngmodules"
users: users:
- mhevery #primary - mhevery #primary
- jasonaden - jasonaden
- kara - kara
- IgorMinar - vicb
- jenniferfell #docs only - IgorMinar #fallback
animations: animations:
conditions: conditions:
files: files:
- "packages/animations/*" - "packages/animations/*"
- "packages/platform-browser/animations/*" - "packages/platform-browser/animations/*"
- "aio/content/guide/animations.md"
- "aio/content/examples/animations/*"
- "aio/content/images/guide/animations/*"
users: users:
- matsko #primary - matsko #primary
- mhevery #fallback - mhevery #fallback
- IgorMinar #fallback - IgorMinar #fallback
- jenniferfell #docs only
compiler/i18n: compiler/i18n:
conditions: conditions:
files: files:
- "packages/compiler/src/i18n/*" - "packages/compiler/src/i18n/*"
- "aio/content/guide/i18n.md"
- "aio/content/examples/i18n/*"
users: users:
- alxhub #primary - vicb #primary
- alxhub
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
- jenniferfell #docs only
compiler: compiler:
conditions: conditions:
files: files:
- "packages/compiler/*" - "packages/compiler/*"
- "aio/content/guide/aot-compiler.md"
users: users:
- alxhub #primary - chuckjaz #primary
- vicb
- mhevery - mhevery
- IgorMinar #fallback - IgorMinar #fallback
- jenniferfell #docs only
compiler-cli/ngtools: compiler-cli/ngtools:
conditions: conditions:
@ -242,6 +175,7 @@ groups:
users: users:
- hansl - hansl
- filipesilva #fallback - filipesilva #fallback
- brocco #fallback
- IgorMinar #fallback - IgorMinar #fallback
compiler-cli: compiler-cli:
@ -255,6 +189,7 @@ groups:
users: users:
- alexeagle - alexeagle
- alxhub - alxhub
- vicb
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
@ -267,6 +202,7 @@ groups:
- "packages/common/http/*" - "packages/common/http/*"
users: users:
- pkozlowski-opensource #primary - pkozlowski-opensource #primary
- vicb
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
@ -275,159 +211,109 @@ groups:
files: files:
- "packages/forms/*" - "packages/forms/*"
- "aio/content/guide/forms.md" - "aio/content/guide/forms.md"
- "aio/content/examples/forms/*"
- "aio/content/images/guide/forms/*"
- "aio/content/guide/form-validation.md" - "aio/content/guide/form-validation.md"
- "aio/content/examples/form-validation/*"
- "aio/content/images/guide/form-validation/*"
- "aio/content/guide/dynamic-form.md"
- "aio/content/examples/dynamic-form/*"
- "aio/content/images/guide/dynamic-form/*"
- "aio/content/guide/reactive-forms.md" - "aio/content/guide/reactive-forms.md"
- "aio/content/examples/forms/*"
- "aio/content/examples/form-validation/*"
- "aio/content/examples/reactive-forms/*" - "aio/content/examples/reactive-forms/*"
- "aio/content/images/guide/reactive-forms/*"
users: users:
- kara #primary - kara #primary
- tinayuangao #secondary
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
- jenniferfell #docs only
http: http:
conditions: conditions:
files: files:
- "packages/common/http/*" - "packages/common/http/*"
- "packages/http/*" - "packages/http/*"
- "aio/content/guide/http.md"
- "aio/content/examples/http/*"
- "aio/content/images/guide/http/*"
users: users:
- alxhub #primary - alxhub #primary
- IgorMinar - IgorMinar
- mhevery #fallback - mhevery #fallback
- jenniferfell #docs only
language-service: language-service:
conditions: conditions:
files: files:
- "packages/language-service/*" - "packages/language-service/*"
- "aio/content/guide/language-service.md"
- "aio/content/images/guide/language-service/*"
users: users:
- kyliau #primary - kyliau #primary
# needs secondary # needs secondary
- vicb
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
- jenniferfell #docs only
router: router:
conditions: conditions:
files: files:
- "packages/router/*" - "packages/router/*"
- "aio/content/guide/router.md"
- "aio/content/examples/router/*"
- "aio/content/images/guide/router/*"
users: users:
- jasonaden #primary - jasonaden #primary
- vicb
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
- jenniferfell #docs only
testing:
conditions:
files:
- "*/testing/*"
- "aio/content/guide/testing.md"
- "aio/content/examples/testing/*"
- "aio/content/images/guide/testing/*"
users:
- vikerman
- IgorMinar #fallback
- mhevery #fallback
- jenniferfell #docs only
upgrade: upgrade:
conditions: conditions:
files: files:
- "packages/upgrade/*" - "packages/upgrade/*"
- "aio/content/guide/upgrade.md"
- "aio/content/examples/upgrade-module/*"
- "aio/content/images/guide/upgrade/*"
- "aio/content/examples/upgrade-phonecat-1-typescript/*"
- "aio/content/examples/upgrade-phonecat-2-hybrid/*"
- "aio/content/examples/upgrade-phonecat-3-final/*"
- "aio/content/guide/upgrade-performance.md"
- "aio/content/guide/ajs-quick-reference.md"
- "aio/content/examples/ajs-quick-reference/*"
users: users:
- petebacondarwin #primary - petebacondarwin #primary
- gkalpak - gkalpak
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
- jenniferfell #docs only
platform-browser: platform-browser:
conditions: conditions:
files: files:
- "packages/platform-browser/*" - "packages/platform-browser/*"
users: users:
- mhevery #primary - vicb #primary
# needs secondary # needs secondary
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback
platform-server: platform-server:
conditions: conditions:
files: files:
- "packages/platform-server/*" - "packages/platform-server/*"
- "aio/content/guide/universal.md"
- "aio/content/examples/universal/*"
users: users:
- vikerman #primary - vikerman #primary
- alxhub #secondary - alxhub #secondary
- vicb
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
- jenniferfell #docs only
platform-webworker: platform-webworker:
conditions: conditions:
files: files:
- "packages/platform-webworker/*" - "packages/platform-webworker/*"
users: users:
- mhevery #primary - vicb #primary
# needs secondary # needs secondary
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback
service-worker: service-worker:
conditions: conditions:
files: files:
- "packages/service-worker/*" - "packages/service-worker/*"
- "aio/content/guide/service-worker-getting-started.md"
- "aio/content/examples/service-worker-getting-started/*"
- "aio/content/guide/service-worker-communications.md"
- "aio/content/guide/service-worker-config.md"
- "aio/content/guide/service-worker-devops.md"
- "aio/content/guide/service-worker-intro.md"
- "aio/content/images/guide/service-worker/*"
users: users:
- gkalpak #primary - alxhub #primary
- alxhub - gkalpak
- IgorMinar - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
- jenniferfell #docs only
elements: elements:
conditions: conditions:
files: files:
- "packages/elements/*" - "packages/elements/*"
- "aio/content/examples/elements/*"
- "aio/content/images/guide/elements/*"
- "aio/content/guide/elements.md"
users: users:
- andrewseguin #primary - andrewseguin #primary
- gkalpak - gkalpak
- robwormald - robwormald
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
- jenniferfell #docs only
benchpress: benchpress:
conditions: conditions:
@ -439,7 +325,7 @@ groups:
- IgorMinar #fallback - IgorMinar #fallback
- mhevery #fallback - mhevery #fallback
docs-infra: angular.io:
conditions: conditions:
files: files:
include: include:
@ -452,7 +338,7 @@ groups:
- gkalpak - gkalpak
- mhevery #fallback - mhevery #fallback
docs/guide-and-tutorial: angular.io-guide-and-tutorial:
conditions: conditions:
files: files:
include: include:
@ -462,20 +348,19 @@ groups:
- "aio/content/navigation.json" - "aio/content/navigation.json"
- "aio/content/license.md" - "aio/content/license.md"
users: users:
- kapunahelewong
- stephenfluin - stephenfluin
- jenniferfell
- brandonroberts
- petebacondarwin - petebacondarwin
- gkalpak - gkalpak
- IgorMinar - IgorMinar
- brandonroberts
- mhevery #fallback - mhevery #fallback
docs/marketing: angular.io-marketing:
conditions: conditions:
files: files:
include: include:
- "aio/content/marketing/*" - "aio/content/marketing/*"
- "aio/content/images/marketing/*"
- "aio/content/navigation.json" - "aio/content/navigation.json"
- "aio/content/license.md" - "aio/content/license.md"
users: users:
@ -485,43 +370,3 @@ groups:
- IgorMinar - IgorMinar
- robwormald - robwormald
- mhevery #fallback - mhevery #fallback
docs/observables:
conditions:
files:
- "aio/content/examples/observables/*"
- "aio/content/images/guide/observables/*"
- "aio/content/guide/observables.md"
- "aio/content/guide/comparing-observables.md"
- "aio/content/examples/observables-in-angular/*"
- "aio/content/images/guide/observables-in-angular/*"
- "aio/content/guide/observables-in-angular.md"
- "aio/content/examples/practical-observable-usage/*"
- "aio/content/guide/practical-observable-usage.md"
- "aio/content/examples/rx-library/*"
- "aio/content/guide/rx-library.md"
users:
- jasonaden
- benlesh
- IgorMinar
- mhevery
- jenniferfell #docs only
docs/packaging:
conditions:
files:
- "aio/content/guide/npm-packages.md"
- "aio/content/guide/browser-support.md"
- "aio/content/guide/typescript-configuration.md"
- "aio/content/guide/setup-systemjs-anatomy.md"
- "aio/content/examples/setup/*"
- "aio/content/guide/setup.md"
- "aio/content/guide/deployment.md"
- "aio/content/guide/releases.md"
- "aio/content/guide/updating.md"
users:
- IgorMinar #primary
- alexeagle
- hansl
- mhevery #fallback
- jenniferfell #docs only

View File

@ -13,7 +13,11 @@ addons:
packages: packages:
# needed to install g++ that is used by npms's native modules # needed to install g++ that is used by npms's native modules
- g++-4.8 - 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: branches:
except: except:
- g3 - g3

View File

@ -5,41 +5,53 @@ load("@build_bazel_rules_nodejs//:defs.bzl", "node_modules_filegroup")
exports_files([ exports_files([
"tsconfig.json", "tsconfig.json",
"LICENSE", "LICENSE",
"protractor-perf.conf.js",
]) ])
# Developers should always run `bazel run :install` # Developers should always run `bazel run :install`
# This ensures that package.json in subdirectories get installed as well. # This ensures that package.json in subdirectories get installed as well.
alias( alias(
name = "install", name = "install",
actual = "@nodejs//:yarn", actual = "@yarn//:yarn",
) )
alias( node_modules_filegroup(
name = "node_modules", name = "node_modules",
actual = "@angular_deps//:node_modules", packages = [
"bytebuffer",
"hammerjs",
"jasmine",
"minimist",
"protobufjs",
"reflect-metadata",
"source-map-support",
"tsickle",
"tslib",
"tsutils",
"typescript",
"zone.js",
"@angular-devkit/core",
"@angular-devkit/schematics",
"@types",
"@webcomponents/custom-elements",
],
) )
filegroup( filegroup(
name = "web_test_bootstrap_scripts", name = "web_test_bootstrap_scripts",
# do not sort # do not sort
srcs = [ srcs = [
"@angular_deps//:node_modules/reflect-metadata/Reflect.js", "//:node_modules/reflect-metadata/Reflect.js",
"@angular_deps//:node_modules/zone.js/dist/zone.js", "//:node_modules/zone.js/dist/zone.js",
"@angular_deps//:node_modules/zone.js/dist/zone-testing.js", "//:node_modules/zone.js/dist/zone-testing.js",
"@angular_deps//:node_modules/zone.js/dist/task-tracking.js", "//:node_modules/zone.js/dist/task-tracking.js",
"//:test-events.js",
], ],
) )
filegroup( filegroup(
name = "angularjs_scripts", name = "angularjs",
# do not sort
srcs = [ srcs = [
"@angular_deps//:node_modules/angular-1.5/angular.js", "//:node_modules/angular/angular.js",
"@angular_deps//:node_modules/angular-1.6/angular.js", "//:node_modules/angular-mocks/angular-mocks.js",
"@angular_deps//:node_modules/angular-mocks-1.5/angular-mocks.js",
"@angular_deps//:node_modules/angular-mocks-1.6/angular-mocks.js",
"@angular_deps//:node_modules/angular-mocks/angular-mocks.js",
"@angular_deps//:node_modules/angular/angular.js",
], ],
) )

View File

@ -1,245 +1,3 @@
<a name="6.1.5"></a>
## [6.1.5](https://github.com/angular/angular/compare/6.1.4...6.1.5) (2018-08-29)
### Bug Fixes
* **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))
<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)
### Bug Fixes
* **service-worker:** `Cache-Control: no-cache` on assets breaks service worker ([#25408](https://github.com/angular/angular/issues/25408)) ([1319ff4](https://github.com/angular/angular/commit/1319ff4)), closes [#25442](https://github.com/angular/angular/issues/25442)
<a name="6.1.2"></a>
## [6.1.2](https://github.com/angular/angular/compare/6.1.1...6.1.2) (2018-08-08)
### Bug Fixes
* **router:** take base uri into account in `setUpLocationSync()` ([#20244](https://github.com/angular/angular/issues/20244)) ([ae9b4e6](https://github.com/angular/angular/commit/ae9b4e6)), closes [#20061](https://github.com/angular/angular/issues/20061)
* add mappings for ngfactory & ngsummary files to their module names in aot summary resolver ([#25335](https://github.com/angular/angular/issues/25335)) ([054fbbe](https://github.com/angular/angular/commit/054fbbe))
<a name="6.1.1"></a>
## [6.1.1](https://github.com/angular/angular/compare/6.1.0...6.1.1) (2018-08-02)
### Bug Fixes
* **compiler-cli:** correct tsickle dependency version to fix typescript 2.9 compatibility ([fec29fa](https://github.com/angular/angular/commit/317c7087c56b72aa74cd6d6a8f719e6e7fec29fa))
<a name="6.1.0"></a>
# [6.1.0](https://github.com/angular/angular/compare/6.0.0-rc.5...6.1.0) (2018-07-25)
### Bug Fixes
* **animations:** always render end-state styles for orphaned DOM nodes ([#24236](https://github.com/angular/angular/issues/24236)) ([dc4a3d0](https://github.com/angular/angular/commit/dc4a3d0))
* **animations:** set animations styles properly on platform-server ([#24624](https://github.com/angular/angular/issues/24624)) ([0b356d4](https://github.com/angular/angular/commit/0b356d4))
* **animations:** do not throw errors when a destroyed component is animated ([#23836](https://github.com/angular/angular/issues/23836)) ([d2a8687](https://github.com/angular/angular/commit/d2a8687))
* **animations:** Fix browser detection logic ([#24188](https://github.com/angular/angular/issues/24188)) ([b492b9e](https://github.com/angular/angular/commit/b492b9e))
* **animations:** properly clean up queried element styles in safari/edge ([#23633](https://github.com/angular/angular/issues/23633)) ([da9ff25](https://github.com/angular/angular/commit/da9ff25))
* **animations:** retain state styling for nodes that are moved around ([#23534](https://github.com/angular/angular/issues/23534)) ([65211f4](https://github.com/angular/angular/commit/65211f4))
* **animations:** retain trigger-state for nodes that are moved around ([#24238](https://github.com/angular/angular/issues/24238)) ([8db928d](https://github.com/angular/angular/commit/8db928d))
* **bazel:** Allow ng_module to depend on targets w no deps ([#24446](https://github.com/angular/angular/issues/24446)) ([282d351](https://github.com/angular/angular/commit/282d351))
* **benchpress:** Fix promise chain in chrome_driver_extension. ([#23458](https://github.com/angular/angular/issues/23458)) ([d4b6c41](https://github.com/angular/angular/commit/d4b6c41))
* **common:** do not round factional seconds ([#24831](https://github.com/angular/angular/issues/24831)) ([a527c69](https://github.com/angular/angular/commit/a527c69)), closes [#24384](https://github.com/angular/angular/issues/24384)
* **common:** format fractional seconds ([#24844](https://github.com/angular/angular/issues/24844)) ([0b4d85e](https://github.com/angular/angular/commit/0b4d85e)), closes [#24831](https://github.com/angular/angular/issues/24831)
* **common:** properly update collection reference in NgForOf ([#24684](https://github.com/angular/angular/issues/24684)) ([ff84c5c](https://github.com/angular/angular/commit/ff84c5c)), closes [#24155](https://github.com/angular/angular/issues/24155)
* **common:** use correct currency format for locale de-AT ([#24658](https://github.com/angular/angular/issues/24658)) ([dcabb05](https://github.com/angular/angular/commit/dcabb05)), closes [#24609](https://github.com/angular/angular/issues/24609)
* **common:** use correct ICU plural for locale mk ([#24659](https://github.com/angular/angular/issues/24659)) ([64a8584](https://github.com/angular/angular/commit/64a8584))
* **compiler:** fix a few non-tree-shakeable code patterns ([#24677](https://github.com/angular/angular/issues/24677)) ([50d4a4f](https://github.com/angular/angular/commit/50d4a4f))
* **compiler:** i18n_extractor now outputs the correct source file name ([#24885](https://github.com/angular/angular/issues/24885)) ([c8ad965](https://github.com/angular/angular/commit/c8ad965)), closes [#24884](https://github.com/angular/angular/issues/24884)
* **compiler:** support `.` in import statements. ([#20634](https://github.com/angular/angular/issues/20634)) ([d8f7b29](https://github.com/angular/angular/commit/d8f7b29)), closes [#20363](https://github.com/angular/angular/issues/20363)
* **compiler:** avoid a crash in ngc-wrapped. ([#23468](https://github.com/angular/angular/issues/23468)) ([e1c4930](https://github.com/angular/angular/commit/e1c4930))
* **compiler:** generate constant array for i18n attributes ([#23837](https://github.com/angular/angular/issues/23837)) ([cfde36d](https://github.com/angular/angular/commit/cfde36d))
* **compiler:** generate core-compliant hostBindings property ([#24087](https://github.com/angular/angular/issues/24087)) ([01b5acd](https://github.com/angular/angular/commit/01b5acd)), closes [#24013](https://github.com/angular/angular/issues/24013)
* **compiler:** handle undefined annotation metadata ([#23349](https://github.com/angular/angular/issues/23349)) ([ca776c5](https://github.com/angular/angular/commit/ca776c5))
* **compiler-cli:** Use typescript to resolve modules for metadata ([#22856](https://github.com/angular/angular/issues/22856)) ([0d5f2d3](https://github.com/angular/angular/commit/0d5f2d3))
* **compiler-cli:** don't rely on incompatible TS method ([#23550](https://github.com/angular/angular/issues/23550)) ([b1f040f](https://github.com/angular/angular/commit/b1f040f))
* **core:** stop reusing provider definitions across NgModuleRef instances ([#25022](https://github.com/angular/angular/issues/25022)) ([6b859da](https://github.com/angular/angular/commit/6b859da)), closes [#25018](https://github.com/angular/angular/issues/25018)
* **core:** mark NgModule as not the root if APP_ROOT is set to false ([#24814](https://github.com/angular/angular/issues/24814)) ([1089261](https://github.com/angular/angular/commit/1089261))
* **core:** use addCustomEqualityTester instead of overriding toEqual ([#22983](https://github.com/angular/angular/issues/22983)) ([0922228](https://github.com/angular/angular/commit/0922228)), closes [#22939](https://github.com/angular/angular/issues/22939)
* **core:** Injector correctly honors the @Self flag ([#24520](https://github.com/angular/angular/issues/24520)) ([ccbda9d](https://github.com/angular/angular/commit/ccbda9d))
* **core:** avoid eager providers re-initialization ([#23559](https://github.com/angular/angular/issues/23559)) ([0c6dc45](https://github.com/angular/angular/commit/0c6dc45))
* **core:** call ngOnDestroy on all services that have it ([#23755](https://github.com/angular/angular/issues/23755)) ([fc03427](https://github.com/angular/angular/commit/fc03427)), closes [#22466](https://github.com/angular/angular/issues/22466) [#22240](https://github.com/angular/angular/issues/22240) [#14818](https://github.com/angular/angular/issues/14818)
* **docs-infra:** fix table header layout in API pages ([#24919](https://github.com/angular/angular/issues/24919)) ([3cd9645](https://github.com/angular/angular/commit/3cd9645))
* **elements:** always check to create strategy ([#23825](https://github.com/angular/angular/issues/23825)) ([b1cda36](https://github.com/angular/angular/commit/b1cda36))
* **elements:** prevent closure renaming of platform properties ([#23843](https://github.com/angular/angular/issues/23843)) ([d4b8b24](https://github.com/angular/angular/commit/d4b8b24))
* **forms:** properly handle special properties in FormGroup.get ([#22249](https://github.com/angular/angular/issues/22249)) ([9367e91](https://github.com/angular/angular/commit/9367e91)), closes [#17195](https://github.com/angular/angular/issues/17195)
* **language-service:** do not overwrite native `Reflect` ([#24299](https://github.com/angular/angular/issues/24299)) ([6881404](https://github.com/angular/angular/commit/6881404)), closes [#21420](https://github.com/angular/angular/issues/21420)
* **platform-browser:** add missing deps for HammerGesturesPlugin ([#24682](https://github.com/angular/angular/issues/24682)) ([13d60ea](https://github.com/angular/angular/commit/13d60ea))
* **platform-browser:** mark Meta and Title services as tree shakable providers ([#24815](https://github.com/angular/angular/issues/24815)) ([197387d](https://github.com/angular/angular/commit/197387d))
* **platform-browser:** workaround wrong import path generated by ngc for DOCUMENT ([#24830](https://github.com/angular/angular/issues/24830)) ([7d27ecc](https://github.com/angular/angular/commit/7d27ecc))
* **platform-server:** avoid clash between server and client style encapsulation attributes ([#24158](https://github.com/angular/angular/issues/24158)) ([b96a3c8](https://github.com/angular/angular/commit/b96a3c8))
* **platform-server:** avoid dependency cycle when using http interceptor ([#24229](https://github.com/angular/angular/issues/24229)) ([60aa943](https://github.com/angular/angular/commit/60aa943)), closes [#23023](https://github.com/angular/angular/issues/23023)
* **platform-server:** don't reflect innerHTML property to attribute ([#24213](https://github.com/angular/angular/issues/24213)) ([6a663a4](https://github.com/angular/angular/commit/6a663a4)), closes [#19278](https://github.com/angular/angular/issues/19278)
* **platform-server:** provide Domino DOM types globally ([#24116](https://github.com/angular/angular/issues/24116)) ([c73196e](https://github.com/angular/angular/commit/c73196e)), closes [#23280](https://github.com/angular/angular/issues/23280) [#23133](https://github.com/angular/angular/issues/23133)
* **router:** Fix _lastPathIndex in deeply nested empty paths ([#22394](https://github.com/angular/angular/issues/22394)) ([968f153](https://github.com/angular/angular/commit/968f153))
* **router:** add ability to recover from malformed url ([#23283](https://github.com/angular/angular/issues/23283)) ([86d254d](https://github.com/angular/angular/commit/86d254d)), closes [#21468](https://github.com/angular/angular/issues/21468)
* **router:** fix lazy loading of aux routes ([#23459](https://github.com/angular/angular/issues/23459)) ([5731d07](https://github.com/angular/angular/commit/5731d07)), closes [#10981](https://github.com/angular/angular/issues/10981)
* **router:** avoid freezing queryParams in-place ([#22663](https://github.com/angular/angular/issues/22663)) ([89f64e5](https://github.com/angular/angular/commit/89f64e5)), closes [#22617](https://github.com/angular/angular/issues/22617)
* **router:** cache route handle if found ([#22475](https://github.com/angular/angular/issues/22475)) ([4cfa571](https://github.com/angular/angular/commit/4cfa571)), closes [#22474](https://github.com/angular/angular/issues/22474)
* **router:** correct the segment parsing so it won't break on ampersand ([#23684](https://github.com/angular/angular/issues/23684)) ([553a680](https://github.com/angular/angular/commit/553a680))
* **service-worker:** don't include sourceMappingURL in ngsw-worker ([#24877](https://github.com/angular/angular/issues/24877)) ([8620373](https://github.com/angular/angular/commit/8620373)), closes [#23596](https://github.com/angular/angular/issues/23596)
* **service-worker:** avoid network requests when looking up hashed resources in cache ([#24127](https://github.com/angular/angular/issues/24127)) ([52d43a9](https://github.com/angular/angular/commit/52d43a9))
* **service-worker:** fix `SwPush.unsubscribe()` ([#24162](https://github.com/angular/angular/issues/24162)) ([3ed2d75](https://github.com/angular/angular/commit/3ed2d75)), closes [#24095](https://github.com/angular/angular/issues/24095)
* **service-worker:** add badge to NOTIFICATION_OPTION_NAMES ([#23241](https://github.com/angular/angular/issues/23241)) ([fb59b2d](https://github.com/angular/angular/commit/fb59b2d)), closes [#23196](https://github.com/angular/angular/issues/23196)
* **service-worker:** check platformBrowser before accessing navigator.serviceWorker ([#21231](https://github.com/angular/angular/issues/21231)) ([0bdd30e](https://github.com/angular/angular/commit/0bdd30e))
* **service-worker:** correctly handle requests with empty `clientId` ([#23625](https://github.com/angular/angular/issues/23625)) ([e0ed59e](https://github.com/angular/angular/commit/e0ed59e)), closes [#23526](https://github.com/angular/angular/issues/23526)
* **service-worker:** deprecate `versionedFiles` in asset-group resources ([#23584](https://github.com/angular/angular/issues/23584)) ([1d378e2](https://github.com/angular/angular/commit/1d378e2))
### Features
* **bazel:** Initial commit of protractor_web_test_suite ([#24787](https://github.com/angular/angular/issues/24787)) ([71e0df0](https://github.com/angular/angular/commit/71e0df0))
* **bazel:** protractor_web_test_suite for release ([#24787](https://github.com/angular/angular/issues/24787)) ([161ff5c](https://github.com/angular/angular/commit/161ff5c))
* **common:** introduce KeyValuePipe ([#24319](https://github.com/angular/angular/issues/24319)) ([2b49bf7](https://github.com/angular/angular/commit/2b49bf7))
* **compiler:** support `// ...` and `// TODO` in mock compiler expectations ([#23441](https://github.com/angular/angular/issues/23441)) ([c6b206e](https://github.com/angular/angular/commit/c6b206e))
* **compiler-cli:** update `tsickle` to `0.29.x` ([#24233](https://github.com/angular/angular/issues/24233)) ([f69ac67](https://github.com/angular/angular/commit/f69ac67))
* **core:** export defaultKeyValueDiffers to private api ([#24319](https://github.com/angular/angular/issues/24319)) ([92b278c](https://github.com/angular/angular/commit/92b278c))
* **core:** expose a Compiler API for accessing module ids from NgModule types ([#24258](https://github.com/angular/angular/issues/24258)) ([bd02b27](https://github.com/angular/angular/commit/bd02b27))
* **core:** KeyValueDiffer#diff allows null values ([#24319](https://github.com/angular/angular/issues/24319)) ([52ce9d5](https://github.com/angular/angular/commit/52ce9d5))
* **core:** add support for ShadowDOM v1 ([#24718](https://github.com/angular/angular/issues/24718)) ([3553977](https://github.com/angular/angular/commit/3553977))
(https://github.com/angular/angular/commit/328971f)), closes [#24616](https://github.com/angular/angular/issues/24616)
* **platform-browser:** add HammerJS lazy-loader symbols to public API ([#23943](https://github.com/angular/angular/issues/23943)) ([26fbf1d](https://github.com/angular/angular/commit/26fbf1d))
* **platform-browser:** allow lazy-loading HammerJS ([#23906](https://github.com/angular/angular/issues/23906)) ([313bdce](https://github.com/angular/angular/commit/313bdce))
* **platform-server:** use EventManagerPlugin on the server ([#24132](https://github.com/angular/angular/issues/24132)) ([d6595eb](https://github.com/angular/angular/commit/d6595eb))
* **router:** add urlUpdateStrategy allow updating the browser URL at the beginning of navigation ([#24820](https://github.com/angular/angular/issues/24820)) ([328971f]
* **router:** add navigation execution context info to activation hooks ([#24204](https://github.com/angular/angular/issues/24204)) ([20c463e](https://github.com/angular/angular/commit/20c463e)), closes [#24202](https://github.com/angular/angular/issues/24202)
* **router:** implement scrolling restoration service ([#20030](https://github.com/angular/angular/issues/20030)) ([49c5234](https://github.com/angular/angular/commit/49c5234)), closes [#13636](https://github.com/angular/angular/issues/13636) [#10929](https://github.com/angular/angular/issues/10929) [#7791](https://github.com/angular/angular/issues/7791) [#6595](https://github.com/angular/angular/issues/6595)
* **service-worker:** add support for `?` in SW config globbing ([#24105](https://github.com/angular/angular/issues/24105)) ([250527c](https://github.com/angular/angular/commit/250527c))
* typescript 2.9 support ([#24652](https://github.com/angular/angular/issues/24652)) ([e3064d5](https://github.com/angular/angular/commit/e3064d5))
### build
* **bazel:** turn on preserve-symlinks ([#24881](https://github.com/angular/angular/issues/24881)) ([c438b5e](https://github.com/angular/angular/commit/c438b5e))
### Angular Labs (experimental feature) breaking change
* **bazel:** Use of @angular/bazel rules now requires calling ng_setup_workspace() in your WORKSPACE file.
For example:
local_repository(
name = "angular",
path = "node_modules/@angular/bazel",
)
load("@angular//:index.bzl", "ng_setup_workspace")
ng_setup_workspace()
<a name="6.0.9"></a>
## [6.0.9](https://github.com/angular/angular/compare/6.0.8...6.0.9) (2018-07-11)
### Bug Fixes
* **common:** format fractional seconds ([#24844](https://github.com/angular/angular/issues/24844)) ([3c93d07](https://github.com/angular/angular/commit/3c93d07)), closes [#24831](https://github.com/angular/angular/issues/24831)
<a name="6.0.8"></a>
## [6.0.8](https://github.com/angular/angular/compare/6.0.7...6.0.8) (2018-07-11)
### Bug Fixes
* **common:** do not round factional seconds ([#24831](https://github.com/angular/angular/issues/24831)) ([0746485](https://github.com/angular/angular/commit/0746485)), closes [#24384](https://github.com/angular/angular/issues/24384)
* **common:** properly update collection reference in NgForOf ([#24684](https://github.com/angular/angular/issues/24684)) ([9a98de9](https://github.com/angular/angular/commit/9a98de9)), closes [#24155](https://github.com/angular/angular/issues/24155)
* **common:** use correct currency format for locale de-AT ([#24658](https://github.com/angular/angular/issues/24658)) ([a92f111](https://github.com/angular/angular/commit/a92f111)), closes [#24609](https://github.com/angular/angular/issues/24609)
* **compiler-cli:** Use typescript to resolve modules for metadata ([#22856](https://github.com/angular/angular/issues/22856)) ([7717ff1](https://github.com/angular/angular/commit/7717ff1))
* **core:** use addCustomEqualityTester instead of overriding toEqual ([#22983](https://github.com/angular/angular/issues/22983)) ([b8975a9](https://github.com/angular/angular/commit/b8975a9)), closes [#22939](https://github.com/angular/angular/issues/22939)
* **language-service:** do not overwrite native `Reflect` ([#24299](https://github.com/angular/angular/issues/24299)) ([de1c44f](https://github.com/angular/angular/commit/de1c44f)), closes [#21420](https://github.com/angular/angular/issues/21420)
* **router:** add ability to recover from malformed url ([#23283](https://github.com/angular/angular/issues/23283)) ([2d4f4b5](https://github.com/angular/angular/commit/2d4f4b5)), closes [#21468](https://github.com/angular/angular/issues/21468)
* **service-worker:** avoid network requests when looking up hashed resources in cache ([#24127](https://github.com/angular/angular/issues/24127)) ([183b079](https://github.com/angular/angular/commit/183b079))
### Features
* **core:** add support for ShadowDOM v1 ([#24718](https://github.com/angular/angular/issues/24718)) ([6c55a13](https://github.com/angular/angular/commit/6c55a13))
<a name="6.0.7"></a>
## [6.0.7](https://github.com/angular/angular/compare/6.0.6...6.0.7) (2018-06-27)
### Bug Fixes
* **animations:** set animations styles properly on platform-server ([#24624](https://github.com/angular/angular/issues/24624)) ([0b356d4](https://github.com/angular/angular/commit/0b356d4))
* **common:** use correct ICU plural for locale mk ([#24659](https://github.com/angular/angular/issues/24659)) ([64a8584](https://github.com/angular/angular/commit/64a8584))
<a name="6.0.6"></a>
## [6.0.6](https://github.com/angular/angular/compare/6.0.5...6.0.6) (2018-06-20)
### Bug Fixes
* **compiler:** support `.` in import statements. ([#20634](https://github.com/angular/angular/issues/20634)) ([e543c73](https://github.com/angular/angular/commit/e543c73)), closes [#20363](https://github.com/angular/angular/issues/20363)
* **core:** Injector correctly honors the @Self flag ([#24520](https://github.com/angular/angular/issues/24520)) ([f5b3661](https://github.com/angular/angular/commit/f5b3661))
<a name="6.0.5"></a>
## [6.0.5](https://github.com/angular/angular/compare/6.0.4...6.0.5) (2018-06-13)
* **animations:** always render end-state styles for orphaned DOM nodes ([#24236](https://github.com/angular/angular/issues/24236)) ([0139173](https://github.com/angular/angular/commit/0139173))
* **bazel:** Allow ng_module to depend on targets w no deps ([#24446](https://github.com/angular/angular/issues/24446)) ([ea3669e](https://github.com/angular/angular/commit/ea3669e))
* **docs-infra:** use script nomodule to load IE polyfills, skip other polyfills ([#24317](https://github.com/angular/angular/issues/24317)) ([e876535](https://github.com/angular/angular/commit/e876535)), closes [#23647](https://github.com/angular/angular/issues/23647)
* **router:** fix lazy loading of aux routes ([#23459](https://github.com/angular/angular/issues/23459)) ([d20877b](https://github.com/angular/angular/commit/d20877b)), closes [#10981](https://github.com/angular/angular/issues/10981)
* **service-worker:** fix `SwPush.unsubscribe()` ([#24162](https://github.com/angular/angular/issues/24162)) ([ea2987c](https://github.com/angular/angular/commit/ea2987c)), closes [#24095](https://github.com/angular/angular/issues/24095)
<a name="6.0.4"></a>
## [6.0.4](https://github.com/angular/angular/compare/6.0.3...6.0.4) (2018-06-06)
### Bug Fixes
* **animations:** Fix browser detection logic ([#24188](https://github.com/angular/angular/issues/24188)) ([c9eb491](https://github.com/angular/angular/commit/c9eb491))
* **animations:** retain trigger-state for nodes that are moved around ([#24238](https://github.com/angular/angular/issues/24238)) ([19deca1](https://github.com/angular/angular/commit/19deca1))
* **forms:** properly handle special properties in FormGroup.get ([#22249](https://github.com/angular/angular/issues/22249)) ([dc3e8aa](https://github.com/angular/angular/commit/dc3e8aa)), closes [#17195](https://github.com/angular/angular/issues/17195)
* **platform-server:** avoid clash between server and client style encapsulation attributes ([#24158](https://github.com/angular/angular/issues/24158)) ([e9f2203](https://github.com/angular/angular/commit/e9f2203))
* **platform-server:** avoid dependency cycle when using http interceptor ([#24229](https://github.com/angular/angular/issues/24229)) ([2991b1b](https://github.com/angular/angular/commit/2991b1b)), closes [#23023](https://github.com/angular/angular/issues/23023)
* **platform-server:** don't reflect innerHTML property to attibute ([#24213](https://github.com/angular/angular/issues/24213)) ([c17098d](https://github.com/angular/angular/commit/c17098d)), closes [#19278](https://github.com/angular/angular/issues/19278)
* **platform-server:** provide Domino DOM types globally ([#24116](https://github.com/angular/angular/issues/24116)) ([906b3ec](https://github.com/angular/angular/commit/906b3ec)), closes [#23280](https://github.com/angular/angular/issues/23280) [#23133](https://github.com/angular/angular/issues/23133)
<a name="6.0.3"></a>
## [6.0.3](https://github.com/angular/angular/compare/6.0.2...6.0.3) (2018-05-22)
### Bug Fixes
* **service-worker:** check platformBrowser before accessing navigator.serviceWorker ([#21231](https://github.com/angular/angular/issues/21231)) ([0ee5b7e](https://github.com/angular/angular/commit/0ee5b7e))
<a name="6.0.2"></a>
## [6.0.2](https://github.com/angular/angular/compare/6.0.1...6.0.2) (2018-05-15)
### Bug Fixes
* **animations:** do not throw errors when a destroyed component is animated ([#23836](https://github.com/angular/angular/issues/23836)) ([752b83a](https://github.com/angular/angular/commit/752b83a))
* **service-worker:** deprecate `versionedFiles` in asset-group resources ([#23584](https://github.com/angular/angular/issues/23584)) ([c6b618d](https://github.com/angular/angular/commit/c6b618d))
<a name="6.0.1"></a> <a name="6.0.1"></a>
# [6.0.1](https://github.com/angular/angular/compare/6.0.0...6.0.1) (2018-05-11) # [6.0.1](https://github.com/angular/angular/compare/6.0.0...6.0.1) (2018-05-11)
@ -282,6 +40,7 @@ To learn about the release highlights and our new CLI-powered update workflow fo
* **animations:** only use the WA-polyfill alongside AnimationBuilder ([#22143](https://github.com/angular/angular/issues/22143)) ([b2f366b](https://github.com/angular/angular/commit/b2f366b)), closes [#17496](https://github.com/angular/angular/issues/17496) * **animations:** only use the WA-polyfill alongside AnimationBuilder ([#22143](https://github.com/angular/angular/issues/22143)) ([b2f366b](https://github.com/angular/angular/commit/b2f366b)), closes [#17496](https://github.com/angular/angular/issues/17496)
* **animations:** expose `element` and `params` within transition matchers ([#22693](https://github.com/angular/angular/issues/22693)) ([58b94e6](https://github.com/angular/angular/commit/58b94e6)) * **animations:** expose `element` and `params` within transition matchers ([#22693](https://github.com/angular/angular/issues/22693)) ([58b94e6](https://github.com/angular/angular/commit/58b94e6))
* **common:** better error message when non-template element used in NgIf ([#22274](https://github.com/angular/angular/issues/22274)) ([67cf11d](https://github.com/angular/angular/commit/67cf11d)), closes [#16410](https://github.com/angular/angular/issues/16410) * **common:** better error message when non-template element used in NgIf ([#22274](https://github.com/angular/angular/issues/22274)) ([67cf11d](https://github.com/angular/angular/commit/67cf11d)), closes [#16410](https://github.com/angular/angular/issues/16410)
* **common:** better error message when non-template element used in NgIf ([#22274](https://github.com/angular/angular/issues/22274)) ([67cf11d](https://github.com/angular/angular/commit/67cf11d)), closes [#16410](https://github.com/angular/angular/issues/16410)
* **common:** export functions to format numbers, percents, currencies & dates ([#22423](https://github.com/angular/angular/issues/22423)) ([4180912](https://github.com/angular/angular/commit/4180912)), closes [#20536](https://github.com/angular/angular/issues/20536) * **common:** export functions to format numbers, percents, currencies & dates ([#22423](https://github.com/angular/angular/issues/22423)) ([4180912](https://github.com/angular/angular/commit/4180912)), closes [#20536](https://github.com/angular/angular/issues/20536)
* **compiler:** lower @NgModule ids if needed ([#23031](https://github.com/angular/angular/issues/23031)) ([bd024c0](https://github.com/angular/angular/commit/bd024c0)) * **compiler:** lower @NgModule ids if needed ([#23031](https://github.com/angular/angular/issues/23031)) ([bd024c0](https://github.com/angular/angular/commit/bd024c0))
* **compiler:** implement "enableIvy" compiler option ([#21427](https://github.com/angular/angular/issues/21427)) ([64d16de](https://github.com/angular/angular/commit/64d16de)) * **compiler:** implement "enableIvy" compiler option ([#21427](https://github.com/angular/angular/issues/21427)) ([64d16de](https://github.com/angular/angular/commit/64d16de))
@ -321,6 +80,7 @@ To learn about the release highlights and our new CLI-powered update workflow fo
* **animations:** report correct totalTime value even during noOp animations ([#22225](https://github.com/angular/angular/issues/22225)) ([e1bf067](https://github.com/angular/angular/commit/e1bf067)) * **animations:** report correct totalTime value even during noOp animations ([#22225](https://github.com/angular/angular/issues/22225)) ([e1bf067](https://github.com/angular/angular/commit/e1bf067))
* **animations:** avoid animation insertions during router back/refresh ([#21977](https://github.com/angular/angular/issues/21977)) ([f88fba0](https://github.com/angular/angular/commit/f88fba0)), closes [#19712](https://github.com/angular/angular/issues/19712) * **animations:** avoid animation insertions during router back/refresh ([#21977](https://github.com/angular/angular/issues/21977)) ([f88fba0](https://github.com/angular/angular/commit/f88fba0)), closes [#19712](https://github.com/angular/angular/issues/19712)
* **animations:** treat numeric state name values as strings ([#22923](https://github.com/angular/angular/issues/22923)) ([e5e1b0d](https://github.com/angular/angular/commit/e5e1b0d)) * **animations:** treat numeric state name values as strings ([#22923](https://github.com/angular/angular/issues/22923)) ([e5e1b0d](https://github.com/angular/angular/commit/e5e1b0d))
* **animations:** report correct totalTime value even during noOp animations ([#22225](https://github.com/angular/angular/issues/22225)) ([e1bf067](https://github.com/angular/angular/commit/e1bf067))
* **animations:** fix increment/decrement aliases example ([#18323](https://github.com/angular/angular/issues/18323)) ([d2aa8ac](https://github.com/angular/angular/commit/d2aa8ac)) * **animations:** fix increment/decrement aliases example ([#18323](https://github.com/angular/angular/issues/18323)) ([d2aa8ac](https://github.com/angular/angular/commit/d2aa8ac))
* **common:** NgClass should properly take className changes into account ([#21937](https://github.com/angular/angular/issues/21937)) ([4a42669](https://github.com/angular/angular/commit/4a42669)), closes [#21932](https://github.com/angular/angular/issues/21932) * **common:** NgClass should properly take className changes into account ([#21937](https://github.com/angular/angular/issues/21937)) ([4a42669](https://github.com/angular/angular/commit/4a42669)), closes [#21932](https://github.com/angular/angular/issues/21932)
* **common:** fix the titlecase pipe ([#22600](https://github.com/angular/angular/issues/22600)) ([7966744](https://github.com/angular/angular/commit/7966744)) * **common:** fix the titlecase pipe ([#22600](https://github.com/angular/angular/issues/22600)) ([7966744](https://github.com/angular/angular/commit/7966744))

View File

@ -227,15 +227,10 @@ The following is the list of supported scopes:
There are currently a few exceptions to the "use package name" rule: There are currently a few exceptions to the "use package name" rule:
* **packaging**: used for changes that change the npm package layout in all of our packages, e.g. * **packaging**: used for changes that change the npm package layout in all of our packages, e.g. public path changes, package.json changes done to all packages, d.ts file/format changes, changes to bundles, etc.
public path changes, package.json changes done to all packages, d.ts file/format changes, changes
to bundles, etc.
* **changelog**: used for updating the release notes in CHANGELOG.md * **changelog**: used for updating the release notes in CHANGELOG.md
* **docs-infra**: used for docs-app (angular.io) related changes within the /aio directory of the * **aio**: used for docs-app (angular.io) related changes within the /aio directory of the repo
repo * none/empty string: useful for `style`, `test` and `refactor` changes that are done across all packages (e.g. `style: add missing semicolons`)
* none/empty string: useful for `style`, `test` and `refactor` changes that are done across all
packages (e.g. `style: add missing semicolons`) and for docs changes that are not related to a
specific package (e.g. `docs: fix typo in tutorial`).
### Subject ### Subject
The subject contains a succinct description of the change: The subject contains a succinct description of the change:

View File

@ -3,39 +3,26 @@ workspace(name = "angular")
# #
# Download Bazel toolchain dependencies as needed by build actions # 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.12.0.zip"],
strip_prefix = "rules_nodejs-0.12.0",
sha256 = "2977cdbc8ae0eed7d4186385af56a50a3321a549e2136a959998bba89d2edb6e",
)
http_archive( http_archive(
name = "build_bazel_rules_nodejs", name = "build_bazel_rules_nodejs",
urls = ["https://github.com/bazelbuild/rules_nodejs/archive/0.11.4.zip"], url = "https://github.com/bazelbuild/rules_nodejs/archive/0.8.0.zip",
strip_prefix = "rules_nodejs-0.11.4", strip_prefix = "rules_nodejs-0.8.0",
sha256 = "c31c4ead696944a50fad2b3ee9dfbbeffe31a8dcca0b21b9bf5b3e6c6b069801", sha256 = "4e40dd49ae7668d245c3107645f2a138660fcfd975b9310b91eda13f0c973953",
)
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( http_archive(
name = "io_bazel_rules_webtesting", name = "io_bazel_rules_webtesting",
url = "https://github.com/bazelbuild/rules_webtesting/archive/0.2.1.zip", url = "https://github.com/bazelbuild/rules_webtesting/archive/ca7b8062d9cf4ef2fde9193c7d37a0764c4262d7.zip",
strip_prefix = "rules_webtesting-0.2.1", strip_prefix = "rules_webtesting-ca7b8062d9cf4ef2fde9193c7d37a0764c4262d7",
sha256 = "7d490aadff9b5262e5251fa69427ab2ffd1548422467cb9f9e1d110e2c36f0fa", sha256 = "28c73cf9d310fa6dba30e66bdb98071341c99c3feb8662f2d3883a632de97d72",
) )
http_archive( http_archive(
name = "build_bazel_rules_typescript", name = "build_bazel_rules_typescript",
url = "https://github.com/bazelbuild/rules_typescript/archive/0.16.0.zip", url = "https://github.com/bazelbuild/rules_typescript/archive/v0.13.0.zip",
strip_prefix = "rules_typescript-0.16.0", strip_prefix = "rules_typescript-0.13.0",
sha256 = "e65c5639a42e2f6d3f9d2bda62487d6b42734830dda45be1620c3e2b1115070c", sha256 = "8f2767ff56ad68c80c62e9a1cdc2ba2c2ba0b19d350f713365e5333045df02e3",
) )
http_archive( http_archive(
@ -47,13 +34,13 @@ http_archive(
# This commit matches the version of buildifier in angular/ngcontainer # 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 # If you change this, also check if it matches the version in the angular/ngcontainer
# version in /.circleci/config.yml # version in /.circleci/config.yml
BAZEL_BUILDTOOLS_VERSION = "82b21607e00913b16fe1c51bec80232d9d6de31c" BAZEL_BUILDTOOLS_VERSION = "fd9878fd5de921e0bbab3dcdcb932c2627812ee1"
http_archive( http_archive(
name = "com_github_bazelbuild_buildtools", name = "com_github_bazelbuild_buildtools",
url = "https://github.com/bazelbuild/buildtools/archive/%s.zip" % BAZEL_BUILDTOOLS_VERSION, url = "https://github.com/bazelbuild/buildtools/archive/%s.zip" % BAZEL_BUILDTOOLS_VERSION,
strip_prefix = "buildtools-%s" % BAZEL_BUILDTOOLS_VERSION, strip_prefix = "buildtools-%s" % BAZEL_BUILDTOOLS_VERSION,
sha256 = "edb24c2f9c55b10a820ec74db0564415c0cf553fa55e9fc709a6332fb6685eff", sha256 = "27bb461ade23fd44ba98723ad98f84ee9c83cd3540b773b186a1bc5037f3d862",
) )
# Fetching the Bazel source code allows us to compile the Skylark linter # Fetching the Bazel source code allows us to compile the Skylark linter
@ -71,33 +58,17 @@ http_archive(
# Even better, things like aspects will visit the entire graph including # Even better, things like aspects will visit the entire graph including
# ts_library rules in the devkit repository. # ts_library rules in the devkit repository.
http_archive( http_archive(
name = "angular_cli", name = "angular_devkit",
url = "https://github.com/angular/angular-cli/archive/v6.1.0-rc.0.zip", url = "https://github.com/angular/devkit/archive/v0.3.1.zip",
strip_prefix = "angular-cli-6.1.0-rc.0", strip_prefix = "devkit-0.3.1",
sha256 = "8cf320ea58c321e103f39087376feea502f20eaf79c61a4fdb05c7286c8684fd", sha256 = "31d4b597fe9336650acf13df053c1c84dcbe9c29c6a833bcac3819cd3fd8cad3",
) )
http_archive( http_archive(
name = "org_brotli", name = "org_brotli",
url = "https://github.com/google/brotli/archive/f9b8c02673c576a3e807edbf3a9328e9e7af6d7c.zip", url = "https://github.com/google/brotli/archive/c6333e1e79fb62ea088443f192293f964409b04e.zip",
strip_prefix = "brotli-f9b8c02673c576a3e807edbf3a9328e9e7af6d7c", strip_prefix = "brotli-c6333e1e79fb62ea088443f192293f964409b04e",
sha256 = "8a517806d2b7c8505ba5c53934e7d7c70d341b68ffd268e9044d35b564a48828", sha256 = "3f781988dee7dd3bcce2bf238294663cfaaf3b6433505bdb762e24d0a284d1dc",
)
#
# Point Bazel to WORKSPACEs that live in subdirectories
#
local_repository(
name = "rxjs",
path = "node_modules/rxjs/src",
)
# Point to the integration test workspace just so that Bazel doesn't descend into it
# when expanding the //... pattern
local_repository(
name = "bazel_integration_test",
path = "integration/bazel",
) )
# #
@ -106,15 +77,8 @@ local_repository(
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", "yarn_install")
check_bazel_version("0.16.0", """ check_bazel_version("0.13.0")
If you are on a Mac and using Homebrew, there is a breaking change to the installation in Bazel 0.16 node_repositories(package_json = ["//:package.json"])
See https://blog.bazel.build/2018/08/22/bazel-homebrew.html
""")
node_repositories(
package_json = ["//:package.json"],
preserve_symlinks = True,
)
load("@io_bazel_rules_go//go:def.bzl", "go_rules_dependencies", "go_register_toolchains") load("@io_bazel_rules_go//go:def.bzl", "go_rules_dependencies", "go_register_toolchains")
@ -133,9 +97,21 @@ load("@build_bazel_rules_typescript//:defs.bzl", "ts_setup_workspace")
ts_setup_workspace() ts_setup_workspace()
load("@angular//:index.bzl", "ng_setup_workspace") #
# Point Bazel to WORKSPACEs that live in subdirectories
#
ng_setup_workspace() local_repository(
name = "rxjs",
path = "node_modules/rxjs/src",
)
# Point to the integration test workspace just so that Bazel doesn't descend into it
# when expanding the //... pattern
local_repository(
name = "bazel_integration_test",
path = "integration/bazel",
)
# #
# Ask Bazel to manage these toolchain dependencies for us. # Ask Bazel to manage these toolchain dependencies for us.

View File

@ -8,7 +8,7 @@ Everything in this folder is part of the documentation project. This includes
## Developer tasks ## Developer tasks
We use [Yarn](https://yarnpkg.com) to manage the dependencies and to run build tasks. We use `yarn` to manage the dependencies and to run build tasks.
You should run all these tasks from the `angular/aio` folder. You should run all these tasks from the `angular/aio` folder.
Here are the most important tasks you might need to use: Here are the most important tasks you might need to use:
@ -43,17 +43,6 @@ Here are the most important tasks you might need to use:
* `yarn build-ie-polyfills` - generates a js file of polyfills that can be loaded in Internet Explorer. * `yarn build-ie-polyfills` - generates a js file of polyfills that can be loaded in Internet Explorer.
## Developing on Windows
The `packages/` directory may contain Linux-specific symlinks, which are not recognized by Windows.
These unresolved links cause the docs generation process to fail because it cannot locate certain files.
> Hint: The following steps require administration rights or [Windows Developer Mode](https://docs.microsoft.com/en-us/windows/uwp/get-started/enable-your-device-for-development) enabled!
To fix this problem, run `scripts/windows/create-symlinks.sh`. This command creates temporary files where the symlinks used to be. Make sure not to commit those files with your documentation changes.
When you are done making and testing your documentation changes, you can restore the original symlinks and delete the temporary files by running `scripts/windows/remove-symlinks.sh`.
It's necessary to remove the temporary files, because otherwise they're displayed as local changes in your git working copy and certain operations are blocked.
## Using ServiceWorker locally ## Using ServiceWorker locally
Since abb36e3cb, running `yarn start --prod` will no longer set up the ServiceWorker, which Since abb36e3cb, running `yarn start --prod` will no longer set up the ServiceWorker, which

View File

@ -8,24 +8,17 @@ LABEL name="angular.io PR preview" \
VOLUME /aio-secrets VOLUME /aio-secrets
VOLUME /var/www/aio-builds VOLUME /var/www/aio-builds
VOLUME /dockerbuild
EXPOSE 80 443 EXPOSE 80 443
# Build-time args and env vars # 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 AIO_BUILDS_DIR=/var/www/aio-builds
ARG TEST_AIO_BUILDS_DIR=/tmp/aio-builds ARG TEST_AIO_BUILDS_DIR=/tmp/aio-builds
ARG AIO_DOMAIN_NAME=ngbuilds.io ARG AIO_DOMAIN_NAME=ngbuilds.io
ARG TEST_AIO_DOMAIN_NAME=$AIO_DOMAIN_NAME.localhost ARG TEST_AIO_DOMAIN_NAME=$AIO_DOMAIN_NAME.localhost
ARG AIO_GITHUB_ORGANIZATION=angular ARG AIO_GITHUB_ORGANIZATION=angular
ARG TEST_AIO_GITHUB_ORGANIZATION=test-org ARG TEST_AIO_GITHUB_ORGANIZATION=angular
ARG AIO_GITHUB_REPO=angular
ARG TEST_AIO_GITHUB_REPO=test-repo
ARG AIO_GITHUB_TEAM_SLUGS=team,aio-contributors ARG AIO_GITHUB_TEAM_SLUGS=team,aio-contributors
ARG TEST_AIO_GITHUB_TEAM_SLUGS=team,aio-contributors ARG TEST_AIO_GITHUB_TEAM_SLUGS=team,aio-contributors
ARG AIO_NGINX_HOSTNAME=$AIO_DOMAIN_NAME ARG AIO_NGINX_HOSTNAME=$AIO_DOMAIN_NAME
@ -34,36 +27,34 @@ ARG AIO_NGINX_PORT_HTTP=80
ARG TEST_AIO_NGINX_PORT_HTTP=8080 ARG TEST_AIO_NGINX_PORT_HTTP=8080
ARG AIO_NGINX_PORT_HTTPS=443 ARG AIO_NGINX_PORT_HTTPS=443
ARG TEST_AIO_NGINX_PORT_HTTPS=4433 ARG TEST_AIO_NGINX_PORT_HTTPS=4433
ARG AIO_SIGNIFICANT_FILES_PATTERN='^(?:aio|packages)/(?!.*[._]spec\\.[jt]s$)' ARG AIO_REPO_SLUG=angular/angular
ARG TEST_AIO_SIGNIFICANT_FILES_PATTERN=$AIO_SIGNIFICANT_FILES_PATTERN ARG TEST_AIO_REPO_SLUG=test-repo/test-slug
ARG AIO_TRUSTED_PR_LABEL="aio: preview" ARG AIO_TRUSTED_PR_LABEL="aio: preview"
ARG TEST_AIO_TRUSTED_PR_LABEL="aio: preview" ARG TEST_AIO_TRUSTED_PR_LABEL="aio: preview"
ARG AIO_PREVIEW_SERVER_HOSTNAME=preview.localhost ARG AIO_UPLOAD_HOSTNAME=upload.localhost
ARG TEST_AIO_PREVIEW_SERVER_HOSTNAME=preview.localhost ARG TEST_AIO_UPLOAD_HOSTNAME=upload.localhost
ARG AIO_ARTIFACT_MAX_SIZE=20971520 ARG AIO_UPLOAD_MAX_SIZE=20971520
ARG TEST_AIO_ARTIFACT_MAX_SIZE=200 ARG TEST_AIO_UPLOAD_MAX_SIZE=20971520
ARG AIO_PREVIEW_SERVER_PORT=3000 ARG AIO_UPLOAD_PORT=3000
ARG TEST_AIO_PREVIEW_SERVER_PORT=3001 ARG TEST_AIO_UPLOAD_PORT=3001
ENV AIO_ARTIFACT_PATH=$AIO_ARTIFACT_PATH TEST_AIO_ARTIFACT_PATH=$TEST_AIO_ARTIFACT_PATH \ ENV AIO_BUILDS_DIR=$AIO_BUILDS_DIR TEST_AIO_BUILDS_DIR=$TEST_AIO_BUILDS_DIR \
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_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_ORGANIZATION=$AIO_GITHUB_ORGANIZATION TEST_AIO_GITHUB_ORGANIZATION=$TEST_AIO_GITHUB_ORGANIZATION \ AIO_GITHUB_TEAM_SLUGS=$AIO_GITHUB_TEAM_SLUGS TEST_AIO_GITHUB_TEAM_SLUGS=$TEST_AIO_GITHUB_TEAM_SLUGS \
AIO_GITHUB_REPO=$AIO_GITHUB_REPO TEST_AIO_GITHUB_REPO=$TEST_AIO_GITHUB_REPO \ AIO_LOCALCERTS_DIR=/etc/ssl/localcerts TEST_AIO_LOCALCERTS_DIR=/etc/ssl/localcerts-test \
AIO_GITHUB_TEAM_SLUGS=$AIO_GITHUB_TEAM_SLUGS TEST_AIO_GITHUB_TEAM_SLUGS=$TEST_AIO_GITHUB_TEAM_SLUGS \ AIO_NGINX_HOSTNAME=$AIO_NGINX_HOSTNAME TEST_AIO_NGINX_HOSTNAME=$TEST_AIO_NGINX_HOSTNAME \
AIO_LOCALCERTS_DIR=/etc/ssl/localcerts TEST_AIO_LOCALCERTS_DIR=/etc/ssl/localcerts-test \ AIO_NGINX_LOGS_DIR=/var/log/aio/nginx TEST_AIO_NGINX_LOGS_DIR=/var/log/aio/nginx-test \
AIO_NGINX_HOSTNAME=$AIO_NGINX_HOSTNAME TEST_AIO_NGINX_HOSTNAME=$TEST_AIO_NGINX_HOSTNAME \ AIO_NGINX_PORT_HTTP=$AIO_NGINX_PORT_HTTP TEST_AIO_NGINX_PORT_HTTP=$TEST_AIO_NGINX_PORT_HTTP \
AIO_NGINX_LOGS_DIR=/var/log/aio/nginx TEST_AIO_NGINX_LOGS_DIR=/var/log/aio/nginx-test \ AIO_NGINX_PORT_HTTPS=$AIO_NGINX_PORT_HTTPS TEST_AIO_NGINX_PORT_HTTPS=$TEST_AIO_NGINX_PORT_HTTPS \
AIO_NGINX_PORT_HTTP=$AIO_NGINX_PORT_HTTP TEST_AIO_NGINX_PORT_HTTP=$TEST_AIO_NGINX_PORT_HTTP \ AIO_REPO_SLUG=$AIO_REPO_SLUG TEST_AIO_REPO_SLUG=$TEST_AIO_REPO_SLUG \
AIO_NGINX_PORT_HTTPS=$AIO_NGINX_PORT_HTTPS TEST_AIO_NGINX_PORT_HTTPS=$TEST_AIO_NGINX_PORT_HTTPS \ AIO_SCRIPTS_JS_DIR=/usr/share/aio-scripts-js \
AIO_SCRIPTS_JS_DIR=/usr/share/aio-scripts-js \ AIO_SCRIPTS_SH_DIR=/usr/share/aio-scripts-sh \
AIO_SCRIPTS_SH_DIR=/usr/share/aio-scripts-sh \ AIO_TRUSTED_PR_LABEL=$AIO_TRUSTED_PR_LABEL TEST_AIO_TRUSTED_PR_LABEL=$TEST_AIO_TRUSTED_PR_LABEL \
AIO_SIGNIFICANT_FILES_PATTERN=$AIO_SIGNIFICANT_FILES_PATTERN TEST_AIO_SIGNIFICANT_FILES_PATTERN=$TEST_AIO_SIGNIFICANT_FILES_PATTERN \ AIO_UPLOAD_HOSTNAME=$AIO_UPLOAD_HOSTNAME TEST_AIO_UPLOAD_HOSTNAME=$TEST_AIO_UPLOAD_HOSTNAME \
AIO_TRUSTED_PR_LABEL=$AIO_TRUSTED_PR_LABEL TEST_AIO_TRUSTED_PR_LABEL=$TEST_AIO_TRUSTED_PR_LABEL \ AIO_UPLOAD_MAX_SIZE=$AIO_UPLOAD_MAX_SIZE TEST_AIO_UPLOAD_MAX_SIZE=$TEST_AIO_UPLOAD_MAX_SIZE \
AIO_PREVIEW_SERVER_HOSTNAME=$AIO_PREVIEW_SERVER_HOSTNAME TEST_AIO_PREVIEW_SERVER_HOSTNAME=$TEST_AIO_PREVIEW_SERVER_HOSTNAME \ AIO_UPLOAD_PORT=$AIO_UPLOAD_PORT TEST_AIO_UPLOAD_PORT=$TEST_AIO_UPLOAD_PORT \
AIO_ARTIFACT_MAX_SIZE=$AIO_ARTIFACT_MAX_SIZE TEST_AIO_ARTIFACT_MAX_SIZE=$TEST_AIO_ARTIFACT_MAX_SIZE \ AIO_WWW_USER=www-data \
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 NODE_ENV=production
@ -73,7 +64,7 @@ RUN mkdir /var/log/aio
# Add extra package sources # Add extra package sources
RUN apt-get update -y && apt-get install -y curl RUN apt-get update -y && apt-get install -y curl
RUN curl --silent --show-error --location https://deb.nodesource.com/setup_10.x | bash - RUN curl --silent --show-error --location https://deb.nodesource.com/setup_6.x | bash -
RUN curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 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 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 RUN echo "deb http://ftp.debian.org/debian jessie-backports main" | tee /etc/apt/sources.list.d/backports.list
@ -108,9 +99,9 @@ RUN printenv | grep AIO_ >> /etc/environment
# Set up dnsmasq # Set up dnsmasq
COPY dnsmasq/dnsmasq.conf /etc/ COPY dnsmasq/dnsmasq.conf /etc/
RUN sed -i "s|{{\$AIO_NGINX_HOSTNAME}}|$AIO_NGINX_HOSTNAME|g" /etc/dnsmasq.conf RUN sed -i "s|{{\$AIO_NGINX_HOSTNAME}}|$AIO_NGINX_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|{{\$AIO_UPLOAD_HOSTNAME}}|$AIO_UPLOAD_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_NGINX_HOSTNAME}}|$TEST_AIO_NGINX_HOSTNAME|g" /etc/dnsmasq.conf
RUN sed -i "s|{{\$TEST_AIO_PREVIEW_SERVER_HOSTNAME}}|$TEST_AIO_PREVIEW_SERVER_HOSTNAME|g" /etc/dnsmasq.conf RUN sed -i "s|{{\$TEST_AIO_UPLOAD_HOSTNAME}}|$TEST_AIO_UPLOAD_HOSTNAME|g" /etc/dnsmasq.conf
# Set up SSL/TLS certificates # Set up SSL/TLS certificates
@ -134,9 +125,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_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_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_NGINX_PORT_HTTPS}}|$AIO_NGINX_PORT_HTTPS|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_UPLOAD_HOSTNAME}}|$AIO_UPLOAD_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_UPLOAD_MAX_SIZE}}|$AIO_UPLOAD_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 RUN sed -i "s|{{\$AIO_UPLOAD_PORT}}|$AIO_UPLOAD_PORT|g" /etc/nginx/conf.d/aio-builds-prod.conf
COPY nginx/aio-builds.conf /etc/nginx/conf.d/aio-builds-test.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 RUN sed -i "s|{{\$AIO_BUILDS_DIR}}|$TEST_AIO_BUILDS_DIR|g" /etc/nginx/conf.d/aio-builds-test.conf
@ -145,9 +136,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_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_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_NGINX_PORT_HTTPS}}|$TEST_AIO_NGINX_PORT_HTTPS|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_UPLOAD_HOSTNAME}}|$TEST_AIO_UPLOAD_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_UPLOAD_MAX_SIZE}}|$TEST_AIO_UPLOAD_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 RUN sed -i "s|{{\$AIO_UPLOAD_PORT}}|$TEST_AIO_UPLOAD_PORT|g" /etc/nginx/conf.d/aio-builds-test.conf
# Set up pm2 # Set up pm2

View File

@ -8,9 +8,9 @@ listen-address=127.0.0.1
# Force an IP address for these domains. # Force an IP address for these domains.
address=/{{$AIO_NGINX_HOSTNAME}}/127.0.0.1 address=/{{$AIO_NGINX_HOSTNAME}}/127.0.0.1
address=/{{$AIO_PREVIEW_SERVER_HOSTNAME}}/127.0.0.1 address=/{{$AIO_UPLOAD_HOSTNAME}}/127.0.0.1
address=/{{$TEST_AIO_NGINX_HOSTNAME}}/127.0.0.1 address=/{{$TEST_AIO_NGINX_HOSTNAME}}/127.0.0.1
address=/{{$TEST_AIO_PREVIEW_SERVER_HOSTNAME}}/127.0.0.1 address=/{{$TEST_AIO_UPLOAD_HOSTNAME}}/127.0.0.1
# Run as root (required from inside docker container). # Run as root (required from inside docker container).
user=root user=root

View File

@ -1,4 +1,4 @@
/var/log/aio/preview-server-*.log { /var/log/aio/upload-server-*.log {
compress compress
copytruncate copytruncate
delaycompress delaycompress

View File

@ -66,17 +66,24 @@ server {
return 200 ''; return 200 '';
} }
# Notify about CircleCI builds # Upload builds
location "~^/circle-build/?$" { location "~^/create-build/(?<pr>[1-9][0-9]*)/(?<sha>[0-9a-f]{40})/?$" {
if ($request_method != "POST") { if ($request_method != "POST") {
add_header Allow "POST"; add_header Allow "POST";
return 405; 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_pass_request_headers on;
proxy_set_header X-FILE $request_body_file;
proxy_set_body off;
proxy_redirect off; proxy_redirect off;
proxy_method POST; proxy_method GET;
proxy_pass http://{{$AIO_PREVIEW_SERVER_HOSTNAME}}:{{$AIO_PREVIEW_SERVER_PORT}}$request_uri; proxy_pass http://{{$AIO_UPLOAD_HOSTNAME}}:{{$AIO_UPLOAD_PORT}}$request_uri;
resolver 127.0.0.1; resolver 127.0.0.1;
} }
@ -91,7 +98,7 @@ server {
proxy_pass_request_headers on; proxy_pass_request_headers on;
proxy_redirect off; proxy_redirect off;
proxy_method POST; proxy_method POST;
proxy_pass http://{{$AIO_PREVIEW_SERVER_HOSTNAME}}:{{$AIO_PREVIEW_SERVER_PORT}}$request_uri; proxy_pass http://{{$AIO_UPLOAD_HOSTNAME}}:{{$AIO_UPLOAD_PORT}}$request_uri;
resolver 127.0.0.1; resolver 127.0.0.1;
} }

View File

@ -3,53 +3,29 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as shell from 'shelljs'; import * as shell from 'shelljs';
import {HIDDEN_DIR_PREFIX} from '../common/constants'; import {HIDDEN_DIR_PREFIX} from '../common/constants';
import {GithubApi} from '../common/github-api';
import {GithubPullRequests} from '../common/github-pull-requests'; import {GithubPullRequests} from '../common/github-pull-requests';
import {assertNotMissingOrEmpty, createLogger, getPrInfoFromDownloadPath} from '../common/utils'; import {assertNotMissingOrEmpty} from '../common/utils';
// Classes // Classes
export class BuildCleaner { export class BuildCleaner {
private logger = createLogger('BuildCleaner');
// Constructor // Constructor
constructor(protected buildsDir: string, protected githubOrg: string, protected githubRepo: string, constructor(protected buildsDir: string, protected repoSlug: string, protected githubToken: string) {
protected githubToken: string, protected downloadsDir: string, protected artifactPath: string) {
assertNotMissingOrEmpty('buildsDir', buildsDir); assertNotMissingOrEmpty('buildsDir', buildsDir);
assertNotMissingOrEmpty('githubOrg', githubOrg); assertNotMissingOrEmpty('repoSlug', repoSlug);
assertNotMissingOrEmpty('githubRepo', githubRepo);
assertNotMissingOrEmpty('githubToken', githubToken); assertNotMissingOrEmpty('githubToken', githubToken);
assertNotMissingOrEmpty('downloadsDir', downloadsDir);
assertNotMissingOrEmpty('artifactPath', artifactPath);
} }
// Methods - Public // Methods - Public
public async cleanUp(): Promise<void> { public cleanUp(): Promise<void> {
try { return Promise.all([
this.logger.log('Cleaning up builds and downloads'); this.getExistingBuildNumbers(),
const openPrs = await this.getOpenPrNumbers(); this.getOpenPrNumbers(),
this.logger.log(`Open pull requests: ${openPrs.length}`); ]).then(([existingBuilds, openPrs]) => this.removeUnnecessaryBuilds(existingBuilds, openPrs));
await Promise.all([
this.cleanBuilds(openPrs),
this.cleanDownloads(openPrs),
]);
} catch (error) {
this.logger.error('ERROR:', error);
}
} }
public async cleanBuilds(openPrs: number[]): Promise<void> { // Methods - Protected
const existingBuilds = await this.getExistingBuildNumbers(); protected getExistingBuildNumbers(): Promise<number[]> {
await this.removeUnnecessaryBuilds(existingBuilds, openPrs); return new Promise((resolve, reject) => {
}
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) => { fs.readdir(this.buildsDir, (err, files) => {
if (err) { if (err) {
return reject(err); return reject(err);
@ -65,29 +41,32 @@ export class BuildCleaner {
}); });
} }
public async getOpenPrNumbers(): Promise<number[]> { protected getOpenPrNumbers(): Promise<number[]> {
const api = new GithubApi(this.githubToken); const githubPullRequests = new GithubPullRequests(this.githubToken, this.repoSlug);
const githubPullRequests = new GithubPullRequests(api, this.githubOrg, this.githubRepo);
const prs = await githubPullRequests.fetchAll('open'); return githubPullRequests.
return prs.map(pr => pr.number); fetchAll('open').
then(prs => prs.map(pr => pr.number));
} }
public removeDir(dir: string): void { protected removeDir(dir: string) {
try { try {
if (shell.test('-d', dir)) { if (shell.test('-d', dir)) {
shell.chmod('-R', 'a+w', dir); // Undocumented signature (see https://github.com/shelljs/shelljs/pull/663).
(shell as any).chmod('-R', 'a+w', dir);
shell.rm('-rf', dir); shell.rm('-rf', dir);
} }
} catch (err) { } catch (err) {
this.logger.error(`ERROR: Unable to remove '${dir}' due to:`, err); console.error(`ERROR: Unable to remove '${dir}' due to:`, err);
} }
} }
public removeUnnecessaryBuilds(existingBuildNumbers: number[], openPrNumbers: number[]): void { protected removeUnnecessaryBuilds(existingBuildNumbers: number[], openPrNumbers: number[]) {
const toRemove = existingBuildNumbers.filter(num => !openPrNumbers.includes(num)); const toRemove = existingBuildNumbers.filter(num => !openPrNumbers.includes(num));
this.logger.log(`Existing builds: ${existingBuildNumbers.length}`); console.log(`Existing builds: ${existingBuildNumbers.length}`);
this.logger.log(`Removing ${toRemove.length} build(s): ${toRemove.join(', ')}`); console.log(`Open pull requests: ${openPrNumbers.length}`);
console.log(`Removing ${toRemove.length} build(s): ${toRemove.join(', ')}`);
// Try removing public dirs. // Try removing public dirs.
toRemove. toRemove.
@ -99,29 +78,4 @@ export class BuildCleaner {
map(num => path.join(this.buildsDir, HIDDEN_DIR_PREFIX + String(num))). map(num => path.join(this.buildsDir, HIDDEN_DIR_PREFIX + String(num))).
forEach(dir => this.removeDir(dir)); 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(filePath));
}
} }

View File

@ -1,26 +1,23 @@
// Imports // Imports
import {AIO_DOWNLOADS_DIR} from '../common/constants'; import {getEnvVar} from '../common/utils';
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'; 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 // Run
_main(); _main();
// Functions // Functions
function _main(): void { function _main() {
const buildCleaner = new BuildCleaner( console.log(`[${new Date()}] - Cleaning up builds...`);
AIO_BUILDS_DIR,
AIO_GITHUB_ORGANIZATION,
AIO_GITHUB_REPO,
AIO_GITHUB_TOKEN,
AIO_DOWNLOADS_DIR,
AIO_ARTIFACT_PATH);
buildCleaner.cleanUp().catch(() => process.exit(1)); const buildCleaner = new BuildCleaner(AIO_BUILDS_DIR, AIO_REPO_SLUG, AIO_GITHUB_TOKEN);
buildCleaner.cleanUp().catch(err => {
console.error('ERROR:', err);
process.exit(1);
});
} }

View File

@ -1,90 +0,0 @@
// 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<BuildInfo>();
} 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<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})`);
}
}
}

View File

@ -1,4 +1,3 @@
// Constants // Constants
export const AIO_DOWNLOADS_DIR = '/tmp/aio-downloads';
export const HIDDEN_DIR_PREFIX = 'hidden--'; export const HIDDEN_DIR_PREFIX = 'hidden--';
export const SHORT_SHA_LEN = 7; export const SHORT_SHA_LEN = 7;

View File

@ -1,19 +0,0 @@
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');

View File

@ -28,17 +28,29 @@ export class GithubApi {
} }
// Methods - Public // Methods - Public
public get<T = any>(pathname: string, params?: RequestParamsOrNull): Promise<T> { public get<T>(pathname: string, params?: RequestParamsOrNull): Promise<T> {
const path = this.buildPath(pathname, params); const path = this.buildPath(pathname, params);
return this.request<T>('get', path); return this.request<T>('get', path);
} }
public post<T = any>(pathname: string, params?: RequestParamsOrNull, data?: any): Promise<T> { public post<T>(pathname: string, params?: RequestParamsOrNull, data?: any): Promise<T> {
const path = this.buildPath(pathname, params); const path = this.buildPath(pathname, params);
return this.request<T>('post', path, data); return this.request<T>('post', path, data);
} }
public getPaginated<T>(pathname: string, baseParams: RequestParams = {}, currentPage: number = 0): Promise<T[]> { // 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[]> {
const perPage = 100; const perPage = 100;
const params = { const params = {
...baseParams, ...baseParams,
@ -55,18 +67,6 @@ 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> { protected request<T>(method: string, path: string, data: any = null): Promise<T> {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const options = { const options = {
@ -81,7 +81,7 @@ export class GithubApi {
reject(`Request to '${url}' failed (status: ${statusCode}): ${responseText}`); reject(`Request to '${url}' failed (status: ${statusCode}): ${responseText}`);
}; };
const onSuccess = (responseText: string) => { const onSuccess = (responseText: string) => {
try { resolve(responseText && JSON.parse(responseText)); } catch (err) { reject(err); } try { resolve(JSON.parse(responseText)); } catch (err) { reject(err); }
}; };
const onResponse = (res: IncomingMessage) => { const onResponse = (res: IncomingMessage) => {
const statusCode = res.statusCode || -1; const statusCode = res.statusCode || -1;

View File

@ -1,79 +1,46 @@
// Imports
import {assertNotMissingOrEmpty} from '../common/utils';
import {GithubApi} from './github-api'; import {GithubApi} from './github-api';
import {assert, assertNotMissingOrEmpty} from './utils';
export interface PullRequest { // Interfaces - Types
export interface PullRequest {
number: number; number: number;
user: {login: string}; user: {login: string};
labels: {name: string}[]; labels: {name: string}[];
} }
export interface FileInfo {
sha: string;
filename: string;
}
export type PullRequestState = 'all' | 'closed' | 'open'; export type PullRequestState = 'all' | 'closed' | 'open';
/** // Classes
* Access pull requests on GitHub. export class GithubPullRequests extends GithubApi {
*/ // Constructor
export class GithubPullRequests { constructor(githubToken: string, protected repoSlug: string) {
public repoSlug: string; super(githubToken);
assertNotMissingOrEmpty('repoSlug', repoSlug);
/**
* 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
* Post a comment on a PR. public addComment(pr: number, body: string): Promise<void> {
* @param pr The number of the PR on which to comment. if (!(pr > 0)) {
* @param body The body of the comment to post. throw new Error(`Invalid PR number: ${pr}`);
* @returns A promise that resolves when the comment has been posted. } else if (!body) {
*/ throw new Error(`Invalid or empty comment body: ${body}`);
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.post<void>(`/repos/${this.repoSlug}/issues/${pr}/comments`, null, {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> { 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. // Using the `/issues/` URL, because the `/pulls/` one does not provide labels.
return this.api.get<PullRequest>(`/repos/${this.repoSlug}/issues/${pr}`); return this.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[]> { public fetchAll(state: PullRequestState = 'all'): Promise<PullRequest[]> {
console.log(`Fetching ${state} pull requests...`);
const pathname = `/repos/${this.repoSlug}/pulls`; const pathname = `/repos/${this.repoSlug}/pulls`;
const params = {state}; const params = {state};
return this.api.getPaginated<PullRequest>(pathname, params); return this.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.get<FileInfo[]>(`/repos/${this.repoSlug}/pulls/${pr}/files`);
} }
} }

View File

@ -1,72 +1,45 @@
// Imports
import {assertNotMissingOrEmpty} from '../common/utils';
import {GithubApi} from './github-api'; import {GithubApi} from './github-api';
import {assertNotMissingOrEmpty} from './utils';
export interface Team { // Interfaces - Types
interface Team {
id: number; id: number;
slug: string; slug: string;
} }
export interface TeamMembership { interface TeamMembership {
state: string; state: string;
} }
export class GithubTeams { // Classes
/** export class GithubTeams extends GithubApi {
* Create an instance of this helper // Constructor
* @param api An instance of the Github API helper. constructor(githubToken: string, protected organization: string) {
* @param githubOrg The organisation on GitHub whose repo we will interrogate. super(githubToken);
*/ assertNotMissingOrEmpty('organization', organization);
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[]> { public fetchAll(): Promise<Team[]> {
return this.api.getPaginated<Team>(`/orgs/${this.githubOrg}/teams`); return this.getPaginated<Team>(`/orgs/${this.organization}/teams`);
} }
/** public isMemberById(username: string, teamIds: number[]): Promise<boolean> {
* Check whether the specified username is a member of the specified team. const getMembership = (teamId: number) =>
* @param username The usernane to check for in the team. this.get<TeamMembership>(`/teams/${teamId}/memberships/${username}`).
* @param teamIds The team to check for the username. then(membership => membership.state === 'active').
* @returns a Promise that resolves to `true` if the username is a member of the team. catch(() => false);
*/ const reduceFn = (promise: Promise<boolean>, teamId: number) =>
public async isMemberById(username: string, teamIds: number[]): Promise<boolean> { promise.then(isMember => isMember || getMembership(teamId));
const getMembership = async (teamId: number) => { return teamIds.reduce(reduceFn, Promise.resolve(false));
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;
}
}
return false;
} }
/** public isMemberBySlug(username: string, teamSlugs: string[]): Promise<boolean> {
* Check whether the given username is a member of the teams specified by the team slugs. return this.fetchAll().
* @param username The username to check for in the teams. then(teams => teams.filter(team => teamSlugs.includes(team.slug)).map(team => team.id)).
* @param teamSlugs A collection of slugs that represent the teams to check for the the username. then(teamIds => this.isMemberById(username, teamIds)).
* @returns a Promise that resolves to `true` if the usernane is a member of at least one of the specified teams. catch(() => false);
*/
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;
}
} }
} }

View File

@ -1,85 +1,17 @@
import {basename, resolve as resolvePath} from 'path'; // Functions
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(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) => { export const assertNotMissingOrEmpty = (name: string, value: string | null | undefined) => {
assert(!!value, `Missing or empty required parameter '${name}'!`); if (!value) {
throw new Error(`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 => { export const getEnvVar = (name: string, isOptional = false): string => {
const value = process.env[name]; const value = process.env[name];
if (!isOptional && !value) { if (!isOptional && !value) {
try { console.error(`ERROR: Missing required environment variable '${name}'!`);
throw new Error(`ERROR: Missing required environment variable '${name}'!`); process.exit(1);
} catch (error) {
console.error(error.stack);
process.exit(1);
}
} }
return value || ''; return value || '';
}; };
export function createLogger(scope: string) {
const padding = ' '.repeat(20 - scope.length);
return {
error: (...args: any[]) => console.error(`[${new Date()}]`, `${scope}:${padding}`, ...args),
info: (...args: any[]) => console.info(`[${new Date()}]`, `${scope}:${padding}`, ...args),
log: (...args: any[]) => console.log(`[${new Date()}]`, `${scope}:${padding}`, ...args),
warn: (...args: any[]) => console.warn(`[${new Date()}]`, `${scope}:${padding}`, ...args),
};
}

View File

@ -1,83 +0,0 @@
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, createLogger} 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 = createLogger('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];
}

View File

@ -1,46 +0,0 @@
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);
}
}

View File

@ -1,41 +0,0 @@
// 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);
}

View File

@ -1,183 +0,0 @@
// Imports
import * as bodyParser from 'body-parser';
import * as express from 'express';
import * as http from 'http';
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, createLogger} 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 = createLogger('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();
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();
// RESPOND TO IS-ALIVE PING
middleware.get(/^\/health-check\/?$/, (_req, res) => res.sendStatus(200));
// 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, new RegExp(cfg.significantFilesPattern))) {
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;
}
}

View File

@ -1,29 +0,0 @@
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);
}

View File

@ -4,16 +4,13 @@ import {EventEmitter} from 'events';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as shell from 'shelljs'; import * as shell from 'shelljs';
import {HIDDEN_DIR_PREFIX} from '../common/constants'; import {HIDDEN_DIR_PREFIX, SHORT_SHA_LEN} from '../common/constants';
import {assertNotMissingOrEmpty, computeShortSha, createLogger} from '../common/utils'; import {assertNotMissingOrEmpty} from '../common/utils';
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from './build-events'; import {ChangedPrVisibilityEvent, CreatedBuildEvent} from './build-events';
import {PreviewServerError} from './preview-error'; import {UploadError} from './upload-error';
// Classes // Classes
export class BuildCreator extends EventEmitter { export class BuildCreator extends EventEmitter {
private logger = createLogger('BuildCreator');
// Constructor // Constructor
constructor(protected buildsDir: string) { constructor(protected buildsDir: string) {
super(); super();
@ -21,9 +18,9 @@ export class BuildCreator extends EventEmitter {
} }
// Methods - Public // Methods - Public
public create(pr: number, sha: string, archivePath: string, isPublic: boolean): Promise<void> { public create(pr: string, sha: string, archivePath: string, isPublic: boolean): Promise<void> {
// Use only part of the SHA for more readable URLs. // Use only part of the SHA for more readable URLs.
sha = computeShortSha(sha); sha = sha.substr(0, SHORT_SHA_LEN);
const {newPrDir: prDir} = this.getCandidatePrDirs(pr, isPublic); const {newPrDir: prDir} = this.getCandidatePrDirs(pr, isPublic);
const shaDir = path.join(prDir, sha); const shaDir = path.join(prDir, sha);
@ -36,7 +33,7 @@ export class BuildCreator extends EventEmitter {
then(([prDirExisted, shaDirExisted]) => { then(([prDirExisted, shaDirExisted]) => {
if (shaDirExisted) { if (shaDirExisted) {
const publicOrNot = isPublic ? 'public' : 'non-public'; const publicOrNot = isPublic ? 'public' : 'non-public';
throw new PreviewServerError(409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`); throw new UploadError(409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`);
} }
dirToRemoveOnError = prDirExisted ? shaDir : prDir; dirToRemoveOnError = prDirExisted ? shaDir : prDir;
@ -52,15 +49,15 @@ export class BuildCreator extends EventEmitter {
shell.rm('-rf', dirToRemoveOnError); shell.rm('-rf', dirToRemoveOnError);
} }
if (!(err instanceof PreviewServerError)) { if (!(err instanceof UploadError)) {
err = new PreviewServerError(500, `Error while creating preview at: ${shaDir}\n${err}`); err = new UploadError(500, `Error while uploading to directory: ${shaDir}\n${err}`);
} }
throw err; throw err;
}); });
} }
public updatePrVisibility(pr: number, makePublic: boolean): Promise<boolean> { public updatePrVisibility(pr: string, makePublic: boolean): Promise<boolean> {
const {oldPrDir: otherVisPrDir, newPrDir: targetVisPrDir} = this.getCandidatePrDirs(pr, makePublic); const {oldPrDir: otherVisPrDir, newPrDir: targetVisPrDir} = this.getCandidatePrDirs(pr, makePublic);
return Promise. return Promise.
@ -71,8 +68,7 @@ export class BuildCreator extends EventEmitter {
return false; return false;
} else if (targetVisPrDirExisted) { } else if (targetVisPrDirExisted) {
// Error: Directories for both visibilities exist. // Error: Directories for both visibilities exist.
throw new PreviewServerError(409, throw new UploadError(409, `Request to move '${otherVisPrDir}' to existing directory '${targetVisPrDir}'.`);
`Request to move '${otherVisPrDir}' to existing directory '${targetVisPrDir}'.`);
} }
// Visibility change: Moving `otherVisPrDir` to `targetVisPrDir`. // Visibility change: Moving `otherVisPrDir` to `targetVisPrDir`.
@ -83,8 +79,8 @@ export class BuildCreator extends EventEmitter {
then(() => true); then(() => true);
}). }).
catch(err => { catch(err => {
if (!(err instanceof PreviewServerError)) { if (!(err instanceof UploadError)) {
err = new PreviewServerError(500, `Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\n${err}`); err = new UploadError(500, `Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\n${err}`);
} }
throw err; throw err;
@ -106,11 +102,12 @@ export class BuildCreator extends EventEmitter {
} }
if (stderr) { if (stderr) {
this.logger.warn(stderr); console.warn(stderr);
} }
try { try {
shell.chmod('-R', 'a-w', outputDir); // Undocumented signature (see https://github.com/shelljs/shelljs/pull/663).
(shell as any).chmod('-R', 'a-w', outputDir);
shell.rm('-f', inputFile); shell.rm('-f', inputFile);
resolve(); resolve();
} catch (err) { } catch (err) {
@ -120,9 +117,9 @@ export class BuildCreator extends EventEmitter {
}); });
} }
protected getCandidatePrDirs(pr: number, isPublic: boolean): {oldPrDir: string, newPrDir: string} { protected getCandidatePrDirs(pr: string, isPublic: boolean) {
const hiddenPrDir = path.join(this.buildsDir, HIDDEN_DIR_PREFIX + pr); 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 oldPrDir = isPublic ? hiddenPrDir : publicPrDir;
const newPrDir = isPublic ? publicPrDir : hiddenPrDir; const newPrDir = isPublic ? publicPrDir : hiddenPrDir;

View File

@ -0,0 +1,87 @@
// 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));
}
}

View File

@ -0,0 +1,39 @@
// 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);
});
}

View File

@ -0,0 +1,34 @@
// 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);
}

View File

@ -1,8 +1,8 @@
// Classes // Classes
export class PreviewServerError extends Error { export class UploadError extends Error {
// Constructor // Constructor
constructor(public status: number = 500, message?: string) { constructor(public status: number = 500, message?: string) {
super(message); super(message);
Object.setPrototypeOf(this, PreviewServerError.prototype); Object.setPrototypeOf(this, UploadError.prototype);
} }
} }

View File

@ -0,0 +1,153 @@
// 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();

View File

@ -1,37 +1,16 @@
export const enum BuildNums { // Using the values below, we can fake the response of the corresponding methods in tests. This is
BUILD_INFO_ERROR = 1, // necessary, because the test upload-server will be running as a separate node process, so we will
BUILD_INFO_404, // not have direct access to the code (e.g. for mocking).
BUILD_INFO_BUILD_FAILED, // (See also 'lib/verify-setup/start-test-upload-server.ts'.)
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,
}
export const enum PrNums { /* tslint:disable: variable-name */
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,
}
export const SHA = '1234567890'.repeat(4); // Special values to be used as `authHeader` in `BuildVerifier#verify()`.
export const ALT_SHA = 'abcde'.repeat(8); export const BV_verify_error = 'FAKE_VERIFICATION_ERROR';
export const SIMILAR_SHA = SHA.slice(0, -1) + 'A'; 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 */

View File

@ -1,10 +0,0 @@
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[];
}

View File

@ -4,14 +4,18 @@ import * as fs from 'fs';
import * as http from 'http'; import * as http from 'http';
import * as path from 'path'; import * as path from 'path';
import * as shell from 'shelljs'; import * as shell from 'shelljs';
import {AIO_DOWNLOADS_DIR, HIDDEN_DIR_PREFIX} from '../common/constants'; import {HIDDEN_DIR_PREFIX, SHORT_SHA_LEN} from '../common/constants';
import { import {getEnvVar} from '../common/utils';
AIO_BUILDS_DIR,
AIO_NGINX_PORT_HTTP, // Constans
AIO_NGINX_PORT_HTTPS, const TEST_AIO_BUILDS_DIR = getEnvVar('TEST_AIO_BUILDS_DIR');
AIO_WWW_USER, const TEST_AIO_NGINX_HOSTNAME = getEnvVar('TEST_AIO_NGINX_HOSTNAME');
} from '../common/env-variables'; const TEST_AIO_NGINX_PORT_HTTP = +getEnvVar('TEST_AIO_NGINX_PORT_HTTP');
import {computeShortSha, createLogger} from '../common/utils'; 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');
// Interfaces - Types // Interfaces - Types
export interface CmdResult { success: boolean; err: Error | null; stdout: string; stderr: string; } export interface CmdResult { success: boolean; err: Error | null; stdout: string; stderr: string; }
@ -23,50 +27,61 @@ export type VerifyCmdResultFn = (result: CmdResult) => void;
// Classes // Classes
class Helper { 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 // Properties - Protected
protected cleanUpFns: CleanUpFn[] = []; protected cleanUpFns: CleanUpFn[] = [];
protected portPerScheme: {[scheme: string]: number} = { protected portPerScheme: {[scheme: string]: number} = {
http: AIO_NGINX_PORT_HTTP, http: this.nginxPortHttp,
https: AIO_NGINX_PORT_HTTPS, https: this.nginxPortHttps,
}; };
private logger = createLogger('TestHelper');
// Constructor // Constructor
constructor() { constructor() {
shell.mkdir('-p', AIO_BUILDS_DIR); shell.mkdir('-p', this.buildsDir);
shell.exec(`chown -R ${AIO_WWW_USER} ${AIO_BUILDS_DIR}`); shell.exec(`chown -R ${this.wwwUser} ${this.buildsDir}`);
shell.mkdir('-p', AIO_DOWNLOADS_DIR);
shell.exec(`chown -R ${AIO_WWW_USER} ${AIO_DOWNLOADS_DIR}`);
} }
// Methods - Public // Methods - Public
public cleanUp(): void { 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() {
while (this.cleanUpFns.length) { while (this.cleanUpFns.length) {
// Clean-up fns remove themselves from the list. // Clean-up fns remove themselves from the list.
this.cleanUpFns[0](); this.cleanUpFns[0]();
} }
const leftoverDownloads = fs.readdirSync(AIO_DOWNLOADS_DIR); if (fs.readdirSync(this.buildsDir).length) {
const leftoverBuilds = fs.readdirSync(AIO_BUILDS_DIR); throw new Error(`Directory '${this.buildsDir}' is not empty after clean-up.`);
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 createDummyBuild(pr: number, sha: string, isPublic = true, force = false, legacy = false): CleanUpFn { 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 {
const prDir = this.getPrDir(pr, isPublic); const prDir = this.getPrDir(pr, isPublic);
const shaDir = this.getShaDir(prDir, sha, legacy); const shaDir = this.getShaDir(prDir, sha, legacy);
const idxPath = path.join(shaDir, 'index.html'); const idxPath = path.join(shaDir, 'index.html');
@ -74,21 +89,35 @@ class Helper {
this.writeFile(idxPath, {content: `PR: ${pr} | SHA: ${sha} | File: /index.html`}, force); 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); this.writeFile(barPath, {content: `PR: ${pr} | SHA: ${sha} | File: /foo/bar.js`}, force);
shell.exec(`chown -R ${AIO_WWW_USER} ${prDir}`); shell.exec(`chown -R ${this.wwwUser} ${prDir}`);
return this.createCleanUpFn(() => shell.rm('-rf', prDir)); return this.createCleanUpFn(() => shell.rm('-rf', prDir));
} }
public getPrDir(pr: number, isPublic: boolean): string { public deletePrDir(pr: string, isPublic = true) {
const prDirName = isPublic ? '' + pr : HIDDEN_DIR_PREFIX + pr; const prDir = this.getPrDir(pr, isPublic);
return path.join(AIO_BUILDS_DIR, prDirName);
if (fs.existsSync(prDir)) {
// Undocumented signature (see https://github.com/shelljs/shelljs/pull/663).
(shell as any).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 getShaDir(prDir: string, sha: string, legacy = false): string { public getShaDir(prDir: string, sha: string, legacy = false): string {
return path.join(prDir, legacy ? sha : computeShortSha(sha)); return path.join(prDir, legacy ? sha : this.getShordSha(sha));
} }
public readBuildFile(pr: number, sha: string, relFilePath: string, isPublic = true, legacy = false): string { 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 {
const shaDir = this.getShaDir(this.getPrDir(pr, isPublic), sha, legacy); const shaDir = this.getShaDir(this.getPrDir(pr, isPublic), sha, legacy);
const absFilePath = path.join(shaDir, relFilePath); const absFilePath = path.join(shaDir, relFilePath);
return fs.readFileSync(absFilePath, 'utf8'); return fs.readFileSync(absFilePath, 'utf8');
@ -101,7 +130,7 @@ class Helper {
}); });
} }
public runForAllSupportedSchemes(suiteFactory: TestSuiteFactory): void { public runForAllSupportedSchemes(suiteFactory: TestSuiteFactory) {
Object.keys(this.portPerScheme).forEach(scheme => suiteFactory(scheme, this.portPerScheme[scheme])); Object.keys(this.portPerScheme).forEach(scheme => suiteFactory(scheme, this.portPerScheme[scheme]));
} }
@ -125,9 +154,9 @@ class Helper {
// Only keep the last to sections (final headers and body). // Only keep the last to sections (final headers and body).
if (!result.success) { if (!result.success) {
this.logger.log('Stdout:', result.stdout); console.log('Stdout:', result.stdout);
this.logger.error('Stderr:', result.stderr); console.log('Stderr:', result.stderr);
this.logger.error('Error:', result.err); console.log('Error:', result.err);
} }
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -136,14 +165,14 @@ class Helper {
}; };
} }
public writeBuildFile(pr: number, sha: string, relFilePath: string, content: string, isPublic = true, public writeBuildFile(pr: string, sha: string, relFilePath: string, content: string, isPublic = true,
legacy = false): void { legacy = false): CleanUpFn {
const shaDir = this.getShaDir(this.getPrDir(pr, isPublic), sha, legacy); const shaDir = this.getShaDir(this.getPrDir(pr, isPublic), sha, legacy);
const absFilePath = path.join(shaDir, relFilePath); const absFilePath = path.join(shaDir, relFilePath);
this.writeFile(absFilePath, {content}, true); return this.writeFile(absFilePath, {content}, true);
} }
public writeFile(filePath: string, {content, size}: FileSpecs, force = false): void { public writeFile(filePath: string, {content, size}: FileSpecs, force = false): CleanUpFn {
if (!force && fs.existsSync(filePath)) { if (!force && fs.existsSync(filePath)) {
throw new Error(`Refusing to overwrite existing file '${filePath}'.`); throw new Error(`Refusing to overwrite existing file '${filePath}'.`);
} }
@ -161,7 +190,9 @@ class Helper {
// Create a file with the specified content. // Create a file with the specified content.
fs.writeFileSync(filePath, content || ''); fs.writeFileSync(filePath, content || '');
} }
shell.exec(`chown ${AIO_WWW_USER} ${filePath}`); shell.exec(`chown ${this.wwwUser} ${filePath}`);
return this.createCleanUpFn(() => shell.rm('-rf', cleanUpTarget));
} }
// Methods - Protected // Methods - Protected
@ -180,54 +211,5 @@ class Helper {
} }
} }
interface CurlOptions {
method?: string;
options?: string;
data?: any;
url?: string;
extraPath?: string;
}
export function makeCurl(baseUrl: string) {
return function curl({
method = 'POST',
options = '',
data = {},
url = baseUrl,
extraPath = '',
}: CurlOptions) {
const dataString = data ? JSON.stringify(data) : '';
const cmd = `curl -iLX ${method} ` +
`${options} ` +
`--header "Content-Type: application/json" ` +
`--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 // Exports
export const helper = new Helper(); export const helper = new Helper();

View File

@ -1,7 +0,0 @@
declare module jasmine {
interface Matchers {
toExistAsAFile(remove = true): boolean;
toExistAsABuild(remove = true): boolean;
toExistAsAnArtifact(remove = true): boolean;
}
}

View File

@ -1,88 +0,0 @@
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(),
};

View File

@ -1,171 +0,0 @@
/* tslint:disable:max-line-length */
import * as nock from 'nock';
import * as tar from 'tar-stream';
import {gzipSync} from 'zlib';
import {createLogger, getEnvVar} 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 = createLogger('NOCK');
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) => `${GITHUB_PULLS_URL}/${prNum}/files`;
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=0&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);

View File

@ -1,22 +1,17 @@
// Imports // Imports
import * as path from 'path'; 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 {helper as h} from './helper'; import {helper as h} from './helper';
import {customMatchers} from './jasmine-custom-matchers';
// Tests // Tests
describe(`nginx`, () => { describe(`nginx`, () => {
beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000); beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000);
beforeEach(() => jasmine.addMatchers(customMatchers));
afterEach(() => h.cleanUp()); afterEach(() => h.cleanUp());
it('should redirect HTTP to HTTPS', done => { it('should redirect HTTP to HTTPS', done => {
const httpHost = `${AIO_NGINX_HOSTNAME}:${AIO_NGINX_PORT_HTTP}`; const httpHost = `${h.nginxHostname}:${h.nginxPortHttp}`;
const httpsHost = `${AIO_NGINX_HOSTNAME}:${AIO_NGINX_PORT_HTTPS}`; const httpsHost = `${h.nginxHostname}:${h.nginxPortHttps}`;
const urlMap = { const urlMap = {
[`http://${httpHost}/`]: `https://${httpsHost}/`, [`http://${httpHost}/`]: `https://${httpsHost}/`,
[`http://${httpHost}/foo`]: `https://${httpsHost}/foo`, [`http://${httpHost}/foo`]: `https://${httpsHost}/foo`,
@ -37,13 +32,13 @@ describe(`nginx`, () => {
h.runForAllSupportedSchemes((scheme, port) => describe(`(on ${scheme.toUpperCase()})`, () => { h.runForAllSupportedSchemes((scheme, port) => describe(`(on ${scheme.toUpperCase()})`, () => {
const hostname = AIO_NGINX_HOSTNAME; const hostname = h.nginxHostname;
const host = `${hostname}:${port}`; const host = `${hostname}:${port}`;
const pr = 9; const pr = '9';
const sha9 = '9'.repeat(40); const sha9 = '9'.repeat(40);
const sha0 = '0'.repeat(40); const sha0 = '0'.repeat(40);
const shortSha9 = computeShortSha(sha9); const shortSha9 = h.getShordSha(sha9);
const shortSha0 = computeShortSha(sha0); const shortSha0 = h.getShordSha(sha0);
describe(`pr<pr>-<sha>.${host}/*`, () => { describe(`pr<pr>-<sha>.${host}/*`, () => {
@ -55,11 +50,6 @@ describe(`nginx`, () => {
h.createDummyBuild(pr, sha0); h.createDummyBuild(pr, sha0);
}); });
afterEach(() => {
expect({ prNum: pr, sha: sha9 }).toExistAsABuild();
expect({ prNum: pr, sha: sha0 }).toExistAsABuild();
});
it('should return /index.html', done => { it('should return /index.html', done => {
const origin = `${scheme}://pr${pr}-${shortSha9}.${host}`; const origin = `${scheme}://pr${pr}-${shortSha9}.${host}`;
@ -73,19 +63,17 @@ describe(`nginx`, () => {
}); });
it('should return /index.html (for legacy builds)', async () => { it('should return /index.html (for legacy builds)', done => {
const origin = `${scheme}://pr${pr}-${sha9}.${host}`; const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /index\\.html$`); const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /index\\.html$`);
h.createDummyBuild(pr, sha9, true, false, true); h.createDummyBuild(pr, sha9, true, false, true);
await Promise.all([ Promise.all([
h.runCmd(`curl -iL ${origin}/index.html`).then(h.verifyResponse(200, bodyRegex)), 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)),
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();
}); });
@ -98,15 +86,15 @@ describe(`nginx`, () => {
}); });
it('should return /foo/bar.js (for legacy builds)', async () => { it('should return /foo/bar.js (for legacy builds)', done => {
const origin = `${scheme}://pr${pr}-${sha9}.${host}`; const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /foo/bar\\.js$`); const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /foo/bar\\.js$`);
h.createDummyBuild(pr, sha9, true, false, true); h.createDummyBuild(pr, sha9, true, false, true);
await h.runCmd(`curl -iL ${origin}/foo/bar.js`).then(h.verifyResponse(200, bodyRegex)); h.runCmd(`curl -iL ${origin}/foo/bar.js`).
then(h.verifyResponse(200, bodyRegex)).
expect({ prNum: pr, sha: sha9, isLegacy: true }).toExistAsABuild(); then(done);
}); });
@ -138,7 +126,7 @@ describe(`nginx`, () => {
it('should respond with 404 for unknown PRs/SHAs', done => { it('should respond with 404 for unknown PRs/SHAs', done => {
const otherPr = 54321; const otherPr = 54321;
const otherShortSha = computeShortSha('8'.repeat(40)); const otherShortSha = h.getShordSha('8'.repeat(40));
Promise.all([ Promise.all([
h.runCmd(`curl -iL ${scheme}://pr${pr}9-${shortSha9}.${host}`).then(h.verifyResponse(404)), h.runCmd(`curl -iL ${scheme}://pr${pr}9-${shortSha9}.${host}`).then(h.verifyResponse(404)),
@ -186,41 +174,39 @@ describe(`nginx`, () => {
describe('(for hidden builds)', () => { describe('(for hidden builds)', () => {
it('should respond with 404 for any file or directory', async () => { it('should respond with 404 for any file or directory', done => {
const origin = `${scheme}://pr${pr}-${shortSha9}.${host}`; const origin = `${scheme}://pr${pr}-${shortSha9}.${host}`;
const assert404 = h.verifyResponse(404); const assert404 = h.verifyResponse(404);
h.createDummyBuild(pr, sha9, false); h.createDummyBuild(pr, sha9, false);
expect(h.buildExists(pr, sha9, false)).toBe(true);
await Promise.all([ Promise.all([
h.runCmd(`curl -iL ${origin}/index.html`).then(assert404), 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}`).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/bar.js`).then(assert404),
h.runCmd(`curl -iL ${origin}/foo/`).then(assert404), h.runCmd(`curl -iL ${origin}/foo/`).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)', async () => { it('should respond with 404 for any file or directory (for legacy builds)', done => {
const origin = `${scheme}://pr${pr}-${sha9}.${host}`; const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
const assert404 = h.verifyResponse(404); const assert404 = h.verifyResponse(404);
h.createDummyBuild(pr, sha9, false, false, true); h.createDummyBuild(pr, sha9, false, false, true);
expect(h.buildExists(pr, sha9, false, true)).toBe(true);
await Promise.all([ Promise.all([
h.runCmd(`curl -iL ${origin}/index.html`).then(assert404), 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}`).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/bar.js`).then(assert404),
h.runCmd(`curl -iL ${origin}/foo/`).then(assert404), h.runCmd(`curl -iL ${origin}/foo/`).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();
}); });
}); });
@ -252,10 +238,10 @@ describe(`nginx`, () => {
}); });
describe(`${host}/circle-build`, () => { describe(`${host}/create-build/<pr>/<sha>`, () => {
it('should disallow non-POST requests', done => { it('should disallow non-POST requests', done => {
const url = `${scheme}://${host}/circle-build`; const url = `${scheme}://${host}/create-build/${pr}/${sha9}`;
Promise.all([ Promise.all([
h.runCmd(`curl -iLX GET ${url}`).then(h.verifyResponse([405, 'Not Allowed'])), h.runCmd(`curl -iLX GET ${url}`).then(h.verifyResponse([405, 'Not Allowed'])),
@ -266,9 +252,31 @@ describe(`nginx`, () => {
}); });
it('should pass requests through to the preview server', done => { it(`should reject files larger than ${h.uploadMaxSize}B (according to header)`, done => {
h.runCmd(`curl -iLX POST ${scheme}://${host}/circle-build`). const headers = `--header "Content-Length: ${1.5 * h.uploadMaxSize}"`;
then(h.verifyResponse(400, /Incorrect body content. Expected JSON/)). 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/)).
then(done); then(done);
}); });
@ -277,16 +285,35 @@ describe(`nginx`, () => {
const cmdPrefix = `curl -iLX POST ${scheme}://${host}`; const cmdPrefix = `curl -iLX POST ${scheme}://${host}`;
Promise.all([ Promise.all([
h.runCmd(`${cmdPrefix}/foo/circle-build/`).then(h.verifyResponse(404)), h.runCmd(`${cmdPrefix}/foo/create-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
h.runCmd(`${cmdPrefix}/foo-circle-build/`).then(h.verifyResponse(404)), h.runCmd(`${cmdPrefix}/foo-create-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
h.runCmd(`${cmdPrefix}/fooncircle-build/`).then(h.verifyResponse(404)), h.runCmd(`${cmdPrefix}/fooncreate-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
h.runCmd(`${cmdPrefix}/circle-build/foo/`).then(h.verifyResponse(404)), h.runCmd(`${cmdPrefix}/create-build/foo/${pr}/${sha9}`).then(h.verifyResponse(404)),
h.runCmd(`${cmdPrefix}/circle-build-foo/`).then(h.verifyResponse(404)), h.runCmd(`${cmdPrefix}/create-build-foo/${pr}/${sha9}`).then(h.verifyResponse(404)),
h.runCmd(`${cmdPrefix}/circle-buildnfoo/`).then(h.verifyResponse(404)), h.runCmd(`${cmdPrefix}/create-buildnfoo/${pr}/${sha9}`).then(h.verifyResponse(404)),
h.runCmd(`${cmdPrefix}/circle-build/pr`).then(h.verifyResponse(404)), h.runCmd(`${cmdPrefix}/create-build/pr${pr}/${sha9}`).then(h.verifyResponse(404)),
h.runCmd(`${cmdPrefix}/circle-build/42`).then(h.verifyResponse(404)), h.runCmd(`${cmdPrefix}/create-build/${pr}/${sha9}42`).then(h.verifyResponse(404)),
]).then(done); ]).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)),
]).then(done);
});
}); });
@ -304,13 +331,17 @@ describe(`nginx`, () => {
}); });
it('should pass requests through to the preview server', done => { it('should pass requests through to the upload server', done => {
const cmdPrefix = `curl -iLX POST --header "Content-Type: application/json"`; const cmdPrefix = `curl -iLX POST --header "Content-Type: application/json"`;
const cmd1 = `${cmdPrefix} ${url}`; const cmd1 = `${cmdPrefix} ${url}`;
const cmd2 = `${cmdPrefix} --data '{"number":${pr}}' ${url}`;
const cmd3 = `${cmdPrefix} --data '{"number":${pr},"action":"foo"}' ${url}`;
Promise.all([ Promise.all([
h.runCmd(cmd1).then(h.verifyResponse(400, /Missing or empty 'number' field/)), 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); ]).then(done);
}); });
@ -333,15 +364,13 @@ describe(`nginx`, () => {
describe(`${host}/*`, () => { describe(`${host}/*`, () => {
beforeEach(() => { it('should respond with 404 for unknown URLs (even if the resource exists)', done => {
['index.html', 'foo.js', 'foo/index.html'].forEach(relFilePath => { ['index.html', 'foo.js', 'foo/index.html'].forEach(relFilePath => {
const absFilePath = path.join(AIO_BUILDS_DIR, relFilePath); const absFilePath = path.join(h.buildsDir, relFilePath);
return h.writeFile(absFilePath, {content: `File: /${relFilePath}`}); h.writeFile(absFilePath, {content: `File: /${relFilePath}`});
}); });
});
it('should respond with 404 for unknown URLs (even if the resource exists)', async () => { Promise.all([
await Promise.all([
h.runCmd(`curl -iL ${scheme}://${host}/index.html`).then(h.verifyResponse(404)), 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)),
h.runCmd(`curl -iL ${scheme}://${host}`).then(h.verifyResponse(404)), h.runCmd(`curl -iL ${scheme}://${host}`).then(h.verifyResponse(404)),
@ -350,14 +379,7 @@ describe(`nginx`, () => {
h.runCmd(`curl -iL ${scheme}://foo.${host}`).then(h.verifyResponse(404)), 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.js`).then(h.verifyResponse(404)),
h.runCmd(`curl -iL ${scheme}://${host}/foo/index.html`).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);
});
}); });
}); });

View File

@ -1,483 +0,0 @@
// 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}/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);
});
});
});

View File

@ -1,80 +1,101 @@
// Imports // Imports
import {AIO_NGINX_HOSTNAME} from '../common/env-variables'; import * as path from 'path';
import {computeShortSha} from '../common/utils'; import * as c from './constants';
import {ALT_SHA, BuildNums, PrNums, SHA} from './constants'; import {helper as h} from './helper';
import {helper as h, makeCurl, payload} from './helper';
import {customMatchers} from './jasmine-custom-matchers';
// Tests // Tests
h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme.toUpperCase()})`, () => { h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme.toUpperCase()})`, () => {
const hostname = AIO_NGINX_HOSTNAME; const hostname = h.nginxHostname;
const host = `${hostname}:${port}`; const host = `${hostname}:${port}`;
const curlPrUpdated = makeCurl(`${scheme}://${host}/pr-updated`); const pr9 = '9';
const sha9 = '9'.repeat(40);
const sha0 = '0'.repeat(40);
const archivePath = path.join(h.buildsDir, 'snapshot.tar.gz');
const getFile = (pr: number, sha: string, file: string) => const getFile = (pr: string, sha: string, file: string) =>
h.runCmd(`curl -iL ${scheme}://pr${pr}-${computeShortSha(sha)}.${host}/${file}`); h.runCmd(`curl -iL ${scheme}://pr${pr}-${h.getShordSha(sha)}.${host}/${file}`);
const prUpdated = (prNum: number, action?: string) => curlPrUpdated({ data: { number: prNum, action } }); const uploadBuild = (pr: string, sha: string, archive: string, authHeader = 'Token FOO') => {
const circleBuild = makeCurl(`${scheme}://${host}/circle-build`); 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}`);
};
beforeEach(() => { beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000);
jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; afterEach(() => {
jasmine.addMatchers(customMatchers); h.deletePrDir(pr9);
h.deletePrDir(pr9, false);
h.cleanUp();
}); });
afterEach(() => h.cleanUp());
describe('for a new/non-existing PR', () => { describe('for a new/non-existing PR', () => {
it('should be able to create and serve a public preview', async () => { it('should be able to upload and serve a public build', done => {
const BUILD = BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER; const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
const PR = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER; const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
const regexPrefix = `^BUILD: ${BUILD} \\| PR: ${PR} \\| SHA: ${SHA} \\| File:`; h.createDummyArchive(pr9, sha9, archivePath);
const idxContentRegex = new RegExp(`${regexPrefix} \\/index\\.html$`);
const barContentRegex = new RegExp(`${regexPrefix} \\/foo\\/bar\\.js$`);
await circleBuild(payload(BUILD)).then(h.verifyResponse(201)); uploadBuild(pr9, sha9, archivePath).
await Promise.all([ then(() => Promise.all([
getFile(PR, SHA, 'index.html').then(h.verifyResponse(200, idxContentRegex)), getFile(pr9, sha9, 'index.html').then(h.verifyResponse(200, idxContentRegex9)),
getFile(PR, SHA, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex)), getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex9)),
]); ])).
then(done);
expect({ prNum: PR }).toExistAsABuild();
expect({ prNum: PR, isPublic: false }).not.toExistAsABuild();
}); });
it('should be able to create but not serve a hidden preview', async () => { it('should be able to upload but not serve a hidden build', done => {
const BUILD = BuildNums.TRUST_CHECK_UNTRUSTED; const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
const PR = PrNums.TRUST_CHECK_UNTRUSTED; const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
await circleBuild(payload(BUILD)).then(h.verifyResponse(202)); h.createDummyArchive(pr9, sha9, archivePath);
await Promise.all([
getFile(PR, SHA, 'index.html').then(h.verifyResponse(404)),
getFile(PR, SHA, 'foo/bar.js').then(h.verifyResponse(404)),
]);
expect({ prNum: PR }).not.toExistAsABuild(); uploadBuild(pr9, sha9, archivePath, c.BV_verify_verifiedNotTrusted).
expect({ prNum: PR, isPublic: false }).toExistAsABuild(); 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);
}); });
it('should reject if verification fails', async () => { it('should reject an upload if verification fails', done => {
const BUILD = BuildNums.TRUST_CHECK_ERROR; const errorRegex9 = new RegExp(`Error while verifying upload for PR ${pr9}: Test`);
const PR = PrNums.TRUST_CHECK_ERROR;
await circleBuild(payload(BUILD)).then(h.verifyResponse(500)); h.createDummyArchive(pr9, sha9, archivePath);
expect({ prNum: PR }).toExistAsAnArtifact();
expect({ prNum: PR }).not.toExistAsABuild(); uploadBuild(pr9, sha9, archivePath, c.BV_verify_error).
expect({ prNum: PR, isPublic: false }).not.toExistAsABuild(); then(h.verifyResponse(403, errorRegex9)).
then(() => {
expect(h.buildExists(pr9)).toBe(false);
expect(h.buildExists(pr9, '', false)).toBe(false);
}).
then(done);
}); });
it('should be able to notify that a PR has been updated (and do nothing)', async () => { it('should be able to notify that a PR has been updated (and do nothing)', done => {
await prUpdated(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER).then(h.verifyResponse(200)); prUpdated(+pr9).
// The PR should still not exist. then(h.verifyResponse(200)).
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: false }).not.toExistAsABuild(); then(() => {
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: true }).not.toExistAsABuild(); // The PR should still not exist.
expect(h.buildExists(pr9, '', false)).toBe(false);
expect(h.buildExists(pr9, '', true)).toBe(false);
}).
then(done);
}); });
}); });
@ -82,186 +103,215 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme
describe('for an existing PR', () => { describe('for an existing PR', () => {
it('should be able to create and serve a public preview', async () => { it('should be able to upload and serve a public build', done => {
const BUILD = BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER; const regexPrefix0 = `^PR: ${pr9} \\| SHA: ${sha0} \\| File:`;
const PR = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER; const idxContentRegex0 = new RegExp(`${regexPrefix0} \\/index\\.html$`);
const barContentRegex0 = new RegExp(`${regexPrefix0} \\/foo\\/bar\\.js$`);
const regexPrefix1 = `^PR: ${PR} \\| SHA: ${ALT_SHA} \\| File:`; const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
const idxContentRegex1 = new RegExp(`${regexPrefix1} \\/index\\.html$`); const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
const barContentRegex1 = new RegExp(`${regexPrefix1} \\/foo\\/bar\\.js$`); const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
const regexPrefix2 = `^BUILD: ${BUILD} \\| PR: ${PR} \\| SHA: ${SHA} \\| File:`; h.createDummyBuild(pr9, sha0);
const idxContentRegex2 = new RegExp(`${regexPrefix2} \\/index\\.html$`); h.createDummyArchive(pr9, sha9, archivePath);
const barContentRegex2 = new RegExp(`${regexPrefix2} \\/foo\\/bar\\.js$`);
h.createDummyBuild(PR, ALT_SHA); uploadBuild(pr9, sha9, archivePath).
await circleBuild(payload(BUILD)).then(h.verifyResponse(201)); then(() => Promise.all([
await Promise.all([ getFile(pr9, sha0, 'index.html').then(h.verifyResponse(200, idxContentRegex0)),
getFile(PR, ALT_SHA, 'index.html').then(h.verifyResponse(200, idxContentRegex1)), getFile(pr9, sha0, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex0)),
getFile(PR, ALT_SHA, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex1)), getFile(pr9, sha9, 'index.html').then(h.verifyResponse(200, idxContentRegex9)),
getFile(PR, SHA, 'index.html').then(h.verifyResponse(200, idxContentRegex2)), getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex9)),
getFile(PR, SHA, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex2)), ])).
]); then(done);
expect({ prNum: PR, sha: SHA }).toExistAsABuild();
expect({ prNum: PR, sha: ALT_SHA }).toExistAsABuild();
}); });
it('should be able to create but not serve a hidden preview', async () => { it('should be able to upload but not serve a hidden build', done => {
const BUILD = BuildNums.TRUST_CHECK_UNTRUSTED; const regexPrefix0 = `^PR: ${pr9} \\| SHA: ${sha0} \\| File:`;
const PR = PrNums.TRUST_CHECK_UNTRUSTED; const idxContentRegex0 = new RegExp(`${regexPrefix0} \\/index\\.html$`);
const barContentRegex0 = new RegExp(`${regexPrefix0} \\/foo\\/bar\\.js$`);
h.createDummyBuild(PR, ALT_SHA, false); const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
await circleBuild(payload(BUILD)).then(h.verifyResponse(202)); const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
await Promise.all([ h.createDummyBuild(pr9, sha0, false);
getFile(PR, ALT_SHA, 'index.html').then(h.verifyResponse(404)), h.createDummyArchive(pr9, sha9, archivePath);
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)),
]);
expect({ prNum: PR, sha: SHA }).not.toExistAsABuild(); uploadBuild(pr9, sha9, archivePath, c.BV_verify_verifiedNotTrusted).
expect({ prNum: PR, sha: SHA, isPublic: false }).toExistAsABuild(); then(() => Promise.all([
expect({ prNum: PR, sha: ALT_SHA }).not.toExistAsABuild(); getFile(pr9, sha0, 'index.html').then(h.verifyResponse(404)),
expect({ prNum: PR, sha: ALT_SHA, isPublic: false }).toExistAsABuild(); 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);
}); });
it('should reject if verification fails', async () => { it('should reject an upload if verification fails', done => {
const BUILD = BuildNums.TRUST_CHECK_ERROR; const errorRegex9 = new RegExp(`Error while verifying upload for PR ${pr9}: Test`);
const PR = PrNums.TRUST_CHECK_ERROR;
h.createDummyBuild(PR, ALT_SHA, false); h.createDummyBuild(pr9, sha0);
h.createDummyArchive(pr9, sha9, archivePath);
await circleBuild(payload(BUILD)).then(h.verifyResponse(500)); 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);
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 preview', async () => { it('should not be able to overwrite an existing public build', done => {
const BUILD = BuildNums.TRUST_CHECK_ACTIVE_TRUSTED_USER; const regexPrefix9 = `^PR: ${pr9} \\| SHA: ${sha9} \\| File:`;
const PR = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER; const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
const regexPrefix = `^PR: ${PR} \\| SHA: ${SHA} \\| File:`; h.createDummyBuild(pr9, sha9);
const idxContentRegex = new RegExp(`${regexPrefix} \\/index\\.html$`); h.createDummyArchive(pr9, sha9, archivePath);
const barContentRegex = new RegExp(`${regexPrefix} \\/foo\\/bar\\.js$`);
h.createDummyBuild(PR, SHA); uploadBuild(pr9, sha9, archivePath).
then(h.verifyResponse(409)).
await circleBuild(payload(BUILD)).then(h.verifyResponse(409)); then(() => Promise.all([
await Promise.all([ getFile(pr9, sha9, 'index.html').then(h.verifyResponse(200, idxContentRegex9)),
getFile(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, SHA, 'index.html').then(h.verifyResponse(200, idxContentRegex)), getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex9)),
getFile(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, SHA, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex)), ])).
]); then(done);
expect({ prNum: PR }).toExistAsAnArtifact();
expect({ prNum: PR }).toExistAsABuild();
}); });
it('should not be able to overwrite an existing hidden preview', async () => { it('should not be able to overwrite an existing hidden build', done => {
const BUILD = BuildNums.TRUST_CHECK_UNTRUSTED; const regexPrefix9 = `^PR: ${pr9} \\| SHA: ${sha9} \\| File:`;
const PR = PrNums.TRUST_CHECK_UNTRUSTED; const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
h.createDummyBuild(PR, SHA, false); const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
await circleBuild(payload(BUILD)).then(h.verifyResponse(409)); h.createDummyBuild(pr9, sha9, false);
h.createDummyArchive(pr9, sha9, archivePath);
expect({ prNum: PR }).toExistAsAnArtifact(); uploadBuild(pr9, sha9, archivePath, c.BV_verify_verifiedNotTrusted).
expect({ prNum: PR, isPublic: false }).toExistAsABuild(); 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);
}); });
it('should be able to request re-checking visibility (if outdated)', async () => { it('should be able to request re-checking visibility (if outdated)', done => {
const publicPr = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER; const publicPr = pr9;
const hiddenPr = PrNums.TRUST_CHECK_UNTRUSTED; const hiddenPr = String(c.BV_getPrIsTrusted_notTrusted);
h.createDummyBuild(publicPr, SHA, false); h.createDummyBuild(publicPr, sha9, false);
h.createDummyBuild(hiddenPr, SHA, true); h.createDummyBuild(hiddenPr, sha9, true);
// PR visibilities are outdated (i.e. the opposte of what the should). // PR visibilities are outdated (i.e. the opposte of what the should).
expect({ prNum: publicPr, sha: SHA, isPublic: false }).toExistAsABuild(false); expect(h.buildExists(publicPr, '', false)).toBe(true);
expect({ prNum: publicPr, sha: SHA, isPublic: true }).not.toExistAsABuild(false); expect(h.buildExists(publicPr, '', true)).toBe(false);
expect({ prNum: hiddenPr, sha: SHA, isPublic: false }).not.toExistAsABuild(false); expect(h.buildExists(hiddenPr, '', false)).toBe(false);
expect({ prNum: hiddenPr, sha: SHA, isPublic: true }).toExistAsABuild(false); expect(h.buildExists(hiddenPr, '', true)).toBe(true);
await Promise.all([ Promise.
prUpdated(publicPr).then(h.verifyResponse(200)), all([
prUpdated(hiddenPr).then(h.verifyResponse(200)), prUpdated(+publicPr).then(h.verifyResponse(200)),
]); prUpdated(+hiddenPr).then(h.verifyResponse(200)),
]).
// PR visibilities should have been updated. then(() => {
expect({ prNum: publicPr, isPublic: false }).not.toExistAsABuild(); // PR visibilities should have been updated.
expect({ prNum: publicPr, isPublic: true }).toExistAsABuild(); expect(h.buildExists(publicPr, '', false)).toBe(false);
expect({ prNum: hiddenPr, isPublic: false }).toExistAsABuild(); expect(h.buildExists(publicPr, '', true)).toBe(true);
expect({ prNum: hiddenPr, isPublic: true }).not.toExistAsABuild(); 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);
}); });
it('should be able to request re-checking visibility (if up-to-date)', async () => { it('should be able to request re-checking visibility (if up-to-date)', done => {
const publicPr = PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER; const publicPr = pr9;
const hiddenPr = PrNums.TRUST_CHECK_UNTRUSTED; const hiddenPr = String(c.BV_getPrIsTrusted_notTrusted);
h.createDummyBuild(publicPr, SHA, true); h.createDummyBuild(publicPr, sha9, true);
h.createDummyBuild(hiddenPr, SHA, false); h.createDummyBuild(hiddenPr, sha9, false);
// PR visibilities are already up-to-date. // PR visibilities are already up-to-date.
expect({ prNum: publicPr, sha: SHA, isPublic: false }).not.toExistAsABuild(false); expect(h.buildExists(publicPr, '', false)).toBe(false);
expect({ prNum: publicPr, sha: SHA, isPublic: true }).toExistAsABuild(false); expect(h.buildExists(publicPr, '', true)).toBe(true);
expect({ prNum: hiddenPr, sha: SHA, isPublic: false }).toExistAsABuild(false); expect(h.buildExists(hiddenPr, '', false)).toBe(true);
expect({ prNum: hiddenPr, sha: SHA, isPublic: true }).not.toExistAsABuild(false); expect(h.buildExists(hiddenPr, '', true)).toBe(false);
await Promise.all([ Promise.
prUpdated(publicPr).then(h.verifyResponse(200)), all([
prUpdated(hiddenPr).then(h.verifyResponse(200)), prUpdated(+publicPr).then(h.verifyResponse(200)),
]); prUpdated(+hiddenPr).then(h.verifyResponse(200)),
]).
// PR visibilities are still up-to-date. then(() => {
expect({ prNum: publicPr, isPublic: true }).toExistAsABuild(); // PR visibilities are still up-to-date.
expect({ prNum: publicPr, isPublic: false }).not.toExistAsABuild(); expect(h.buildExists(publicPr, '', false)).toBe(false);
expect({ prNum: hiddenPr, isPublic: true }).not.toExistAsABuild(); expect(h.buildExists(publicPr, '', true)).toBe(true);
expect({ prNum: hiddenPr, isPublic: false }).toExistAsABuild(); expect(h.buildExists(hiddenPr, '', false)).toBe(true);
expect(h.buildExists(hiddenPr, '', true)).toBe(false);
}).
then(done);
}); });
it('should reject a request if re-checking visibility fails', async () => { it('should reject a request if re-checking visibility fails', done => {
const errorPr = PrNums.TRUST_CHECK_ERROR; const errorPr = String(c.BV_getPrIsTrusted_error);
h.createDummyBuild(errorPr, SHA, true); h.createDummyBuild(errorPr, sha9, true);
expect({ prNum: errorPr, isPublic: false }).not.toExistAsABuild(false); expect(h.buildExists(errorPr, '', false)).toBe(false);
expect({ prNum: errorPr, isPublic: true }).toExistAsABuild(false); expect(h.buildExists(errorPr, '', true)).toBe(true);
await prUpdated(errorPr).then(h.verifyResponse(500, /TRUST_CHECK_ERROR/)); prUpdated(+errorPr).
then(h.verifyResponse(500, /Test/)).
// PR visibility should not have been updated. then(() => {
expect({ prNum: errorPr, isPublic: false }).not.toExistAsABuild(); // PR visibility should not have been updated.
expect({ prNum: errorPr, isPublic: true }).toExistAsABuild(); expect(h.buildExists(errorPr, '', false)).toBe(false);
expect(h.buildExists(errorPr, '', true)).toBe(true);
}).
then(done);
}); });
it('should reject a request if updating visibility fails', async () => { it('should reject a request if updating visibility fails', done => {
// One way to cause an error is to have both a public and a hidden directory for the same PR. // One way to cause an error is to have both a public and a hidden directory for the same PR.
h.createDummyBuild(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, SHA, false); h.createDummyBuild(pr9, sha9, false);
h.createDummyBuild(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, SHA, true); h.createDummyBuild(pr9, sha9, true);
const hiddenPrDir = h.getPrDir(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, false); const hiddenPrDir = h.getPrDir(pr9, false);
const publicPrDir = h.getPrDir(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, true); const publicPrDir = h.getPrDir(pr9, true);
const bodyRegex = new RegExp(`Request to move '${hiddenPrDir}' to existing directory '${publicPrDir}'`); const bodyRegex = new RegExp(`Request to move '${hiddenPrDir}' to existing directory '${publicPrDir}'`);
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: false }).toExistAsABuild(false); expect(h.buildExists(pr9, '', false)).toBe(true);
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: true }).toExistAsABuild(false); expect(h.buildExists(pr9, '', true)).toBe(true);
await prUpdated(PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER).then(h.verifyResponse(409, bodyRegex)); prUpdated(+pr9).
then(h.verifyResponse(409, bodyRegex)).
// PR visibility should not have been updated. then(() => {
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: false }).toExistAsABuild(); // PR visibility should not have been updated.
expect({ prNum: PrNums.TRUST_CHECK_ACTIVE_TRUSTED_USER, isPublic: true }).toExistAsABuild(); expect(h.buildExists(pr9, '', false)).toBe(true);
expect(h.buildExists(pr9, '', true)).toBe(true);
}).
then(done);
}); });
}); });

View File

@ -1,2 +0,0 @@
import '../preview-server';
import './mock-external-apis';

View File

@ -0,0 +1,38 @@
// 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');

View File

@ -1,30 +0,0 @@
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;
}

View File

@ -0,0 +1,571 @@
// 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);
});
});
});

View File

@ -8,7 +8,7 @@
"scripts": { "scripts": {
"prebuild": "yarn clean-dist", "prebuild": "yarn clean-dist",
"build": "tsc", "build": "tsc",
"build-watch": "yarn build --watch", "build-watch": "yarn tsc --watch",
"clean-dist": "node --eval \"require('shelljs').rm('-rf', 'dist')\"", "clean-dist": "node --eval \"require('shelljs').rm('-rf', 'dist')\"",
"dev": "concurrently --kill-others --raw --success first \"yarn build-watch\" \"yarn test-watch\"", "dev": "concurrently --kill-others --raw --success first \"yarn build-watch\" \"yarn test-watch\"",
"lint": "tslint --project tsconfig.json", "lint": "tslint --project tsconfig.json",
@ -21,23 +21,19 @@
}, },
"dependencies": { "dependencies": {
"body-parser": "^1.18.2", "body-parser": "^1.18.2",
"delete-empty": "^2.0.0",
"express": "^4.15.4", "express": "^4.15.4",
"jasmine": "^2.8.0", "jasmine": "^2.8.0",
"nock": "^9.2.5", "jsonwebtoken": "^8.0.1",
"node-fetch": "^2.1.2", "shelljs": "^0.7.8",
"shelljs": "^0.8.1",
"tar-stream": "^1.6.0",
"tslib": "^1.7.1" "tslib": "^1.7.1"
}, },
"devDependencies": { "devDependencies": {
"@types/body-parser": "^1.16.5", "@types/body-parser": "^1.16.5",
"@types/express": "^4.0.37", "@types/express": "^4.0.37",
"@types/jasmine": "^2.6.0", "@types/jasmine": "^2.6.0",
"@types/nock": "^9.1.3", "@types/jsonwebtoken": "^7.2.3",
"@types/node": "^8.0.30", "@types/node": "^8.0.30",
"@types/node-fetch": "^1.6.8", "@types/shelljs": "^0.7.4",
"@types/shelljs": "^0.8.0",
"@types/supertest": "^2.0.3", "@types/supertest": "^2.0.3",
"concurrently": "^3.5.0", "concurrently": "^3.5.0",
"nodemon": "^1.12.1", "nodemon": "^1.12.1",

View File

@ -1,176 +1,135 @@
// Imports // Imports
import * as fs from 'fs'; import * as fs from 'fs';
import {normalize} from 'path'; import * as path from 'path';
import * as shell from 'shelljs'; import * as shell from 'shelljs';
import {BuildCleaner} from '../../lib/clean-up/build-cleaner'; import {BuildCleaner} from '../../lib/clean-up/build-cleaner';
import {HIDDEN_DIR_PREFIX} from '../../lib/common/constants'; import {HIDDEN_DIR_PREFIX} from '../../lib/common/constants';
import {GithubPullRequests} from '../../lib/common/github-pull-requests'; import {GithubPullRequests} from '../../lib/common/github-pull-requests';
const EXISTING_BUILDS = [10, 20, 30, 40];
const EXISTING_DOWNLOADS = [
'downloads/10-ABCDEF0-build.zip',
'downloads/10-1234567-build.zip',
'downloads/20-ABCDEF0-build.zip',
'downloads/20-1234567-build.zip',
];
const OPEN_PRS = [10, 40];
const ANY_DATE = jasmine.any(String);
// Tests // Tests
describe('BuildCleaner', () => { describe('BuildCleaner', () => {
let cleaner: BuildCleaner; let cleaner: BuildCleaner;
beforeEach(() => { beforeEach(() => cleaner = new BuildCleaner('/foo/bar', 'baz/qux', '12345'));
spyOn(console, 'error');
spyOn(console, 'log');
cleaner = new BuildCleaner('/foo/bar', 'baz', 'qux', '12345', 'downloads', 'build.zip');
});
describe('constructor()', () => { describe('constructor()', () => {
it('should throw if \'buildsDir\' is empty', () => { it('should throw if \'buildsDir\' is empty', () => {
expect(() => new BuildCleaner('', 'baz', 'qux', '12345', 'downloads', 'build.zip')). expect(() => new BuildCleaner('', '/baz/qux', '12345')).
toThrowError('Missing or empty required parameter \'buildsDir\'!'); toThrowError('Missing or empty required parameter \'buildsDir\'!');
}); });
it('should throw if \'githubOrg\' is empty', () => { it('should throw if \'repoSlug\' is empty', () => {
expect(() => new BuildCleaner('/foo/bar', '', 'qux', '12345', 'downloads', 'build.zip')). expect(() => new BuildCleaner('/foo/bar', '', '12345')).
toThrowError('Missing or empty required parameter \'githubOrg\'!'); toThrowError('Missing or empty required parameter \'repoSlug\'!');
});
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', () => { it('should throw if \'githubToken\' is empty', () => {
expect(() => new BuildCleaner('/foo/bar', 'baz', 'qux', '', 'downloads', 'build.zip')). expect(() => new BuildCleaner('/foo/bar', 'baz/qux', '')).
toThrowError('Missing or empty required parameter \'githubToken\'!'); 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()', () => { describe('cleanUp()', () => {
let cleanerGetExistingBuildNumbersSpy: jasmine.Spy; let cleanerGetExistingBuildNumbersSpy: jasmine.Spy;
let cleanerGetOpenPrNumbersSpy: jasmine.Spy; let cleanerGetOpenPrNumbersSpy: jasmine.Spy;
let cleanerGetExistingDownloadsSpy: jasmine.Spy;
let cleanerRemoveUnnecessaryBuildsSpy: jasmine.Spy; let cleanerRemoveUnnecessaryBuildsSpy: jasmine.Spy;
let cleanerRemoveUnnecessaryDownloadsSpy: 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>;
beforeEach(() => { beforeEach(() => {
cleanerGetExistingBuildNumbersSpy = spyOn(cleaner, 'getExistingBuildNumbers') cleanerGetExistingBuildNumbersSpy = spyOn(cleaner as any, 'getExistingBuildNumbers').and.callFake(() => {
.and.callFake(() => Promise.resolve(EXISTING_BUILDS)); return new Promise((resolve, reject) => existingBuildsDeferred = {resolve, reject});
cleanerGetOpenPrNumbersSpy = spyOn(cleaner, 'getOpenPrNumbers') });
.and.callFake(() => Promise.resolve(OPEN_PRS)); cleanerGetOpenPrNumbersSpy = spyOn(cleaner as any, 'getOpenPrNumbers').and.callFake(() => {
cleanerGetExistingDownloadsSpy = spyOn(cleaner, 'getExistingDownloads') return new Promise((resolve, reject) => openPrsDeferred = {resolve, reject});
.and.callFake(() => Promise.resolve(EXISTING_DOWNLOADS)); });
cleanerRemoveUnnecessaryBuildsSpy = spyOn(cleaner as any, 'removeUnnecessaryBuilds');
cleanerRemoveUnnecessaryBuildsSpy = spyOn(cleaner, 'removeUnnecessaryBuilds');
cleanerRemoveUnnecessaryDownloadsSpy = spyOn(cleaner, 'removeUnnecessaryDownloads');
promise = cleaner.cleanUp();
}); });
it('should return a promise', () => { it('should return a promise', () => {
const promise = cleaner.cleanUp();
expect(promise).toEqual(jasmine.any(Promise)); expect(promise).toEqual(jasmine.any(Promise));
}); });
it('should get the open PRs', async () => { it('should get the existing builds', () => {
await cleaner.cleanUp();
expect(cleanerGetOpenPrNumbersSpy).toHaveBeenCalled();
});
it('should get the existing builds', async () => {
await cleaner.cleanUp();
expect(cleanerGetExistingBuildNumbersSpy).toHaveBeenCalled(); expect(cleanerGetExistingBuildNumbersSpy).toHaveBeenCalled();
}); });
it('should get the existing downloads', async () => { it('should get the open PRs', () => {
await cleaner.cleanUp(); expect(cleanerGetOpenPrNumbersSpy).toHaveBeenCalled();
expect(cleanerGetExistingDownloadsSpy).toHaveBeenCalled();
}); });
it('should pass existing builds and open PRs to \'removeUnnecessaryBuilds()\'', async () => { it('should reject if \'getExistingBuildNumbers()\' rejects', done => {
await cleaner.cleanUp(); promise.catch(err => {
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'); expect(err).toBe('Test');
} done();
});
existingBuildsDeferred.reject('Test');
}); });
it('should reject if \'getExistingBuildNumbers()\' rejects', async () => { it('should reject if \'getOpenPrNumbers()\' rejects', done => {
try { promise.catch(err => {
cleanerGetExistingBuildNumbersSpy.and.callFake(() => Promise.reject('Test'));
await cleaner.cleanUp();
} catch (err) {
expect(err).toBe('Test'); expect(err).toBe('Test');
} done();
});
openPrsDeferred.reject('Test');
}); });
it('should reject if \'getExistingDownloads()\' rejects', async () => { it('should reject if \'removeUnnecessaryBuilds()\' rejects', done => {
try { promise.catch(err => {
cleanerGetExistingDownloadsSpy.and.callFake(() => Promise.reject('Test'));
await cleaner.cleanUp();
} catch (err) {
expect(err).toBe('Test'); expect(err).toBe('Test');
} done();
});
cleanerRemoveUnnecessaryBuildsSpy.and.returnValue(Promise.reject('Test'));
existingBuildsDeferred.resolve();
openPrsDeferred.resolve();
}); });
it('should reject if \'removeUnnecessaryBuilds()\' rejects', async () => { it('should pass existing builds and open PRs to \'removeUnnecessaryBuilds()\'', done => {
try { promise.then(() => {
cleanerRemoveUnnecessaryBuildsSpy.and.callFake(() => Promise.reject('Test')); expect(cleanerRemoveUnnecessaryBuildsSpy).toHaveBeenCalledWith('foo', 'bar');
await cleaner.cleanUp(); done();
} catch (err) { });
expect(err).toBe('Test');
} existingBuildsDeferred.resolve('foo');
openPrsDeferred.resolve('bar');
}); });
it('should reject if \'removeUnnecessaryDownloads()\' rejects', async () => {
try { it('should resolve with the value returned by \'removeUnnecessaryBuilds()\'', done => {
cleanerRemoveUnnecessaryDownloadsSpy.and.callFake(() => Promise.reject('Test')); promise.then(result => {
await cleaner.cleanUp(); expect(result as any).toBe('Test');
} catch (err) { done();
expect(err).toBe('Test'); });
}
cleanerRemoveUnnecessaryBuildsSpy.and.returnValue(Promise.resolve('Test'));
existingBuildsDeferred.resolve();
openPrsDeferred.resolve();
}); });
}); });
// Protected methods
describe('getExistingBuildNumbers()', () => { describe('getExistingBuildNumbers()', () => {
let fsReaddirSpy: jasmine.Spy; let fsReaddirSpy: jasmine.Spy;
let readdirCb: (err: any, files?: string[]) => void; let readdirCb: (err: any, files?: string[]) => void;
@ -178,7 +137,7 @@ describe('BuildCleaner', () => {
beforeEach(() => { beforeEach(() => {
fsReaddirSpy = spyOn(fs, 'readdir').and.callFake((_: string, cb: typeof readdirCb) => readdirCb = cb); fsReaddirSpy = spyOn(fs, 'readdir').and.callFake((_: string, cb: typeof readdirCb) => readdirCb = cb);
promise = cleaner.getExistingBuildNumbers(); promise = (cleaner as any).getExistingBuildNumbers();
}); });
@ -244,7 +203,7 @@ describe('BuildCleaner', () => {
return new Promise((resolve, reject) => prDeferred = {resolve, reject}); return new Promise((resolve, reject) => prDeferred = {resolve, reject});
}); });
promise = cleaner.getOpenPrNumbers(); promise = (cleaner as any).getOpenPrNumbers();
}); });
@ -277,65 +236,6 @@ describe('BuildCleaner', () => {
prDeferred.resolve([{id: 0, number: 1}, {id: 1, number: 2}, {id: 2, number: 3}]); 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(console.log).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 builds 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 files (as numbers)', 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']);
});
}); });
@ -353,7 +253,7 @@ describe('BuildCleaner', () => {
it('should test if the directory exists (and return if is does not)', () => { it('should test if the directory exists (and return if is does not)', () => {
shellTestSpy.and.returnValue(false); shellTestSpy.and.returnValue(false);
cleaner.removeDir('/foo/bar'); (cleaner as any).removeDir('/foo/bar');
expect(shellTestSpy).toHaveBeenCalledWith('-d', '/foo/bar'); expect(shellTestSpy).toHaveBeenCalledWith('-d', '/foo/bar');
expect(shellChmodSpy).not.toHaveBeenCalled(); expect(shellChmodSpy).not.toHaveBeenCalled();
@ -362,117 +262,99 @@ describe('BuildCleaner', () => {
it('should remove the specified directory and its content', () => { it('should remove the specified directory and its content', () => {
cleaner.removeDir('/foo/bar'); (cleaner as any).removeDir('/foo/bar');
expect(shellRmSpy).toHaveBeenCalledWith('-rf', '/foo/bar'); expect(shellRmSpy).toHaveBeenCalledWith('-rf', '/foo/bar');
}); });
it('should make the directory and its content writable before removing', () => { it('should make the directory and its content writable before removing', () => {
shellRmSpy.and.callFake(() => expect(shellChmodSpy).toHaveBeenCalledWith('-R', 'a+w', '/foo/bar')); shellRmSpy.and.callFake(() => expect(shellChmodSpy).toHaveBeenCalledWith('-R', 'a+w', '/foo/bar'));
cleaner.removeDir('/foo/bar'); (cleaner as any).removeDir('/foo/bar');
expect(shellRmSpy).toHaveBeenCalled(); expect(shellRmSpy).toHaveBeenCalled();
}); });
it('should catch errors and log them', () => { it('should catch errors and log them', () => {
const consoleErrorSpy = spyOn(console, 'error');
shellRmSpy.and.callFake(() => { shellRmSpy.and.callFake(() => {
// tslint:disable-next-line: no-string-throw // tslint:disable-next-line: no-string-throw
throw 'Test'; throw 'Test';
}); });
cleaner.removeDir('/foo/bar'); (cleaner as any).removeDir('/foo/bar');
expect(console.error).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalled();
jasmine.any(String), 'BuildCleaner: ', 'ERROR: Unable to remove \'/foo/bar\' due to:', 'Test'); expect(consoleErrorSpy.calls.argsFor(0)[0]).toContain('Unable to remove \'/foo/bar\'');
expect(consoleErrorSpy.calls.argsFor(0)[1]).toBe('Test');
}); });
}); });
describe('removeUnnecessaryBuilds()', () => { describe('removeUnnecessaryBuilds()', () => {
let consoleLogSpy: jasmine.Spy;
let cleanerRemoveDirSpy: jasmine.Spy; let cleanerRemoveDirSpy: jasmine.Spy;
beforeEach(() => { beforeEach(() => {
cleanerRemoveDirSpy = spyOn(cleaner, 'removeDir'); consoleLogSpy = spyOn(console, 'log');
cleanerRemoveDirSpy = spyOn(cleaner as any, 'removeDir');
}); });
it('should log the number of existing builds and builds to be removed', () => { it('should log the number of existing builds, open PRs and builds to be removed', () => {
cleaner.removeUnnecessaryBuilds([1, 2, 3], [3, 4, 5, 6]); (cleaner as any).removeUnnecessaryBuilds([1, 2, 3], [3, 4, 5, 6]);
expect(console.log).toHaveBeenCalledWith(ANY_DATE, 'BuildCleaner: ', 'Existing builds: 3'); expect(console.log).toHaveBeenCalledWith('Existing builds: 3');
expect(console.log).toHaveBeenCalledWith(ANY_DATE, 'BuildCleaner: ', 'Removing 2 build(s): 1, 2'); expect(console.log).toHaveBeenCalledWith('Open pull requests: 4');
expect(console.log).toHaveBeenCalledWith('Removing 2 build(s): 1, 2');
}); });
it('should construct full paths to directories (by prepending \'buildsDir\')', () => { it('should construct full paths to directories (by prepending \'buildsDir\')', () => {
cleaner.removeUnnecessaryBuilds([1, 2, 3], []); (cleaner as any).removeUnnecessaryBuilds([1, 2, 3], []);
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/1')); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/1'));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/2')); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/2'));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/3')); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/3'));
}); });
it('should try removing hidden directories as well', () => { it('should try removing hidden directories as well', () => {
cleaner.removeUnnecessaryBuilds([1, 2, 3], []); (cleaner as any).removeUnnecessaryBuilds([1, 2, 3], []);
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`)); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`)); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`)); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
}); });
it('should remove the builds that do not correspond to open PRs', () => { it('should remove the builds that do not correspond to open PRs', () => {
cleaner.removeUnnecessaryBuilds([1, 2, 3, 4], [2, 4]); (cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], [2, 4]);
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(4); expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(4);
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/1')); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/1'));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/3')); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/3'));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`)); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`)); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
cleanerRemoveDirSpy.calls.reset(); cleanerRemoveDirSpy.calls.reset();
cleaner.removeUnnecessaryBuilds([1, 2, 3, 4], [1, 2, 3, 4]); (cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], [1, 2, 3, 4]);
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(0); expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(0);
cleanerRemoveDirSpy.calls.reset(); cleanerRemoveDirSpy.calls.reset();
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], []); (cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], []);
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(8); expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(8);
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/1')); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/1'));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/2')); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/2'));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/3')); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/3'));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize('/foo/bar/4')); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/4'));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`)); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`)); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`)); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}4`)); expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}4`));
cleanerRemoveDirSpy.calls.reset(); cleanerRemoveDirSpy.calls.reset();
}); });
}); });
describe('removeUnnecessaryDownloads()', () => {
beforeEach(() => {
spyOn(shell, 'rm');
});
it('should remove the downloads that do not correspond to open PRs', () => {
cleaner.removeUnnecessaryDownloads(EXISTING_DOWNLOADS, OPEN_PRS);
expect(shell.rm).toHaveBeenCalledTimes(2);
expect(shell.rm).toHaveBeenCalledWith('downloads/20-ABCDEF0-build.zip');
expect(shell.rm).toHaveBeenCalledWith('downloads/20-1234567-build.zip');
});
it('should log the number of existing builds and builds to be removed', () => {
cleaner.removeUnnecessaryDownloads(EXISTING_DOWNLOADS, OPEN_PRS);
expect(console.log).toHaveBeenCalledWith(ANY_DATE, 'BuildCleaner: ', 'Existing downloads: 4');
expect(console.log).toHaveBeenCalledWith(ANY_DATE, 'BuildCleaner: ',
'Removing 2 download(s): downloads/20-ABCDEF0-build.zip, downloads/20-1234567-build.zip');
});
});
}); });

View File

@ -1,134 +0,0 @@
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})`);
}
});
});
});

View File

@ -1,5 +1,7 @@
// Imports // Imports
import * as nock from 'nock'; import {EventEmitter} from 'events';
import {ClientRequest, IncomingMessage} from 'http';
import * as https from 'https';
import {GithubApi} from '../../lib/common/github-api'; import {GithubApi} from '../../lib/common/github-api';
// Tests // Tests
@ -108,6 +110,39 @@ 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()', () => { describe('getPaginated()', () => {
let deferreds: {resolve: (v: any) => void, reject: (v: any) => void}[]; let deferreds: {resolve: (v: any) => void, reject: (v: any) => void}[];
@ -183,162 +218,191 @@ 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()', () => { 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', () => { it('should return a promise', () => {
nock('https://api.github.com').get('').reply(200);
expect((api as any).request()).toEqual(jasmine.any(Promise)); expect((api as any).request()).toEqual(jasmine.any(Promise));
}); });
it('should call \'https.request()\' with the correct options', () => { it('should call \'https.request()\' with the correct options', () => {
const requestHandler = nock('https://api.github.com') (api as any).request('method', 'path');
.intercept('/path', 'method')
.reply(200);
(api as any).request('method', '/path'); expect(httpsRequestSpy).toHaveBeenCalled();
requestHandler.done(); 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',
}));
}); });
it('should add the \'Authorization\' header containing the \'githubToken\'', () => { it('should call specify an \'Authorization\' header if \'githubToken\' is present', () => {
const requestHandler = nock('https://api.github.com') (api as any).request('method', 'path');
.intercept('/path', 'method', undefined, {
reqheaders: {Authorization: 'token 12345'}, expect(httpsRequestSpy).toHaveBeenCalled();
}) expect(httpsRequestSpy.calls.argsFor(0)[0].headers).toEqual(jasmine.objectContaining({
.reply(200); Authorization: 'token 12345',
(api as any).request('method', '/path'); }));
requestHandler.done();
}); });
it('should reject on request error', async () => { it('should reject on request error', done => {
nock('https://api.github.com') (api as any).request('method', 'path').catch((err: any) => {
.intercept('/path', 'method') expect(err).toBe('Test');
.replyWithError('Test'); done();
let message = 'Failed to reject error'; });
await (api as any).request('method', '/path').catch((err: any) => message = err.message);
expect(message).toEqual('Test'); 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 \'JSON.stringify\' and send the data along with the request', () => { it('should \'JSON.stringify\' and send the data along with the request', () => {
const data = {key: 'value'}; (api as any).request('method', 'path');
const requestHandler = nock('https://api.github.com') expect(latestRequest.end).toHaveBeenCalledWith(null);
.intercept('/path', 'method', JSON.stringify(data))
.reply(200); (api as any).request('method', 'path', {key: 'value'});
(api as any).request('method', '/path', data); expect(latestRequest.end).toHaveBeenCalledWith('{"key":"value"}');
requestHandler.done();
}); });
it('should reject if response statusCode is <200', done => { describe('onResponse', () => {
const requestHandler = nock('https://api.github.com') let promise: Promise<object>;
.intercept('/path', 'method') let respond: (statusCode: number) => IncomingMessage;
.reply(199);
(api as any).request('method', '/path') beforeEach(() => {
.catch((err: string) => { 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 => {
expect(err).toContain('failed'); expect(err).toContain('failed');
expect(err).toContain('status: 199'); expect(err).toContain('status: 199');
done(); done();
}); });
requestHandler.done();
}); const res = respond(199);
res.emit('end');
});
it('should reject if response statusCode is >=400', done => { it('should reject if returned statusCode is >=400', done => {
const requestHandler = nock('https://api.github.com') promise.catch(err => {
.intercept('/path', 'method')
.reply(400);
(api as any).request('method', '/path')
.catch((err: string) => {
expect(err).toContain('failed'); expect(err).toContain('failed');
expect(err).toContain('status: 400'); expect(err).toContain('status: 400');
done(); done();
}); });
requestHandler.done();
}); const res = respond(400);
res.emit('end');
});
it('should include the response text in the rejection message', done => { it('should include the response text in the rejection message', done => {
const requestHandler = nock('https://api.github.com') promise.catch(err => {
.intercept('/path', 'method')
.reply(500, 'Test');
(api as any).request('method', '/path')
.catch((err: string) => {
expect(err).toContain('Test'); expect(err).toContain('Test');
done(); done();
}); });
requestHandler.done();
});
const res = respond(500);
it('should resolve if returned statusCode is >=200 and <400', done => { res.emit('data', 'Test');
const requestHandler = nock('https://api.github.com') res.emit('end');
.intercept('/path', 'method')
.reply(200);
(api as any).request('method', '/path').then(done);
requestHandler.done();
});
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();
}); });
requestHandler.done();
});
it('should reject if the response text is malformed JSON', done => {
const requestHandler = nock('https://api.github.com')
.intercept('/path', 'method')
.reply(300, '}');
(api as any).request('method', '/path').catch((err: any) => { it('should resolve if returned statusCode is <=200 <400', done => {
expect(err).toEqual(jasmine.any(SyntaxError)); promise.then(done);
done();
const res = respond(200);
res.emit('data', '{}');
res.emit('end');
}); });
requestHandler.done();
it('should resolve with the response text \'JSON.parsed\'', done => {
promise.then(data => {
expect(data).toEqual({foo: 'bar'});
done();
});
const res = respond(300);
res.emit('data', '{"foo":"bar"}');
res.emit('end');
});
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 => {
expect(err).toEqual(jasmine.any(SyntaxError));
done();
});
const res = respond(300);
res.emit('data', '}');
res.emit('end');
});
}); });
}); });

View File

@ -1,27 +1,20 @@
// Imports // Imports
import {GithubApi} from '../../lib/common/github-api';
import {GithubPullRequests} from '../../lib/common/github-pull-requests'; import {GithubPullRequests} from '../../lib/common/github-pull-requests';
// Tests // Tests
describe('GithubPullRequests', () => { describe('GithubPullRequests', () => {
let githubApi: jasmine.SpyObj<GithubApi>;
beforeEach(() => {
githubApi = jasmine.createSpyObj('githubApi', ['post', 'get', 'getPaginated']);
});
describe('constructor()', () => { describe('constructor()', () => {
it('should throw if \'githubOrg\' is missing or empty', () => { it('should throw if \'githubToken\' is missing or empty', () => {
expect(() => new GithubPullRequests(githubApi, '', 'bar')). expect(() => new GithubPullRequests('', 'foo/bar')).
toThrowError('Missing or empty required parameter \'githubOrg\'!'); toThrowError('Missing or empty required parameter \'githubToken\'!');
}); });
it('should throw if \'githubRepo\' is missing or empty', () => { it('should throw if \'repoSlug\' is missing or empty', () => {
expect(() => new GithubPullRequests(githubApi, 'foo', '')). expect(() => new GithubPullRequests('12345', '')).
toThrowError('Missing or empty required parameter \'githubRepo\'!'); toThrowError('Missing or empty required parameter \'repoSlug\'!');
}); });
}); });
@ -29,9 +22,17 @@ describe('GithubPullRequests', () => {
describe('addComment()', () => { describe('addComment()', () => {
let prs: GithubPullRequests; let prs: GithubPullRequests;
let deferred: {resolve: (v: any) => void, reject: (v: any) => void};
beforeEach(() => { beforeEach(() => {
prs = new GithubPullRequests(githubApi, 'foo', 'bar'); 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));
}); });
@ -46,28 +47,30 @@ describe('GithubPullRequests', () => {
}); });
it('should make a POST request to Github with the correct pathname, params and data', () => { it('should call \'post()\' with the correct pathname, params and data', () => {
githubApi.post.and.callFake(() => Promise.resolve());
prs.addComment(42, 'body'); prs.addComment(42, 'body');
expect(githubApi.post).toHaveBeenCalledWith('/repos/foo/bar/issues/42/comments', null, {body: 'body'});
expect(prs.post).toHaveBeenCalledWith('/repos/foo/bar/issues/42/comments', null, {body: 'body'});
}); });
it('should reject if the request fails', done => { it('should reject if the request fails', done => {
githubApi.post.and.callFake(() => Promise.reject('Test'));
prs.addComment(42, 'body').catch(err => { prs.addComment(42, 'body').catch(err => {
expect(err).toBe('Test'); expect(err).toBe('Test');
done(); done();
}); });
deferred.reject('Test');
}); });
it('should resolve with the data from the Github POST', done => { it('should resolve with the returned response', done => {
githubApi.post.and.callFake(() => Promise.resolve('Test'));
prs.addComment(42, 'body').then(data => { prs.addComment(42, 'body').then(data => {
expect(data).toBe('Test'); expect(data as any).toBe('Test');
done(); done();
}); });
deferred.resolve('Test');
}); });
}); });
@ -75,34 +78,35 @@ describe('GithubPullRequests', () => {
describe('fetch()', () => { describe('fetch()', () => {
let prs: GithubPullRequests; let prs: GithubPullRequests;
let prsGetSpy: jasmine.Spy;
beforeEach(() => { beforeEach(() => {
prs = new GithubPullRequests(githubApi, 'foo', 'bar'); prs = new GithubPullRequests('12345', 'foo/bar');
prsGetSpy = spyOn(prs as any, 'get');
}); });
it('should make a GET request to GitHub with the correct pathname', () => { it('should call \'get()\' with the correct pathname', () => {
prs.fetch(42); prs.fetch(42);
expect(githubApi.get).toHaveBeenCalledWith('/repos/foo/bar/issues/42'); expect(prsGetSpy).toHaveBeenCalledWith('/repos/foo/bar/issues/42');
}); });
it('should resolve with the data returned from GitHub', done => { it('should forward the value returned by \'get()\'', () => {
const expected: any = {number: 42}; prsGetSpy.and.returnValue('Test');
githubApi.get.and.callFake(() => Promise.resolve(expected)); expect(prs.fetch(42) as any).toBe('Test');
prs.fetch(42).then(data => {
expect(data).toEqual(expected);
done();
});
}); });
}); });
describe('fetchAll()', () => { describe('fetchAll()', () => {
let prs: GithubPullRequests; let prs: GithubPullRequests;
let prsGetPaginatedSpy: jasmine.Spy;
beforeEach(() => { beforeEach(() => {
prs = new GithubPullRequests(githubApi, 'foo', 'bar'); prs = new GithubPullRequests('12345', 'foo/bar');
prsGetPaginatedSpy = spyOn(prs as any, 'getPaginated');
spyOn(console, 'log'); spyOn(console, 'log');
}); });
@ -114,48 +118,24 @@ describe('GithubPullRequests', () => {
prs.fetchAll('closed'); prs.fetchAll('closed');
prs.fetchAll('open'); prs.fetchAll('open');
expect(githubApi.getPaginated).toHaveBeenCalledTimes(3); expect(prsGetPaginatedSpy).toHaveBeenCalledTimes(3);
expect(githubApi.getPaginated.calls.argsFor(0)).toEqual([expectedPathname, {state: 'all'}]); expect(prsGetPaginatedSpy.calls.argsFor(0)).toEqual([expectedPathname, {state: 'all'}]);
expect(githubApi.getPaginated.calls.argsFor(1)).toEqual([expectedPathname, {state: 'closed'}]); expect(prsGetPaginatedSpy.calls.argsFor(1)).toEqual([expectedPathname, {state: 'closed'}]);
expect(githubApi.getPaginated.calls.argsFor(2)).toEqual([expectedPathname, {state: 'open'}]); expect(prsGetPaginatedSpy.calls.argsFor(2)).toEqual([expectedPathname, {state: 'open'}]);
}); });
it('should default to \'all\' if no state is specified', () => { it('should default to \'all\' if no state is specified', () => {
prs.fetchAll(); prs.fetchAll();
expect(githubApi.getPaginated).toHaveBeenCalledWith('/repos/foo/bar/pulls', {state: 'all'}); expect(prsGetPaginatedSpy).toHaveBeenCalledWith('/repos/foo/bar/pulls', {state: 'all'});
}); });
it('should forward the value returned by \'getPaginated()\'', () => { it('should forward the value returned by \'getPaginated()\'', () => {
githubApi.getPaginated.and.returnValue('Test'); prsGetPaginatedSpy.and.returnValue('Test');
expect(prs.fetchAll() as any).toBe('Test'); expect(prs.fetchAll() as any).toBe('Test');
}); });
}); });
describe('fetchFiles()', () => {
let prs: GithubPullRequests;
beforeEach(() => {
prs = new GithubPullRequests(githubApi, 'foo', 'bar');
});
it('should make a GET request to GitHub with the correct pathname', () => {
prs.fetchFiles(42);
expect(githubApi.get).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.get.and.callFake(() => Promise.resolve(expected));
prs.fetch(42).then(data => {
expect(data).toEqual(expected);
done();
});
});
});
}); });

View File

@ -1,40 +1,43 @@
import {GithubApi} from '../../lib/common/github-api'; // Imports
import {GithubTeams} from '../../lib/common/github-teams'; import {GithubTeams} from '../../lib/common/github-teams';
// Tests // Tests
describe('GithubTeams', () => { describe('GithubTeams', () => {
let githubApi: jasmine.SpyObj<GithubApi>;
beforeEach(() => {
githubApi = jasmine.createSpyObj('githubApi', ['post', 'get', 'getPaginated']);
});
describe('constructor()', () => { describe('constructor()', () => {
it('should throw if \'githubOrg\' is missing or empty', () => { it('should throw if \'githubToken\' is missing or empty', () => {
expect(() => new GithubTeams(githubApi, '')). expect(() => new GithubTeams('', 'org')).
toThrowError('Missing or empty required parameter \'githubOrg\'!'); toThrowError('Missing or empty required parameter \'githubToken\'!');
}); });
it('should throw if \'organization\' is missing or empty', () => {
expect(() => new GithubTeams('12345', '')).
toThrowError('Missing or empty required parameter \'organization\'!');
});
}); });
describe('fetchAll()', () => { describe('fetchAll()', () => {
let teams: GithubTeams; let teams: GithubTeams;
let teamsGetPaginatedSpy: jasmine.Spy;
beforeEach(() => { beforeEach(() => {
teams = new GithubTeams(githubApi, 'foo'); teams = new GithubTeams('12345', 'foo');
teamsGetPaginatedSpy = spyOn(teams as any, 'getPaginated');
}); });
it('should call \'getPaginated()\' with the correct pathname and params', () => { it('should call \'getPaginated()\' with the correct pathname and params', () => {
teams.fetchAll(); teams.fetchAll();
expect(githubApi.getPaginated).toHaveBeenCalledWith('/orgs/foo/teams'); expect(teamsGetPaginatedSpy).toHaveBeenCalledWith('/orgs/foo/teams');
}); });
it('should forward the value returned by \'getPaginated()\'', () => { it('should forward the value returned by \'getPaginated()\'', () => {
githubApi.getPaginated.and.returnValue('Test'); teamsGetPaginatedSpy.and.returnValue('Test');
expect(teams.fetchAll() as any).toBe('Test'); expect(teams.fetchAll() as any).toBe('Test');
}); });
@ -43,15 +46,19 @@ describe('GithubTeams', () => {
describe('isMemberById()', () => { describe('isMemberById()', () => {
let teams: GithubTeams; let teams: GithubTeams;
let teamsGetSpy: jasmine.Spy;
beforeEach(() => { beforeEach(() => {
teams = new GithubTeams(githubApi, 'foo'); teams = new GithubTeams('12345', 'foo');
teamsGetSpy = spyOn(teams, 'get').and.returnValue(Promise.resolve(null));
}); });
it('should return a promise', () => { it('should return a promise', done => {
githubApi.get.and.callFake(() => Promise.resolve());
const promise = teams.isMemberById('user', [1]); 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)); expect(promise).toEqual(jasmine.any(Promise));
}); });
@ -59,43 +66,42 @@ describe('GithubTeams', () => {
it('should resolve with false if called with an empty array', done => { it('should resolve with false if called with an empty array', done => {
teams.isMemberById('user', []).then(isMember => { teams.isMemberById('user', []).then(isMember => {
expect(isMember).toBe(false); expect(isMember).toBe(false);
expect(githubApi.get).not.toHaveBeenCalled(); expect(teamsGetSpy).not.toHaveBeenCalled();
done(); done();
}); });
}); });
it('should call \'get()\' with the correct pathname', done => { it('should call \'get()\' with the correct pathname', done => {
githubApi.get.and.callFake(() => Promise.resolve());
teams.isMemberById('user', [1]).then(() => { teams.isMemberById('user', [1]).then(() => {
expect(githubApi.get).toHaveBeenCalledWith('/teams/1/memberships/user'); expect(teamsGetSpy).toHaveBeenCalledWith('/teams/1/memberships/user');
done(); done();
}); });
}); });
it('should resolve with false if \'get()\' rejects', done => { it('should resolve with false if \'get()\' rejects', done => {
githubApi.get.and.callFake(() => Promise.reject(null)); teamsGetSpy.and.returnValue(Promise.reject(null));
teams.isMemberById('user', [1]).then(isMember => { teams.isMemberById('user', [1]).then(isMember => {
expect(isMember).toBe(false); expect(isMember).toBe(false);
expect(githubApi.get).toHaveBeenCalled(); expect(teamsGetSpy).toHaveBeenCalled();
done(); done();
}); });
}); });
it('should resolve with false if the membership is not active', done => { it('should resolve with false if the membership is not active', done => {
githubApi.get.and.callFake(() => Promise.resolve({state: 'pending'})); teamsGetSpy.and.returnValue(Promise.resolve({state: 'pending'}));
teams.isMemberById('user', [1]).then(isMember => { teams.isMemberById('user', [1]).then(isMember => {
expect(isMember).toBe(false); expect(isMember).toBe(false);
expect(githubApi.get).toHaveBeenCalled(); expect(teamsGetSpy).toHaveBeenCalled();
done(); done();
}); });
}); });
it('should resolve with true if the membership is active', done => { it('should resolve with true if the membership is active', done => {
githubApi.get.and.callFake(() => Promise.resolve({state: 'active'})); teamsGetSpy.and.returnValue(Promise.resolve({state: 'active'}));
teams.isMemberById('user', [1]).then(isMember => { teams.isMemberById('user', [1]).then(isMember => {
expect(isMember).toBe(true); expect(isMember).toBe(true);
done(); done();
@ -109,15 +115,15 @@ describe('GithubTeams', () => {
'/teams/2/memberships/user': Promise.reject(null), '/teams/2/memberships/user': Promise.reject(null),
'/teams/3/memberships/user': Promise.resolve({state: 'active'}), '/teams/3/memberships/user': Promise.resolve({state: 'active'}),
}; };
githubApi.get.and.callFake((pathname: string) => trainedResponses[pathname]); teamsGetSpy.and.callFake((pathname: string) => trainedResponses[pathname]);
teams.isMemberById('user', [1, 2, 3, 4]).then(isMember => { teams.isMemberById('user', [1, 2, 3, 4]).then(isMember => {
expect(isMember).toBe(true); expect(isMember).toBe(true);
expect(githubApi.get).toHaveBeenCalledTimes(3); expect(teamsGetSpy).toHaveBeenCalledTimes(3);
expect(githubApi.get.calls.argsFor(0)[0]).toBe('/teams/1/memberships/user'); expect(teamsGetSpy.calls.argsFor(0)[0]).toBe('/teams/1/memberships/user');
expect(githubApi.get.calls.argsFor(1)[0]).toBe('/teams/2/memberships/user'); expect(teamsGetSpy.calls.argsFor(1)[0]).toBe('/teams/2/memberships/user');
expect(githubApi.get.calls.argsFor(2)[0]).toBe('/teams/3/memberships/user'); expect(teamsGetSpy.calls.argsFor(2)[0]).toBe('/teams/3/memberships/user');
done(); done();
}); });
@ -131,16 +137,16 @@ describe('GithubTeams', () => {
'/teams/3/memberships/user': Promise.resolve({state: 'not active'}), '/teams/3/memberships/user': Promise.resolve({state: 'not active'}),
'/teams/4/memberships/user': Promise.reject(null), '/teams/4/memberships/user': Promise.reject(null),
}; };
githubApi.get.and.callFake((pathname: string) => trainedResponses[pathname]); teamsGetSpy.and.callFake((pathname: string) => trainedResponses[pathname]);
teams.isMemberById('user', [1, 2, 3, 4]).then(isMember => { teams.isMemberById('user', [1, 2, 3, 4]).then(isMember => {
expect(isMember).toBe(false); expect(isMember).toBe(false);
expect(githubApi.get).toHaveBeenCalledTimes(4); expect(teamsGetSpy).toHaveBeenCalledTimes(4);
expect(githubApi.get.calls.argsFor(0)[0]).toBe('/teams/1/memberships/user'); expect(teamsGetSpy.calls.argsFor(0)[0]).toBe('/teams/1/memberships/user');
expect(githubApi.get.calls.argsFor(1)[0]).toBe('/teams/2/memberships/user'); expect(teamsGetSpy.calls.argsFor(1)[0]).toBe('/teams/2/memberships/user');
expect(githubApi.get.calls.argsFor(2)[0]).toBe('/teams/3/memberships/user'); expect(teamsGetSpy.calls.argsFor(2)[0]).toBe('/teams/3/memberships/user');
expect(githubApi.get.calls.argsFor(3)[0]).toBe('/teams/4/memberships/user'); expect(teamsGetSpy.calls.argsFor(3)[0]).toBe('/teams/4/memberships/user');
done(); done();
}); });
@ -155,7 +161,7 @@ describe('GithubTeams', () => {
let teamsIsMemberByIdSpy: jasmine.Spy; let teamsIsMemberByIdSpy: jasmine.Spy;
beforeEach(() => { beforeEach(() => {
teams = new GithubTeams(githubApi, 'foo'); teams = new GithubTeams('12345', 'foo');
const mockResponse = Promise.resolve([{id: 1, slug: 'team1'}, {id: 2, slug: 'team2'}]); const mockResponse = Promise.resolve([{id: 1, slug: 'team1'}, {id: 2, slug: 'team2'}]);
teamsFetchAllSpy = spyOn(teams, 'fetchAll').and.returnValue(mockResponse); teamsFetchAllSpy = spyOn(teams, 'fetchAll').and.returnValue(mockResponse);
@ -175,7 +181,7 @@ describe('GithubTeams', () => {
it('should resolve with false if \'fetchAll()\' rejects', done => { it('should resolve with false if \'fetchAll()\' rejects', done => {
teamsFetchAllSpy.and.callFake(() => Promise.reject(null)); teamsFetchAllSpy.and.returnValue(Promise.reject(null));
teams.isMemberBySlug('user', ['team-slug']).then(isMember => { teams.isMemberBySlug('user', ['team-slug']).then(isMember => {
expect(isMember).toBe(false); expect(isMember).toBe(false);
done(); done();
@ -203,7 +209,7 @@ describe('GithubTeams', () => {
it('should resolve with false if \'isMemberById()\' rejects', done => { it('should resolve with false if \'isMemberById()\' rejects', done => {
teamsIsMemberByIdSpy.and.callFake(() => Promise.reject(null)); teamsIsMemberByIdSpy.and.returnValue(Promise.reject(null));
teams.isMemberBySlug('user', ['team1']).then(isMember => { teams.isMemberBySlug('user', ['team1']).then(isMember => {
expect(isMember).toBe(false); expect(isMember).toBe(false);
expect(teamsIsMemberByIdSpy).toHaveBeenCalled(); expect(teamsIsMemberByIdSpy).toHaveBeenCalled();
@ -212,17 +218,16 @@ describe('GithubTeams', () => {
}); });
it('should resolve with the value \'isMemberById()\' resolves with', async () => { it('should resolve with the value \'isMemberById()\' resolves with', done => {
teamsIsMemberByIdSpy.and.returnValues(Promise.resolve(false), Promise.resolve(true));
teamsIsMemberByIdSpy.and.callFake(() => Promise.resolve(true)); Promise.all([
const isMember1 = await teams.isMemberBySlug('user', ['team1']); teams.isMemberBySlug('user', ['team1']).then(isMember => expect(isMember).toBe(false)),
expect(isMember1).toBe(true); teams.isMemberBySlug('user', ['team1']).then(isMember => expect(isMember).toBe(true)),
expect(teamsIsMemberByIdSpy).toHaveBeenCalledWith('user', [1]); ]).then(() => {
expect(teamsIsMemberByIdSpy).toHaveBeenCalledTimes(2);
teamsIsMemberByIdSpy.and.callFake(() => Promise.resolve(false)); done();
const isMember2 = await teams.isMemberBySlug('user', ['team1']); });
expect(isMember2).toBe(false);
expect(teamsIsMemberByIdSpy).toHaveBeenCalledWith('user', [1]);
}); });
}); });

View File

@ -1,53 +1,9 @@
// Imports // Imports
import { import {assertNotMissingOrEmpty, getEnvVar} from '../../lib/common/utils';
assert,
assertNotMissingOrEmpty,
computeArtifactDownloadPath,
computeShortSha,
getEnvVar,
getPrInfoFromDownloadPath,
} from '../../lib/common/utils';
// Tests // Tests
describe('utils', () => { 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).toEqual('/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()', () => { describe('assertNotMissingOrEmpty()', () => {
it('should throw if passed an empty value', () => { it('should throw if passed an empty value', () => {

View File

@ -1,191 +0,0 @@
import * as fs from 'fs';
import * as nock from 'nock';
import {BuildInfo, CircleCiApi} from '../../lib/common/circle-ci-api';
import {BuildRetriever} from '../../lib/preview-server/build-retriever';
describe('BuildRetriever', () => {
const MAX_DOWNLOAD_SIZE = 10000;
const DOWNLOAD_DIR = '/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',
};
spyOn(console, 'log');
spyOn(console, 'warn');
spyOn(console, 'error');
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(() => {
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);
await retriever.downloadBuildArtifact(12345, 777, 'COMMIT', ARTIFACT_PATH);
expect(writeFileSpy)
.toHaveBeenCalledWith(`${DOWNLOAD_DIR}/777-COMMIT-build.zip`, 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();
});
});
});

View File

@ -1,180 +0,0 @@
// 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();
});
});
});
});
});

View File

@ -1,11 +0,0 @@
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);
}
};

View File

@ -1,39 +0,0 @@
// 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('');
});
});

View File

@ -1,600 +0,0 @@
// Imports
import * as express from 'express';
import * as http from 'http';
import * as supertest from 'supertest';
import {promisify} from 'util';
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 {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',
};
// Helpers
const createPreviewServer = (partialConfig: Partial<PreviewServerConfig> = {}) =>
PreviewServerFactory.create({...defaultConfig, ...partialConfig});
beforeEach(() => {
spyOn(console, 'error');
spyOn(console, 'info');
spyOn(console, '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(console.info).not.toHaveBeenCalled();
server.emit('listening');
expect(console.info).toHaveBeenCalledWith(
jasmine.any(String), 'PreviewServer: ', '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>;
// Helpers
const promisifyRequest = async (req: supertest.Request) => await promisify(req.end.bind(req))();
const verifyRequests = async (reqs: supertest.Request[]) => await Promise.all(reqs.map(promisifyRequest));
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 verifyRequests([
agent.get('/health-check').expect(200),
agent.get('/health-check/').expect(200),
]);
});
it('should respond with 404 for non-GET requests', async () => {
await 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),
]);
});
it('should respond with 404 if the path does not match exactly', async () => {
await 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),
]);
});
});
describe('/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(console.log).toHaveBeenCalledWith(jasmine.any(String), 'PreviewServer: ',
'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(console.log).toHaveBeenCalledWith(jasmine.any(String), 'PreviewServer: ',
'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 verifyRequests([
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 verifyRequests([
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 verifyRequests([
request1.expect(400, `${responseBodyPrefix} {}`),
request2.expect(400, `${responseBodyPrefix} {"number":null}`),
]);
});
it('should call \'BuildVerifier#gtPrIsTrusted()\' with the correct arguments', async () => {
await promisifyRequest(createRequest(+pr));
expect(bvGetPrIsTrustedSpy).toHaveBeenCalledWith(9);
});
it('should propagate errors from BuildVerifier', async () => {
bvGetPrIsTrustedSpy.and.callFake(() => Promise.reject('Test'));
const req = createRequest(+pr).expect(500, 'Test');
await promisifyRequest(req);
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 promisifyRequest(createRequest(24));
expect(bcUpdatePrVisibilitySpy).toHaveBeenCalledWith(24, false);
await promisifyRequest(createRequest(42));
expect(bcUpdatePrVisibilitySpy).toHaveBeenCalledWith(42, true);
});
it('should propagate errors from BuildCreator', async () => {
bcUpdatePrVisibilitySpy.and.callFake(() => Promise.reject('Test'));
const req = createRequest(+pr).expect(500, 'Test');
await verifyRequests([req]);
});
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 verifyRequests(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 verifyRequests(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 verifyRequests(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])).
map(promisifyRequest);
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 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')),
]);
});
});
});
});

View File

@ -1,49 +0,0 @@
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);
});
});
});

View File

@ -5,20 +5,20 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as shell from 'shelljs'; import * as shell from 'shelljs';
import {SHORT_SHA_LEN} from '../../lib/common/constants'; import {SHORT_SHA_LEN} from '../../lib/common/constants';
import {BuildCreator} from '../../lib/preview-server/build-creator'; import {BuildCreator} from '../../lib/upload-server/build-creator';
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/preview-server/build-events'; import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/upload-server/build-events';
import {PreviewServerError} from '../../lib/preview-server/preview-error'; import {UploadError} from '../../lib/upload-server/upload-error';
import {expectToBePreviewServerError} from './helpers'; import {expectToBeUploadError} from './helpers';
// Tests // Tests
describe('BuildCreator', () => { describe('BuildCreator', () => {
const pr = 9; const pr = '9';
const sha = '9'.repeat(40); const sha = '9'.repeat(40);
const shortSha = sha.substr(0, SHORT_SHA_LEN); const shortSha = sha.substr(0, SHORT_SHA_LEN);
const archive = 'snapshot.tar.gz'; const archive = 'snapshot.tar.gz';
const buildsDir = 'builds/dir'; const buildsDir = 'builds/dir';
const hiddenPrDir = path.join(buildsDir, `hidden--${pr}`); 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 hiddenShaDir = path.join(hiddenPrDir, shortSha);
const publicShaDir = path.join(publicPrDir, shortSha); const publicShaDir = path.join(publicPrDir, shortSha);
let bc: BuildCreator; let bc: BuildCreator;
@ -134,8 +134,8 @@ describe('BuildCreator', () => {
it('should abort and skip further operations if changing the PR\'s visibility fails', done => { it('should abort and skip further operations if changing the PR\'s visibility fails', done => {
const mockError = new PreviewServerError(543, 'Test'); const mockError = new UploadError(543, 'Test');
bcUpdatePrVisibilitySpy.and.callFake(() => Promise.reject(mockError)); bcUpdatePrVisibilitySpy.and.returnValue(Promise.reject(mockError));
bc.create(pr, sha, archive, isPublic).catch(err => { bc.create(pr, sha, archive, isPublic).catch(err => {
expect(err).toBe(mockError); expect(err).toBe(mockError);
@ -154,7 +154,7 @@ describe('BuildCreator', () => {
existsValues[shaDir] = true; existsValues[shaDir] = true;
bc.create(pr, sha, archive, isPublic).catch(err => { bc.create(pr, sha, archive, isPublic).catch(err => {
const publicOrNot = isPublic ? 'public' : 'non-public'; const publicOrNot = isPublic ? 'public' : 'non-public';
expectToBePreviewServerError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`); expectToBeUploadError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`);
expect(shellMkdirSpy).not.toHaveBeenCalled(); expect(shellMkdirSpy).not.toHaveBeenCalled();
expect(bcExtractArchiveSpy).not.toHaveBeenCalled(); expect(bcExtractArchiveSpy).not.toHaveBeenCalled();
expect(bcEmitSpy).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled();
@ -171,7 +171,7 @@ describe('BuildCreator', () => {
bc.create(pr, sha, archive, isPublic).catch(err => { bc.create(pr, sha, archive, isPublic).catch(err => {
const publicOrNot = isPublic ? 'public' : 'non-public'; const publicOrNot = isPublic ? 'public' : 'non-public';
expectToBePreviewServerError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`); expectToBeUploadError(err, 409, `Request to overwrite existing ${publicOrNot} directory: ${shaDir}`);
expect(shellMkdirSpy).not.toHaveBeenCalled(); expect(shellMkdirSpy).not.toHaveBeenCalled();
expect(bcExtractArchiveSpy).not.toHaveBeenCalled(); expect(bcExtractArchiveSpy).not.toHaveBeenCalled();
expect(bcEmitSpy).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled();
@ -222,20 +222,20 @@ describe('BuildCreator', () => {
}); });
it('should reject with an PreviewServerError', done => { it('should reject with an UploadError', done => {
// tslint:disable-next-line: no-string-throw // tslint:disable-next-line: no-string-throw
shellMkdirSpy.and.callFake(() => { throw 'Test'; }); shellMkdirSpy.and.callFake(() => { throw 'Test'; });
bc.create(pr, sha, archive, isPublic).catch(err => { bc.create(pr, sha, archive, isPublic).catch(err => {
expectToBePreviewServerError(err, 500, `Error while creating preview at: ${shaDir}\nTest`); expectToBeUploadError(err, 500, `Error while uploading to directory: ${shaDir}\nTest`);
done(); done();
}); });
}); });
it('should pass PreviewServerError instances unmodified', done => { it('should pass UploadError instances unmodified', done => {
shellMkdirSpy.and.callFake(() => { throw new PreviewServerError(543, 'Test'); }); shellMkdirSpy.and.callFake(() => { throw new UploadError(543, 'Test'); });
bc.create(pr, sha, archive, isPublic).catch(err => { bc.create(pr, sha, archive, isPublic).catch(err => {
expectToBePreviewServerError(err, 543, 'Test'); expectToBeUploadError(err, 543, 'Test');
done(); done();
}); });
}); });
@ -324,7 +324,7 @@ describe('BuildCreator', () => {
const shas = ['foo', 'bar', 'baz']; const shas = ['foo', 'bar', 'baz'];
let emitted = false; let emitted = false;
bcListShasByDate.and.callFake(() => Promise.resolve(shas)); bcListShasByDate.and.returnValue(Promise.resolve(shas));
bcEmitSpy.and.callFake((type: string, evt: ChangedPrVisibilityEvent) => { bcEmitSpy.and.callFake((type: string, evt: ChangedPrVisibilityEvent) => {
expect(bcListShasByDate).toHaveBeenCalledWith(newPrDir); expect(bcListShasByDate).toHaveBeenCalledWith(newPrDir);
@ -376,8 +376,7 @@ describe('BuildCreator', () => {
it('should abort and skip further operations if both directories exist', done => { it('should abort and skip further operations if both directories exist', done => {
bcExistsSpy.and.returnValue(true); bcExistsSpy.and.returnValue(true);
bc.updatePrVisibility(pr, makePublic).catch(err => { bc.updatePrVisibility(pr, makePublic).catch(err => {
expectToBePreviewServerError(err, 409, expectToBeUploadError(err, 409, `Request to move '${oldPrDir}' to existing directory '${newPrDir}'.`);
`Request to move '${oldPrDir}' to existing directory '${newPrDir}'.`);
expect(shellMvSpy).not.toHaveBeenCalled(); expect(shellMvSpy).not.toHaveBeenCalled();
expect(bcListShasByDate).not.toHaveBeenCalled(); expect(bcListShasByDate).not.toHaveBeenCalled();
expect(bcEmitSpy).not.toHaveBeenCalled(); expect(bcEmitSpy).not.toHaveBeenCalled();
@ -408,21 +407,20 @@ describe('BuildCreator', () => {
}); });
it('should reject with an PreviewServerError', done => { it('should reject with an UploadError', done => {
// tslint:disable-next-line: no-string-throw // tslint:disable-next-line: no-string-throw
shellMvSpy.and.callFake(() => { throw 'Test'; }); shellMvSpy.and.callFake(() => { throw 'Test'; });
bc.updatePrVisibility(pr, makePublic).catch(err => { bc.updatePrVisibility(pr, makePublic).catch(err => {
expectToBePreviewServerError(err, 500, expectToBeUploadError(err, 500, `Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\nTest`);
`Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\nTest`);
done(); done();
}); });
}); });
it('should pass PreviewServerError instances unmodified', done => { it('should pass UploadError instances unmodified', done => {
shellMvSpy.and.callFake(() => { throw new PreviewServerError(543, 'Test'); }); shellMvSpy.and.callFake(() => { throw new UploadError(543, 'Test'); });
bc.updatePrVisibility(pr, makePublic).catch(err => { bc.updatePrVisibility(pr, makePublic).catch(err => {
expectToBePreviewServerError(err, 543, 'Test'); expectToBeUploadError(err, 543, 'Test');
done(); done();
}); });
}); });
@ -453,7 +451,7 @@ describe('BuildCreator', () => {
it('should call \'fs.access()\' with the specified argument', () => { it('should call \'fs.access()\' with the specified argument', () => {
(bc as any).exists('foo'); (bc as any).exists('foo');
expect(fsAccessSpy).toHaveBeenCalledWith('foo', jasmine.any(Function)); expect(fs.access).toHaveBeenCalledWith('foo', jasmine.any(Function));
}); });
@ -513,8 +511,7 @@ describe('BuildCreator', () => {
it('should log (as a warning) any stderr output if extracting succeeded', done => { it('should log (as a warning) any stderr output if extracting succeeded', done => {
(bc as any).extractArchive('foo', 'bar'). (bc as any).extractArchive('foo', 'bar').
then(() => expect(consoleWarnSpy) then(() => expect(consoleWarnSpy).toHaveBeenCalledWith('This is stderr')).
.toHaveBeenCalledWith(jasmine.any(String), 'BuildCreator: ', 'This is stderr')).
then(done); then(done);
cpExecCbs[0](null, 'This is stdout', 'This is stderr'); cpExecCbs[0](null, 'This is stdout', 'This is stderr');
@ -530,7 +527,7 @@ describe('BuildCreator', () => {
}); });
it('should delete the build artifact file on success', done => { it('should delete the uploaded file on success', done => {
(bc as any).extractArchive('input/file', 'output/dir'). (bc as any).extractArchive('input/file', 'output/dir').
then(() => expect(shellRmSpy).toHaveBeenCalledWith('-f', 'input/file')). then(() => expect(shellRmSpy).toHaveBeenCalledWith('-f', 'input/file')).
then(done); then(done);
@ -570,7 +567,7 @@ describe('BuildCreator', () => {
}); });
it('should abort and reject if it fails to remove the build artifact file', done => { it('should abort and reject if it fails to remove the uploaded file', done => {
(bc as any).extractArchive('foo', 'bar').catch((err: any) => { (bc as any).extractArchive('foo', 'bar').catch((err: any) => {
expect(shellChmodSpy).toHaveBeenCalled(); expect(shellChmodSpy).toHaveBeenCalled();
expect(shellRmSpy).toHaveBeenCalled(); expect(shellRmSpy).toHaveBeenCalled();
@ -621,7 +618,7 @@ describe('BuildCreator', () => {
it('should reject if listing files fails', done => { it('should reject if listing files fails', done => {
shellLsSpy.and.callFake(() => Promise.reject('Test')); shellLsSpy.and.returnValue(Promise.reject('Test'));
(bc as any).listShasByDate('input/dir').catch((err: string) => { (bc as any).listShasByDate('input/dir').catch((err: string) => {
expect(err).toBe('Test'); expect(err).toBe('Test');
done(); done();
@ -630,7 +627,7 @@ describe('BuildCreator', () => {
it('should return the filenames', done => { it('should return the filenames', done => {
shellLsSpy.and.callFake(() => Promise.resolve([ shellLsSpy.and.returnValue(Promise.resolve([
lsResult('foo', 100), lsResult('foo', 100),
lsResult('bar', 200), lsResult('bar', 200),
lsResult('baz', 300), lsResult('baz', 300),
@ -643,7 +640,7 @@ describe('BuildCreator', () => {
it('should sort by date', done => { it('should sort by date', done => {
shellLsSpy.and.callFake(() => Promise.resolve([ shellLsSpy.and.returnValue(Promise.resolve([
lsResult('foo', 300), lsResult('foo', 300),
lsResult('bar', 100), lsResult('bar', 100),
lsResult('baz', 200), lsResult('baz', 200),
@ -663,7 +660,7 @@ describe('BuildCreator', () => {
]; ];
mockArray.sort = jasmine.createSpy('sort'); mockArray.sort = jasmine.createSpy('sort');
shellLsSpy.and.callFake(() => Promise.resolve(mockArray)); shellLsSpy.and.returnValue(Promise.resolve(mockArray));
(bc as any).listShasByDate('input/dir'). (bc as any).listShasByDate('input/dir').
then((shas: string[]) => { then((shas: string[]) => {
expect(shas).toEqual(['bar', 'baz', 'foo']); expect(shas).toEqual(['bar', 'baz', 'foo']);
@ -674,7 +671,7 @@ describe('BuildCreator', () => {
it('should only include directories', done => { it('should only include directories', done => {
shellLsSpy.and.callFake(() => Promise.resolve([ shellLsSpy.and.returnValue(Promise.resolve([
lsResult('foo', 100), lsResult('foo', 100),
lsResult('bar', 200, false), lsResult('bar', 200, false),
lsResult('baz', 300), lsResult('baz', 300),

View File

@ -1,5 +1,5 @@
// Imports // Imports
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/preview-server/build-events'; import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/upload-server/build-events';
// Tests // Tests
describe('ChangedPrVisibilityEvent', () => { describe('ChangedPrVisibilityEvent', () => {

View File

@ -0,0 +1,303 @@
// 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();
});
});
});
});

View File

@ -0,0 +1,11 @@
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);
}
};

View File

@ -0,0 +1,39 @@
// 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('');
});
});

View File

@ -0,0 +1,603 @@
// 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);
});
});
});
});

View File

@ -40,6 +40,12 @@
version "2.6.0" version "2.6.0"
resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-2.6.0.tgz#997b41a27752b4850af2683bc4a8d8222c25bd02" resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-2.6.0.tgz#997b41a27752b4850af2683bc4a8d8222c25bd02"
"@types/jsonwebtoken@^7.2.3":
version "7.2.3"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-7.2.3.tgz#483c8f39945e1e6d308dcc51fd4aeca5208d4dca"
dependencies:
"@types/node" "*"
"@types/mime@*": "@types/mime@*":
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.0.tgz#d24ffac7d1006fe68517202fb2aeba3dbe48284b" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.0.tgz#d24ffac7d1006fe68517202fb2aeba3dbe48284b"
@ -48,18 +54,6 @@
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.1.tgz#b683eb60be358304ef146f5775db4c0e3696a550" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.1.tgz#b683eb60be358304ef146f5775db4c0e3696a550"
"@types/nock@^9.1.3":
version "9.1.3"
resolved "https://registry.yarnpkg.com/@types/nock/-/nock-9.1.3.tgz#1d445679375b9e25afd449dc56585f81729454e8"
dependencies:
"@types/node" "*"
"@types/node-fetch@^1.6.8":
version "1.6.8"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-1.6.8.tgz#a59d8c75b300ddc3ca3eef23d449d677f9486c3d"
dependencies:
"@types/node" "*"
"@types/node@*": "@types/node@*":
version "7.0.31" version "7.0.31"
resolved "https://registry.yarnpkg.com/@types/node/-/node-7.0.31.tgz#80ea4d175599b2a00149c29a10a4eb2dff592e86" resolved "https://registry.yarnpkg.com/@types/node/-/node-7.0.31.tgz#80ea4d175599b2a00149c29a10a4eb2dff592e86"
@ -75,9 +69,9 @@
"@types/express-serve-static-core" "*" "@types/express-serve-static-core" "*"
"@types/mime" "*" "@types/mime" "*"
"@types/shelljs@^0.8.0": "@types/shelljs@^0.7.4":
version "0.8.0" version "0.7.4"
resolved "https://registry.yarnpkg.com/@types/shelljs/-/shelljs-0.8.0.tgz#0caa56b68baae4f68f44e0dd666ab30b098e3632" resolved "https://registry.yarnpkg.com/@types/shelljs/-/shelljs-0.7.4.tgz#137b5f31306eaff4de120ffe5b9d74b297809cfc"
dependencies: dependencies:
"@types/glob" "*" "@types/glob" "*"
"@types/node" "*" "@types/node" "*"
@ -118,12 +112,6 @@ ansi-align@^2.0.0:
dependencies: dependencies:
string-width "^2.0.0" string-width "^2.0.0"
ansi-green@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/ansi-green/-/ansi-green-0.1.1.tgz#8a5d9a979e458d57c40e33580b37390b8e10d0f7"
dependencies:
ansi-wrap "0.1.0"
ansi-regex@^0.2.0, ansi-regex@^0.2.1: ansi-regex@^0.2.0, ansi-regex@^0.2.1:
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-0.2.1.tgz#0d8e946967a3d8143f93e24e298525fc1b2235f9" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-0.2.1.tgz#0d8e946967a3d8143f93e24e298525fc1b2235f9"
@ -140,10 +128,6 @@ ansi-styles@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
ansi-wrap@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf"
anymatch@^1.3.0: anymatch@^1.3.0:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.0.tgz#a3e52fa39168c825ff57b0248126ce5a8ff95507" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.0.tgz#a3e52fa39168c825ff57b0248126ce5a8ff95507"
@ -196,10 +180,6 @@ assert-plus@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234"
assertion-error@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
async-each@^1.0.0: async-each@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d"
@ -228,6 +208,10 @@ balanced-match@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
base64url@2.0.0, base64url@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb"
bcrypt-pbkdf@^1.0.0: bcrypt-pbkdf@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d"
@ -238,13 +222,6 @@ binary-extensions@^1.0.0:
version "1.8.0" version "1.8.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.8.0.tgz#48ec8d16df4377eae5fa5884682480af4d95c774" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.8.0.tgz#48ec8d16df4377eae5fa5884682480af4d95c774"
bl@^1.0.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c"
dependencies:
readable-stream "^2.3.5"
safe-buffer "^5.1.1"
block-stream@*: block-stream@*:
version "0.0.9" version "0.0.9"
resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a"
@ -299,20 +276,9 @@ braces@^1.8.2:
preserve "^0.2.0" preserve "^0.2.0"
repeat-element "^1.1.2" repeat-element "^1.1.2"
buffer-alloc-unsafe@^0.1.0: buffer-equal-constant-time@1.0.1:
version "0.1.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-0.1.1.tgz#ffe1f67551dd055737de253337bfe853dfab1a6a" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
buffer-alloc@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.1.0.tgz#05514d33bf1656d3540c684f65b1202e90eca303"
dependencies:
buffer-alloc-unsafe "^0.1.0"
buffer-fill "^0.1.0"
buffer-fill@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-0.1.1.tgz#76d825c4d6e50e06b7a31eb520c04d08cc235071"
bytes@3.0.0: bytes@3.0.0:
version "3.0.0" version "3.0.0"
@ -330,17 +296,6 @@ caseless@~0.12.0:
version "0.12.0" version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
chai@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.2.tgz#0f64584ba642f0f2ace2806279f4f06ca23ad73c"
dependencies:
assertion-error "^1.0.1"
check-error "^1.0.1"
deep-eql "^3.0.0"
get-func-name "^2.0.0"
pathval "^1.0.0"
type-detect "^4.0.0"
chalk@0.5.1: chalk@0.5.1:
version "0.5.1" version "0.5.1"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.5.1.tgz#663b3a648b68b55d04690d49167aa837858f2174" resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.5.1.tgz#663b3a648b68b55d04690d49167aa837858f2174"
@ -361,10 +316,6 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
strip-ansi "^3.0.0" strip-ansi "^3.0.0"
supports-color "^2.0.0" supports-color "^2.0.0"
check-error@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
chokidar@^1.7.0: chokidar@^1.7.0:
version "1.7.0" version "1.7.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
@ -525,22 +476,6 @@ debug@^2.2.0:
dependencies: dependencies:
ms "2.0.0" ms "2.0.0"
debug@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
dependencies:
ms "2.0.0"
deep-eql@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
dependencies:
type-detect "^4.0.0"
deep-equal@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
deep-extend@~0.4.0: deep-extend@~0.4.0:
version "0.4.2" version "0.4.2"
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
@ -553,14 +488,6 @@ delegates@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
delete-empty@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/delete-empty/-/delete-empty-2.0.0.tgz#dcf7c4f93a98445119acd57b137d13e7af78fa39"
dependencies:
log-ok "^0.1.1"
relative "^3.0.2"
rimraf "^2.6.2"
depd@1.1.1, depd@~1.1.1: depd@1.1.1, depd@~1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359"
@ -593,6 +520,13 @@ ecc-jsbn@~0.1.1:
dependencies: dependencies:
jsbn "~0.1.0" jsbn "~0.1.0"
ecdsa-sig-formatter@1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1"
dependencies:
base64url "^2.0.0"
safe-buffer "^5.0.1"
ee-first@1.1.1: ee-first@1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@ -601,12 +535,6 @@ encodeurl@~1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20"
end-of-stream@^1.0.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
dependencies:
once "^1.4.0"
es6-promise@^3.3.1: es6-promise@^3.3.1:
version "3.3.1" version "3.3.1"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
@ -785,10 +713,6 @@ from@~0:
version "0.1.7" version "0.1.7"
resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe"
fs-constants@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
fs.realpath@^1.0.0: fs.realpath@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@ -830,10 +754,6 @@ gauge@~2.7.3:
strip-ansi "^3.0.1" strip-ansi "^3.0.1"
wide-align "^1.1.0" wide-align "^1.1.0"
get-func-name@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
get-stream@^3.0.0: get-stream@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
@ -972,7 +892,7 @@ inflight@^1.0.4:
once "^1.3.0" once "^1.3.0"
wrappy "1" wrappy "1"
inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.1:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
@ -1124,7 +1044,7 @@ json-stable-stringify@^1.0.1:
dependencies: dependencies:
jsonify "~0.0.0" jsonify "~0.0.0"
json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: json-stringify-safe@~5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
@ -1132,6 +1052,21 @@ jsonify@~0.0.0:
version "0.0.0" version "0.0.0"
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
jsonwebtoken@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.0.1.tgz#50daef8d0a8c7de2cd06bc1013b75b04ccf3f0cf"
dependencies:
jws "^3.1.4"
lodash.includes "^4.3.0"
lodash.isboolean "^3.0.3"
lodash.isinteger "^4.0.4"
lodash.isnumber "^3.0.3"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.once "^4.0.0"
ms "^2.0.0"
xtend "^4.0.1"
jsprim@^1.2.2: jsprim@^1.2.2:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.0.tgz#a3b87e40298d8c380552d8cc7628a0bb95a22918" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.0.tgz#a3b87e40298d8c380552d8cc7628a0bb95a22918"
@ -1141,6 +1076,23 @@ jsprim@^1.2.2:
json-schema "0.2.3" json-schema "0.2.3"
verror "1.3.6" verror "1.3.6"
jwa@^1.1.4:
version "1.1.5"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5"
dependencies:
base64url "2.0.0"
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.9"
safe-buffer "^5.0.1"
jws@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.4.tgz#f9e8b9338e8a847277d6444b1464f61880e050a2"
dependencies:
base64url "^2.0.0"
jwa "^1.1.4"
safe-buffer "^5.0.1"
kind-of@^3.0.2: kind-of@^3.0.2:
version "3.2.2" version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@ -1205,6 +1157,10 @@ lodash.defaults@^3.1.2:
lodash.assign "^3.0.0" lodash.assign "^3.0.0"
lodash.restparam "^3.0.0" lodash.restparam "^3.0.0"
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
lodash.isarguments@^3.0.0: lodash.isarguments@^3.0.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
@ -1213,6 +1169,26 @@ lodash.isarray@^3.0.0:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
lodash.isnumber@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
lodash.keys@^3.0.0: lodash.keys@^3.0.0:
version "3.1.2" version "3.1.2"
resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
@ -1221,25 +1197,18 @@ lodash.keys@^3.0.0:
lodash.isarguments "^3.0.0" lodash.isarguments "^3.0.0"
lodash.isarray "^3.0.0" lodash.isarray "^3.0.0"
lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
lodash.restparam@^3.0.0: lodash.restparam@^3.0.0:
version "3.6.1" version "3.6.1"
resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
lodash@^4.17.5:
version "4.17.5"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"
lodash@^4.5.1: lodash@^4.5.1:
version "4.17.4" version "4.17.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
log-ok@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/log-ok/-/log-ok-0.1.1.tgz#bea3dd36acd0b8a7240d78736b5b97c65444a334"
dependencies:
ansi-green "^0.1.1"
success-symbol "^0.1.0"
lowercase-keys@^1.0.0: lowercase-keys@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306"
@ -1319,13 +1288,13 @@ minimist@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1: "mkdirp@>=0.5 0", mkdirp@^0.5.1:
version "0.5.1" version "0.5.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
dependencies: dependencies:
minimist "0.0.8" minimist "0.0.8"
ms@2.0.0: ms@2.0.0, ms@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -1337,24 +1306,6 @@ negotiator@0.6.1:
version "0.6.1" version "0.6.1"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
nock@^9.2.5:
version "9.2.5"
resolved "https://registry.yarnpkg.com/nock/-/nock-9.2.5.tgz#c131fc8d3c4723f386be0269739638be84733f2f"
dependencies:
chai "^4.1.2"
debug "^3.1.0"
deep-equal "^1.0.0"
json-stringify-safe "^5.0.1"
lodash "^4.17.5"
mkdirp "^0.5.0"
propagate "^1.0.0"
qs "^6.5.1"
semver "^5.5.0"
node-fetch@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.1.2.tgz#ab884e8e7e57e38a944753cec706f788d1768bb5"
node-pre-gyp@^0.6.36: node-pre-gyp@^0.6.36:
version "0.6.36" version "0.6.36"
resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.36.tgz#db604112cb74e0d477554e9b505b17abddfab786" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.36.tgz#db604112cb74e0d477554e9b505b17abddfab786"
@ -1443,7 +1394,7 @@ on-finished@~2.3.0:
dependencies: dependencies:
ee-first "1.1.1" ee-first "1.1.1"
once@^1.3.0, once@^1.3.3, once@^1.4.0: once@^1.3.0, once@^1.3.3:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
dependencies: dependencies:
@ -1506,10 +1457,6 @@ path-to-regexp@0.1.7:
version "0.1.7" version "0.1.7"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
pathval@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
pause-stream@0.0.11: pause-stream@0.0.11:
version "0.0.11" version "0.0.11"
resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445"
@ -1536,14 +1483,6 @@ process-nextick-args@~1.0.6:
version "1.0.7" version "1.0.7"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3"
process-nextick-args@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
propagate@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/propagate/-/propagate-1.0.0.tgz#00c2daeedda20e87e3782b344adba1cddd6ad709"
proxy-addr@~1.1.5: proxy-addr@~1.1.5:
version "1.1.5" version "1.1.5"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.5.tgz#71c0ee3b102de3f202f3b64f608d173fcba1a918" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.5.tgz#71c0ee3b102de3f202f3b64f608d173fcba1a918"
@ -1569,7 +1508,7 @@ qs@6.5.0:
version "6.5.0" version "6.5.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.0.tgz#8d04954d364def3efc55b5a0793e1e2c8b1e6e49" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.0.tgz#8d04954d364def3efc55b5a0793e1e2c8b1e6e49"
qs@6.5.1, qs@^6.5.1: qs@6.5.1:
version "6.5.1" version "6.5.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
@ -1606,18 +1545,6 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.1.7:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
readable-stream@^2.0.0, readable-stream@^2.3.5:
version "2.3.6"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.3"
isarray "~1.0.0"
process-nextick-args "~2.0.0"
safe-buffer "~5.1.1"
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4: readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4:
version "2.2.11" version "2.2.11"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.11.tgz#0796b31f8d7688007ff0b93a8088d34aa17c0f72" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.11.tgz#0796b31f8d7688007ff0b93a8088d34aa17c0f72"
@ -1665,12 +1592,6 @@ registry-url@^3.0.3:
dependencies: dependencies:
rc "^1.0.1" rc "^1.0.1"
relative@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/relative/-/relative-3.0.2.tgz#0dcd8ec54a5d35a3c15e104503d65375b5a5367f"
dependencies:
isobject "^2.0.0"
remove-trailing-separator@^1.0.1: remove-trailing-separator@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.0.2.tgz#69b062d978727ad14dc6b56ba4ab772fd8d70511" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.0.2.tgz#69b062d978727ad14dc6b56ba4ab772fd8d70511"
@ -1728,12 +1649,6 @@ rimraf@2, rimraf@^2.5.1, rimraf@^2.6.1:
dependencies: dependencies:
glob "^7.0.5" glob "^7.0.5"
rimraf@^2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36"
dependencies:
glob "^7.0.5"
rx@2.3.24: rx@2.3.24:
version "2.3.24" version "2.3.24"
resolved "https://registry.yarnpkg.com/rx/-/rx-2.3.24.tgz#14f950a4217d7e35daa71bbcbe58eff68ea4b2b7" resolved "https://registry.yarnpkg.com/rx/-/rx-2.3.24.tgz#14f950a4217d7e35daa71bbcbe58eff68ea4b2b7"
@ -1742,10 +1657,6 @@ safe-buffer@^5.0.1:
version "5.1.0" version "5.1.0"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.0.tgz#fe4c8460397f9eaaaa58e73be46273408a45e223" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.0.tgz#fe4c8460397f9eaaaa58e73be46273408a45e223"
safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
safe-buffer@~5.0.1: safe-buffer@~5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
@ -1760,10 +1671,6 @@ semver@^5.0.3, semver@^5.1.0, semver@^5.3.0:
version "5.3.0" version "5.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"
semver@^5.5.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab"
send@0.15.4: send@0.15.4:
version "0.15.4" version "0.15.4"
resolved "https://registry.yarnpkg.com/send/-/send-0.15.4.tgz#985faa3e284b0273c793364a35c6737bd93905b9" resolved "https://registry.yarnpkg.com/send/-/send-0.15.4.tgz#985faa3e284b0273c793364a35c6737bd93905b9"
@ -1803,9 +1710,9 @@ setprototypeof@1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04"
shelljs@^0.8.1: shelljs@^0.7.8:
version "0.8.1" version "0.7.8"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.1.tgz#729e038c413a2254c4078b95ed46e0397154a9f1" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.8.tgz#decbcf874b0d1e5fb72e14b164a9683048e9acb3"
dependencies: dependencies:
glob "^7.0.0" glob "^7.0.0"
interpret "^1.0.0" interpret "^1.0.0"
@ -1880,12 +1787,6 @@ string_decoder@~1.0.0:
dependencies: dependencies:
safe-buffer "~5.0.1" safe-buffer "~5.0.1"
string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
dependencies:
safe-buffer "~5.1.0"
stringstream@~0.0.4: stringstream@~0.0.4:
version "0.0.5" version "0.0.5"
resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"
@ -1910,10 +1811,6 @@ strip-json-comments@~2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
success-symbol@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/success-symbol/-/success-symbol-0.1.0.tgz#24022e486f3bf1cdca094283b769c472d3b72897"
superagent@^3.0.0: superagent@^3.0.0:
version "3.5.2" version "3.5.2"
resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.5.2.tgz#3361a3971567504c351063abeaae0faa23dbf3f8" resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.5.2.tgz#3361a3971567504c351063abeaae0faa23dbf3f8"
@ -1963,18 +1860,6 @@ tar-pack@^3.4.0:
tar "^2.2.1" tar "^2.2.1"
uid-number "^0.0.6" uid-number "^0.0.6"
tar-stream@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.0.tgz#a50efaa7b17760b82c27b3cae4a301a8254a5715"
dependencies:
bl "^1.0.0"
buffer-alloc "^1.1.0"
end-of-stream "^1.0.0"
fs-constants "^1.0.0"
readable-stream "^2.0.0"
to-buffer "^1.1.0"
xtend "^4.0.0"
tar@^2.2.1: tar@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1"
@ -1997,10 +1882,6 @@ timed-out@^4.0.0:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
to-buffer@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80"
touch@^3.1.0: touch@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b"
@ -2058,10 +1939,6 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5" version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
type-detect@^4.0.0:
version "4.0.8"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
type-is@~1.6.15: type-is@~1.6.15:
version "1.6.15" version "1.6.15"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410"
@ -2170,7 +2047,7 @@ xdg-basedir@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
xtend@^4.0.0: xtend@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"

View File

View File

@ -1,12 +0,0 @@
# 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

View File

@ -30,7 +30,7 @@ done
# Check servers # Check servers
origins=( origins=(
http://$AIO_PREVIEW_SERVER_HOSTNAME:$AIO_PREVIEW_SERVER_PORT http://$AIO_UPLOAD_HOSTNAME:$AIO_UPLOAD_PORT
http://$AIO_NGINX_HOSTNAME:$AIO_NGINX_PORT_HTTP http://$AIO_NGINX_HOSTNAME:$AIO_NGINX_PORT_HTTP
https://$AIO_NGINX_HOSTNAME:$AIO_NGINX_PORT_HTTPS https://$AIO_NGINX_HOSTNAME:$AIO_NGINX_PORT_HTTPS
) )

2
aio/aio-builds-setup/dockerbuild/scripts-sh/init.sh Normal file → Executable file
View File

@ -14,5 +14,5 @@ service cron start
service dnsmasq start service dnsmasq start
service nginx start service nginx start
service pm2-root start service pm2-root start
aio-preview-server-prod start aio-upload-server-prod start
echo [`date`] - Services started successfully. echo [`date`] - Services started successfully.

View File

@ -1,14 +0,0 @@
#!/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}

View File

@ -1,16 +0,0 @@
#!/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

View File

@ -1,19 +0,0 @@
# 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

View File

@ -0,0 +1,14 @@
#!/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}

View File

@ -0,0 +1,28 @@
#!/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

View File

@ -1,2 +0,0 @@
aio-verify-setup
ls -t /var/log/aio/preview-server-verify* | head -1 | xargs cat

View File

@ -2,7 +2,7 @@
set -eu -o pipefail set -eu -o pipefail
logFile=/var/log/aio/verify-setup.log logFile=/var/log/aio/verify-setup.log
previewServerLogFile=/var/log/aio/preview-server-verify-setup.log uploadServerLogFile=/var/log/aio/upload-server-verify-setup.log
exec 3>&1 exec 3>&1
exec >> $logFile exec >> $logFile
@ -23,22 +23,18 @@ function countdown {
} }
function onExit { function onExit {
echo -e "Stopping Test Server" aio-upload-server-test stop
aio-preview-server-test stop
echo -e "Full logs in '$logFile'.\n" > /dev/fd/3 echo -e "Full logs in '$logFile'.\n" > /dev/fd/3
} }
# Setup EXIT trap # Setup EXIT trap
trap 'onExit' EXIT trap 'onExit' EXIT
# Start an preview-server instance for testing # Start an upload-server instance for testing
echo -e "Starting Test Server" aio-upload-server-test start --log $uploadServerLogFile
aio-preview-server-test start --log $previewServerLogFile
# Give the preview-server some time to start :( # Give the upload-server some time to start :(
countdown "Starting" 5 > /dev/fd/3 countdown "Starting" 5 > /dev/fd/3
# Run the tests # Run the tests
echo Running the tests
source aio-test-env
node $AIO_SCRIPTS_JS_DIR/dist/lib/verify-setup | tee /dev/fd/3 node $AIO_SCRIPTS_JS_DIR/dist/lib/verify-setup | tee /dev/fd/3

View File

@ -10,26 +10,18 @@ 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 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. 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`: - `AIO_BUILDS_DIR`:
The directory (inside the container) where the hosted build artifacts are kept. The directory (inside the container) where the uploaded build artifacts are kept.
- `AIO_DOMAIN_NAME`: - `AIO_DOMAIN_NAME`:
The domain name of the server. The domain name of the server.
- `AIO_GITHUB_ORGANIZATION`: - `AIO_GITHUB_ORGANIZATION`:
The GitHub organization whose teams are whitelisted for accepting build artifacts. The GitHub organization whose teams are whitelisted for accepting uploads.
See also `AIO_GITHUB_TEAM_SLUGS`. See also `AIO_GITHUB_TEAM_SLUGS`.
- `AIO_GITHUB_REPO`:
The Github repository for which PRs will be hosted.
- `AIO_GITHUB_TEAM_SLUGS`: - `AIO_GITHUB_TEAM_SLUGS`:
A comma-separated list of teams, whose authors are allowed to preview PRs. A comma-separated list of teams, whose authors are allowed to upload PRs.
See also `AIO_GITHUB_ORGANIZATION`. See also `AIO_GITHUB_ORGANIZATION`.
- `AIO_NGINX_HOSTNAME`: - `AIO_NGINX_HOSTNAME`:
@ -44,24 +36,22 @@ 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 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)). corresponding port on the host VM (as described [here](vm-setup--start-docker-container.md)).
- `AIO_SIGNIFICANT_FILES_PATTERN`: - `AIO_REPO_SLUG`:
The RegExp that determines whether a changed file indicates that a new preview needs to The repository slug (in the form `<user>/<repo>`) for which PRs will be uploaded.
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`: - `AIO_TRUSTED_PR_LABEL`:
The PR whose presence indicates the PR has been manually verified and is allowed to have its 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 build artifacts publicly served. This is useful for enabling previews for any PR (not only those
from trusted authors). from trusted authors).
- `AIO_PREVIEW_SERVER_HOSTNAME`: - `AIO_UPLOAD_HOSTNAME`:
The internal hostname for accessing the Node.js preview-server. This is used by nginx for The internal hostname for accessing the Node.js upload-server. This is used by nginx for
delegating web-hook requests and also for performing a periodic health-check. delegating upload requests and also for performing a periodic health-check.
- `AIO_ARTIFACT_MAX_SIZE`: - `AIO_UPLOAD_MAX_SIZE`:
The maximum allowed size for the gzip archive containing the build artifacts. The maximum allowed size for the uploaded gzip archive containing the build artifacts. Files
Files larger than this will be rejected. larger than this will be rejected.
- `AIO_PREVIEW_SERVER_PORT`: - `AIO_UPLOAD_PORT`:
The port number on which the Node.js preview-server listens for HTTP connections. This is used by The port number on which the Node.js upload-server listens for HTTP connections. This is used by
nginx for delegating web-hook requests and also for performing a periodic health-check. nginx for delegating upload requests and also for performing a periodic health-check.

View File

@ -5,44 +5,8 @@ TODO (gkalpak): Add docs. Mention:
- `aio-health-check` - `aio-health-check`
- `aio-verify-setup` - `aio-verify-setup`
- Test nginx accessible at: - Test nginx accessible at:
- `http://$TEST_AIO_NGINX_HOSTNAME:$TEST_AIO_NGINX_PORT_HTTP` - `http://$TEST_AIO_NGINX_HOTNAME:$TEST_AIO_NGINX_PORT_HTTP`
- `https://$TEST_AIO_NGINX_HOSTNAME:$TEST_AIO_NGINX_PORT_HTTPS` - `https://$TEST_AIO_NGINX_HOTNAME:$TEST_AIO_NGINX_PORT_HTTPS`
- Test preview-server accessible at: - Test upload-server accessible at:
- `http://$TEST_AIO_PREVIEW_SERVER_HOSTNAME:$TEST_AIO_PREVIEW_SERVER_PORT` - `http://$TEST_AIO_UPLOAD_HOTNAME:$TEST_AIO_UPLOAD_PORT`
- Local DNS (via dnsmasq) maps the above hostnames to 127.0.0.1 - 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
```

View File

@ -2,7 +2,10 @@
TODO (gkalpak): Add docs. Mention: TODO (gkalpak): Add docs. Mention:
- Travis' JWT addon (+ limitations).
Relevant files: `.travis.yml`, `scripts/ci/env.sh`
- Testing on CI. - Testing on CI.
Relevant files: `scripts/ci/test-aio.sh`, `aio/aio-builds-setup/scripts/test.sh` Relevant files: `scripts/ci/test-aio.sh`, `aio/aio-builds-setup/scripts/test.sh`
- Deploying from CI. - Deploying from CI.
Relevant files: `scripts/ci/deploy.sh`, `aio/scripts/deploy-to-firebase.sh` Relevant files: `scripts/ci/deploy.sh`, `aio/scripts/deploy-preview.sh`,
`aio/scripts/deploy-to-firebase.sh`

View File

@ -2,10 +2,9 @@
## Objective ## Objective
Whenever a PR job is run on the CI infrastructure (e.g. CircleCI), we want to build `angular.io` Whenever a PR job is run on Travis, we want to build `angular.io` and upload the build artifacts to
and host the build artifacts on a publicly accessible server so that collaborators (developers, a publicly accessible server so that collaborators (developers, designers, authors, etc) can preview
designers, authors, etc) can preview the changes without having to checkout and build the app the changes without having to checkout and build the app locally.
locally.
## Source code ## Source code
@ -33,38 +32,48 @@ This section gives a brief summary of the several operations performed on CI and
container: container:
### On CI (CircleCI) ### On CI (Travis)
- Build job completes successfully. - Build job completes successfully.
- The CI script checks whether the build job was initiated by a PR against the angular/angular - The CI script checks whether the build job was initiated by a PR against the angular/angular
master branch. master branch.
- The CI script checks whether the PR has touched any files that might affect the angular.io app - 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). (currently the `aio/` or `packages/` directories, ignoring spec files).
- The CI script gzips and stores the build artifacts in the CI infrastructure. - Optionally, the CI script can check whether the PR can be automatically verified (i.e. if the
- When the build completes CircleCI triggers a webhook on the preview-server. 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.
More info on how to set things up on CI can be found [here](misc--integrate-with-ci.md). More info on how to set things up on CI can be found [here](misc--integrate-with-ci.md).
### Hosting build artifacts ### Uploading build artifacts
- nginx receives the upload request.
- nginx receives the webhook trigger and passes it through to the preview server. - nginx checks that the uploaded gzip archive does not exceed the specified max file size, stores it
- The preview-server makes a request to CircleCI for the URL of the AIO build artifacts. in a temporary location and passes the filepath to the Node.js upload-server.
- The preview-server makes a request to this URL to receive the artifact - failing if the size - The upload-server runs several checks to determine whether the request should be accepted and
exceeds the specified max file size - and stores it in a temporary location.
- The preview-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 whether it should be publicly accessible or stored for later verification (more details can be
found [here](overview--security-model.md)). found [here](overview--security-model.md)).
- The preview-server changes the "visibility" of the associated PR, if necessary. For example, if - The upload-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 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. automatically verified, all previous builds are made public as well.
If the PR transitions from "non-public" to "public", the preview-server posts a comment on the If the PR transitions from "non-public" to "public", the upload-server posts a comment on the
corresponding PR on GitHub mentioning the SHAs and the links where the previews can be found. corresponding PR on GitHub mentioning the SHAs and the links where the previews can be found.
- The preview-server verifies that it is not trying to overwrite an existing build. - The upload-server verifies that the uploaded file 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 - 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>/` few characters of the SHA: `<PR>/<SHA>/`
(Non-publicly accessible PRs will be stored in a different location, but again derived from the PR (Non-publicly accessible PRs will be stored in a different location, but again derived from the PR
number and SHA.) number and SHA.)
- If the PR is publicly accessible, the preview-server posts a comment on the corresponding PR on - If the PR is publicly accessible, the upload-server posts a comment on the corresponding PR on
GitHub mentioning the SHA and the link where the preview can be found. 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 More info on the possible HTTP status codes and their meaning can be found
@ -73,24 +82,24 @@ More info on the possible HTTP status codes and their meaning can be found
### Updating PR visibility ### Updating PR visibility
- nginx receives a natification that a PR has been updated and passes it through to the - nginx receives a natification that a PR has been updated and passes it through to the
preview-server. This could, for example, be sent by a GitHub webhook every time a PR's labels upload-server. This could, for example, be sent by a GitHub webhook every time a PR's labels
change. change.
E.g.: `ngbuilds.io/pr-updated` (payload: `{"number":<PR>,"action":"labeled"}`) 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 - The request contains the PR number (as `number`) and optionally the action that triggered the
request (as `action`) in the payload. request (as `action`) in the payload.
- The preview-server verifies the payload and determines whether the `action` (if specified) could - The upload-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 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. specify an action that can affect visibility are further processed.
(Currently, the only actions that are considered capable of affecting visibility are `labeled` and (Currently, the only actions that are considered capable of affecting visibility are `labeled` and
`unlabeled`.) `unlabeled`.)
- The preview-server re-checks and if necessary updates the PR's visibility. - The upload-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 More info on the possible HTTP status codes and their meaning can be found
[here](overview--http-status-codes.md). [here](overview--http-status-codes.md).
### Serving build artifacts ### Serving build artifacts
- nginx receives a request for a hosted preview resource on a subdomain corresponding to the PR and SHA. - nginx receives a request for an uploaded resource on a subdomain corresponding to the PR and SHA.
E.g.: `pr<PR>-<SHA>.ngbuilds.io/path/to/resource` E.g.: `pr<PR>-<SHA>.ngbuilds.io/path/to/resource`
- nginx maps the subdomain to the correct sub-directory and serves the resource. - nginx maps the subdomain to the correct sub-directory and serves the resource.
E.g.: `/<PR>/<SHA>/path/to/resource` E.g.: `/<PR>/<SHA>/path/to/resource`
@ -108,4 +117,4 @@ that do not correspond with an open PR.
### Health-check ### Health-check
The docker service runs a periodic health-check that verifies the running conditions of the 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 container. This includes verifying the status of specific system services, the responsiveness of
nginx and the preview-server and internet connectivity. nginx and the upload-server and internet connectivity.

View File

@ -1,8 +1,8 @@
# Overview - HTTP Status Codes # Overview - HTTP Status Codes
This is a list of all the possible HTTP status codes returned by the nginx and preview servers, along This is a list of all the possible HTTP status codes returned by the nginx anf upload servers, along
with a brief explanation of what they mean: with a bried explanation of what they mean:
## `http://*.ngbuilds.io/*` ## `http://*.ngbuilds.io/*`
@ -25,7 +25,7 @@ with a brief explanation of what they mean:
File not found. File not found.
## `https://ngbuilds.io/circle-build` ## `https://ngbuilds.io/create-build/<pr>/<sha>`
- **201 (Created)**: - **201 (Created)**:
Build deployed successfully and is publicly available. Build deployed successfully and is publicly available.
@ -33,14 +33,14 @@ with a brief explanation of what they mean:
- **202 (Accepted)**: - **202 (Accepted)**:
Build not automatically verifiable. Stored for later deployment (after re-verification). Build not automatically verifiable. Stored for later deployment (after re-verification).
- **204 (No Content)**:
Build was not successful, so no further action is being taken.
- **400 (Bad Request)**: - **400 (Bad Request)**:
Invalid payload. No payload.
- **401 (Unauthorized)**:
No `AUTHORIZATION` header.
- **403 (Forbidden)**: - **403 (Forbidden)**:
Unable to talk to 3rd-party APIs. Unable to verify build (e.g. invalid JWT token, or unable to talk to 3rd-party APIs, etc).
- **405 (Method Not Allowed)**: - **405 (Method Not Allowed)**:
Request method other than POST. Request method other than POST.
@ -49,6 +49,9 @@ with a brief explanation of what they mean:
Request to overwrite existing (public or non-public) directory (e.g. deploy existing build or 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). 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` ## `https://ngbuilds.io/health-check`

View File

@ -21,7 +21,7 @@ available:
from a git repository. See [here](vm-setup--update-docker-container.md) for more info. from a git repository. See [here](vm-setup--update-docker-container.md) for more info.
## Production Commands ## Commands
The following commands are available globally from inside the docker container. They are either used 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 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 purposes. Each command is backed by a corresponding script inside
@ -40,27 +40,14 @@ purposes. Each command is backed by a corresponding script inside
Initializes the container (mainly by starting the necessary services). Initializes the container (mainly by starting the necessary services).
_It is run (by default) when starting the container._ _It is run (by default) when starting the container._
- `aio-preview-server-prod`: - `aio-upload-server-prod`:
Spins up a Node.js preview-server instance. Spins up a Node.js upload-server instance.
_It is used in `aio-init` (see above) during initialization._ _It is used in `aio-init` (see above) during initialization._
- `aio-upload-server-test`:
## Developer Commands Spins up a Node.js upload-server instance for tests.
- `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._ _It is used in `aio-verify-setup` (see below) for running tests._
- `aio-verify-setup`: - `aio-verify-setup`:
Runs a suite of e2e-like tests, mainly verifying the correct (inter)operation of nginx and the Runs a suite of e2e-like tests, mainly verifying the correct (inter)operation of nginx and the
Node.js preview-server. Node.js upload-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.

View File

@ -1,27 +1,27 @@
# Overview - Security model # Overview - Security model
Whenever a PR job is run on CircleCI, we want to build `angular.io` and host the build artifacts on 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 a publicly accessible server so that collaborators (developers, designers, authors, etc) can preview
the changes without having to checkout and build the app locally. the changes without having to checkout and build the app locally.
This document discusses the security considerations associated with moving build artifacts as This document discusses the security considerations associated with uploading build artifacts as
part of the CI process and serving them publicly. part of the CI setup and serving them publicly.
## Security objectives ## Security objectives
- **Prevent hosting arbitrary content to on servers.** - **Prevent uploading arbitrary content to our servers.**
Since there is no restriction on who can submit a PR, we cannot allow arbitrary untrusted PRs' Since there is no restriction on who can submit a PR, we cannot allow any PR's build artifacts to
build artifacts to be hosted. be uploaded.
- **Prevent overwriting other people's hosted build artifacts.** - **Prevent overwriting other peoples uploaded content.**
There needs to be a mechanism in place to ensure that the hosted content does indeed correspond There needs to be a mechanism in place to ensure that the uploaded content does indeed correspond
to the PR indicated by its URL. to the PR indicated by its URL.
- **Prevent arbitrary access on the server.** - **Prevent arbitrary access on the server.**
Since the PR author has full access over the build artifacts that would be hosted, we must Since the PR author has full access over the build artifacts that would be uploaded, we must
ensure that the build artifacts will not have arbitrary access to the server or expose sensitive ensure that the uploaded files will not enable arbitrary access to the server or expose sensitive
info. info.
@ -30,7 +30,7 @@ part of the CI process and serving them publicly.
- Because the PR author can change the scripts run on CI, any security mechanisms must be immune to - Because the PR author can change the scripts run on CI, any security mechanisms must be immune to
such changes. such changes.
- For security reasons, encrypted CircleCI variables are not available to PRs, so we can't rely on - For security reasons, encrypted Travis variables are not available to PRs, so we can't rely on
them to implement security. them to implement security.
@ -40,42 +40,35 @@ part of the CI process and serving them publicly.
### In a nutshell ### In a nutshell
The implemented approach can be broken up to the following sub-tasks: The implemented approach can be broken up to the following sub-tasks:
0. Receive notification from CircleCI of a completed build. 1. Verify which PR the uploaded artifacts correspond to.
1. Verify that the build is valid and download the artifact.
2. Fetch the PR's metadata, including author and labels. 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). 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. 4. If necessary, update the corresponding PR's verification status.
5. Deploy the artifacts to the corresponding PR's directory. 5. Deploy the artifacts to the corresponding PR's directory.
6. Prevent overwriting previously deployed artifacts (which ensures that the guarantees established 6. Prevent overwriting previously deployed artifacts (which ensures that the guarantees established
during deployment will remain valid until the artifacts are removed). during deployment will remain valid until the artifacts are removed).
7. Prevent hosted preview files from accessing anything outside their directory. 7. Prevent uploaded files from accessing anything outside their directory.
### Implementation details ### Implementation details
This section describes how each of the aforementioned sub-tasks is accomplished: This section describes how each of the aforementioned sub-tasks is accomplished:
0. **Receive notification from CircleCI of a completed build** 1. **Verify which PR the uploaded artifacts correspond to.**
CircleCI is configured to trigger a webhook on our preview-server whenever a build completes. We are taking advantage of Travis' [JWT addon](https://docs.travis-ci.com/user/jwt). By sharing
The payload contains the number of the build that completed. 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).
1. **Verify that the build is valid and download the artifact.** _Note:_
_There are currently certain limitation in the implementation of the JWT addon._
We cannot trust that the data in the webhook trigger is authentic, so we only extract the build _See the next section for more details._
number and then run a direct query against the CircleCI API to get hold of the real data for
the given build number.
If the build was not successful then we ignore this trigger. Otherwise we check that the
associated github organisation and repository are what we expect (e.g. angular/angular).
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.
2. **Fetch the PR's metadata, including author and labels**. 2. **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 - Once we have securely associated the uploaded artifacts to a PR, we retrieve the PR's metadata -
including the author's username and the labels - using the including the author's username and the labels - using the
[GitHub API](https://developer.github.com/v3/). [GitHub API](https://developer.github.com/v3/).
To avoid rate-limit restrictions, we use a Personal Access Token (issued by To avoid rate-limit restrictions, we use a Personal Access Token (issued by
@ -98,42 +91,48 @@ This section describes how each of the aforementioned sub-tasks is accomplished:
Once we have determined whether the PR is considered "trusted", we update its "visibility" (i.e. 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 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 a PR was initially considered "not trusted" but the check triggered by a new build determined
otherwise, the PR (and all the previously hosted previews) are made public. It works the same otherwise, the PR (and all the previously uploaded previews) are made public. It works the same
way if a PR has gone from "trusted" to "not trusted". way if a PR has gone from "trusted" to "not trusted".
5. **Deploy the artifacts to the corresponding PR's directory.** 5. **Deploy the artifacts to the corresponding PR's directory.**
With the preceding steps, we have verified that the build artifacts are valid. With the preceding steps, we have verified that the uploaded artifacts have been uploaded by
Additionally, we have determined whether the PR can be trusted to have its previews 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 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. 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 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 to the build artifacts. "project" the trust we have in a team's members through the PR and Travis to the build artifacts.
6. **Prevent overwriting previously deployed artifacts**. 6. **Prevent overwriting previously deployed artifacts**.
In order to enforce this restriction (and ensure that the deployed artifacts' validity is In order to enforce this restriction (and ensure that the deployed artifacts' validity is
preserved throughout their "lifetime"), the server that handles the artifacts (currently a Node.js preserved throughout their "lifetime"), the server that handles the upload (currently a Node.js
Express server) rejects builds that have already been handled. Express server) rejects uploads that target an existing directory.
_Note: A PR can contain multiple builds; one for each SHA that was built on CircleCI._ _Note: A PR can contain multiple uploads; one for each SHA that was built on Travis._
7. **Prevent hosted preview files from accessing anything outside their directory.** 7. **Prevent uploaded files from accessing anything outside their directory.**
Nginx (which is used to serve the hosted preview) has been configured to not follow symlinks Nginx (which is used to serve the uploaded artifacts) has been configured to not follow symlinks
outside of the directory where the preview files are stored. outside of the directory where the build artifacts are stored.
## Assumptions / Things to keep in mind ## Assumptions / Things to keep in mind
- Other than the initial webhook trigger, which provides a build number, all requests for data come - Each trusted PR author has full control over the content that is uploaded for their PRs. Part of
from the preview-server making requests to well defined API endpoints (e.g. CircleCI and Github). the security model relies on the trustworthiness of these authors.
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).
- Each trusted PR author has full control over the content that is hosted as a preview for their PRs. - Adding the specified label on a PR and marking it as trusted, gives the author full control over
Part of the security model relies on the trustworthiness of these authors. the content that is uploaded for the specific PR (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.
- Adding the specified label on a PR to mark it as trusted, gives the author full control over - If anyone gets access to the `PREVIEW_DEPLOYMENT_TOKEN` (a.k.a. `NGBUILDS_IO_KEY` on
the content that is hosted for the specific PR preview (e.g. by pushing more commits to it). angular/angular) variable generated for each Travis job, they will be able to impersonate the
The user adding the label is responsible for ensuring that this control is not abused and that corresponding PR's author on the preview server for as long as the token is valid (currently 90
the PR is either closed (one way of another) or the access is revoked. 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`).

View File

@ -1,12 +1,6 @@
# VM setup - Create docker image # 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 ## Checkout repository
- `git clone <repo-url>` - `git clone <repo-url>`
@ -27,7 +21,7 @@ The following commands would create a docker image from GitHub repo `foo/bar` to
- `git clone https://github.com/foo/bar.git foobar` - `git clone https://github.com/foo/bar.git foobar`
- Run: - Run:
``` ```
./foobar/aio-builds-setup/scripts/create-image.sh foobar-builds \ ./foobar/aio-builds-setup/scripts/build.sh foobar-builds \
--build-arg AIO_REPO_SLUG=foo/bar \ --build-arg AIO_REPO_SLUG=foo/bar \
--build-arg AIO_DOMAIN_NAME=foobar-builds.io \ --build-arg AIO_DOMAIN_NAME=foobar-builds.io \
--build-arg AIO_GITHUB_ORGANIZATION=foo \ --build-arg AIO_GITHUB_ORGANIZATION=foo \

View File

@ -12,8 +12,8 @@ More info on how to create `secrets` directory and files can be found
## Create directory for build artifacts ## Create directory for build artifacts
The build artifacts should be kept on a directory outside the docker container, so it is 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 builds. For portability across VMs a easier to replace the container without losing the uploaded builds. For portability across VMs a
persistent disk can be used (as described [here](vm-setup--attach-persistent-disk.md)). 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`. **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) ## 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 The host VM can attach a directory containing the SSL certificate and key to be used by the nginx
server for serving the hosted previews. More info on how to attach the directory when server for serving the uploaded build artifacts. More info on how to attach the directory when
starting the container can be found [here](vm-setup--start-docker-container.md). 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 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` 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.) command. (See [here](overview--scripts-and-commands.md) for more info.)
- `preview-server-{prod,test,verify-setup}-*.log`: - `upload-server-{prod,test,verify-setup}-*.log`:
The logs produced by the Node.js preview-server while serving either: The logs produced by the Node.js upload-server while serving either:
- `-prod`: "Production" files (g.g during normal operation). - `-prod`: "Production" files (g.g during normal operation).
- `-test`: "Test" files (e.g. when a test instance is started with the `aio-preview-server-test` - `-test`: "Test" files (e.g. when a test instance is started with the `aio-upload-server-test`
command). command).
- `-verify-setup`: "Test" files, but while running `aio-verify-setup`. - `-verify-setup`: "Test" files, but while running `aio-verify-setup`.
(See [here](overview--scripts-and-commands.md) for more info the commands mentioned above.) (See [here](overview--scripts-and-commands.md) for more info the commands mentioned above.)
- `verify-setup.log`: - `verify-setup.log`:
The output of the `aio-verify-setup` command (e.g. Jasmine output), except for preview-server The output of the `aio-verify-setup` command (e.g. Jasmine output), except for upload-server
output which is logged to `preview-server-verify-setup-*.log` (see above). output which is logged to `upload-server-verify-setup-*.log` (see above).

View File

@ -12,10 +12,14 @@ Necessary secrets:
- Retrieving members of the trusted GitHub teams. - Retrieving members of the trusted GitHub teams.
- Posting comments with preview links on PRs. - Posting comments with preview links on PRs.
2. `CIRCLE_CI_TOKEN` 2. `PREVIEW_DEPLOYMENT_TOKEN`
- Used for: - Used for:
- Retrieving build information. - Decoding the JWT tokens received with `/create-build` requests.
- Downloading build artifacts.
**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.
## Create secrets ## Create secrets
@ -24,15 +28,25 @@ Necessary secrets:
- Visit https://github.com/settings/tokens. - Visit https://github.com/settings/tokens.
- Generate new token with the `public_repo` scope. - Generate new token with the `public_repo` scope.
2. `CIRCLE_CI_TOKEN` 2. `PREVIEW_DEPLOYMENT_TOKEN`
- Visit https://circleci.com/gh/angular/angular/edit#api - Just generate a hard-to-guess character sequence.
- Create an API token with `Build Artifacts` scope - 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.
## Save secrets on the VM ## Save secrets on the VM
- `sudo mkdir /aio-secrets` - `sudo mkdir /aio-secrets`
- `sudo touch /aio-secrets/GITHUB_TOKEN` - `sudo touch /aio-secrets/GITHUB_TOKEN`
- Insert `<github-token>` into `/aio-secrets/GITHUB_TOKEN`. - Insert `<github-token>` into `/aio-secrets/GITHUB_TOKEN`.
- `sudo touch /aio-secrets/CIRCLE_CI_TOKEN` - `sudo touch /aio-secrets/PREVIEW_DEPLOYMENT_TOKEN`
- Insert `<access-token>` into `/aio-secrets/CIRCLE_CI_TOKEN`. - Insert `<access-token>` into `/aio-secrets/PREVIEW_DEPLOYMENT_TOKEN`.
- `sudo chmod 400 /aio-secrets/*` - `sudo chmod 400 /aio-secrets/*`

View File

@ -13,15 +13,14 @@ sudo docker run \
--publish 80:80 \ --publish 80:80 \
--publish 443:443 \ --publish 443:443 \
--restart unless-stopped \ --restart unless-stopped \
[--volume <host-cert-dir>:/etc/ssl/localcerts:ro] \
--volume <host-secrets-dir>:/aio-secrets:ro \ --volume <host-secrets-dir>:/aio-secrets:ro \
--volume <host-builds-dir>:/var/www/aio-builds \ --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-logs-dir>:/var/log/aio] \
[--volume <host-dockerbuild-dir>:/dockerbuild] \
<name>[:<tag>] <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/). can be found [here](https://docs.docker.com/engine/reference/run/).
``` ```
@ -31,7 +30,7 @@ sudo docker run \
--detach \ --detach \
# Use the local DNS server. # Use the local DNS server.
# (This is necessary for mapping internal URLs, e.g. for the Node.js preview-server.) # (This is necessary for mapping internal URLs, e.g. for the Node.js upload-server.)
--dns 127.0.0.1 \ --dns 127.0.0.1 \
# USe `<instance-name>` as an alias for the container. # USe `<instance-name>` as an alias for the container.
@ -46,32 +45,28 @@ sudo docker run \
# (This ensures that the container will be automatically started on boot.) # (This ensures that the container will be automatically started on boot.)
--restart unless-stopped \ --restart unless-stopped \
# 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 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. # The directory the contains the SSL certificates.
# (See [here](vm-setup--create-host-dirs-and-files.md) for more info.) # (See [here](vm-setup--create-host-dirs-and-files.md) for more info.)
# If not provided, the container will use self-signed certificates. # If not provided, the container will use self-signed certificates.
[--volume <host-cert-dir>:/etc/ssl/localcerts:ro] \ [--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.
# (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 where the logs are being kept. # The directory where the logs are being kept.
# (See [here](vm-setup--create-host-dirs-and-files.md) for more info.) # (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 # If not provided, the logs will be kept inside the container, which means they will be lost
# whenever a new container is created. # whenever a new container is created.
[--volume <host-logs-dir>:/var/log/aio] \ [--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`). # 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 image.) # (See [here](vm-setup--create-docker-image.md) for instructions on how to create the iamge.)
<name>[:<tag>] <name>[:<tag>]
``` ```
@ -79,8 +74,7 @@ sudo docker run \
## Example ## Example
The following command would start a docker container based on the previously created `foobar-builds` 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 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 \ sudo docker run \
@ -90,10 +84,9 @@ sudo docker run \
--publish 80:80 \ --publish 80:80 \
--publish 443:443 \ --publish 443:443 \
--restart unless-stopped \ --restart unless-stopped \
--volume /etc/ssl/localcerts:/etc/ssl/localcerts:ro \
--volume /foobar-secrets:/aio-secrets:ro \ --volume /foobar-secrets:/aio-secrets:ro \
--volume /mnt/disks/foobar-builds:/var/www/aio-builds \ --volume /mnt/disks/foobar-builds:/var/www/aio-builds \
--volume /etc/ssl/localcerts:/etc/ssl/localcerts:ro \
--volume /foobar-logs:/var/log/aio \ --volume /foobar-logs:/var/log/aio \
--volume ~/angular/aio/aio-builds-setup/dockerbuild:/dockerbuild \
foobar-builds foobar-builds
``` ```

View File

@ -3,7 +3,7 @@
set -eux -o pipefail set -eux -o pipefail
exec 3>&1 exec 3>&1
echo -e "\n\n[`date`] - Updating the preview server..." echo "\n\n[`date`] - Updating the preview server..."
# Input # Input
readonly HOST_REPO_DIR=$1 readonly HOST_REPO_DIR=$1

View File

@ -60,8 +60,6 @@ dist/
!rollup-config.js !rollup-config.js
aot-compiler/**/*.d.ts aot-compiler/**/*.d.ts
aot-compiler/**/*.factory.d.ts aot-compiler/**/*.factory.d.ts
upgrade-phonecat-2-hybrid/aot/**/*
!upgrade-phonecat-2-hybrid/aot/index.html
# i18n # i18n
!i18n/src/systemjs-text-plugin.js !i18n/src/systemjs-text-plugin.js

View File

@ -40,7 +40,5 @@ export class HighlightDirective {
// #docregion color-2 // #docregion color-2
@Input() appHighlight: string; @Input() appHighlight: string;
// #enddocregion color-2 // #enddocregion color-2
// #docregion
} }
// #enddocregion

View File

@ -5,7 +5,7 @@
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http'; import { HttpModule } from '@angular/http';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
// #docregion directive-import // #docregion directive-import
@ -24,7 +24,7 @@ import { ItemDirective } from './item.directive';
imports: [ imports: [
BrowserModule, BrowserModule,
FormsModule, FormsModule,
HttpClientModule HttpModule
], ],
providers: [], providers: [],
bootstrap: [AppComponent] bootstrap: [AppComponent]

View File

@ -0,0 +1,27 @@
# MasterProject
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.0.0-rc.0.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive/pipe/service/class/module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
Before running the tests make sure you are serving the app via `ng serve`.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View File

@ -5,18 +5,18 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
selector: 'app-voter', selector: 'app-voter',
template: ` template: `
<h4>{{name}}</h4> <h4>{{name}}</h4>
<button (click)="vote(true)" [disabled]="didVote">Agree</button> <button (click)="vote(true)" [disabled]="voted">Agree</button>
<button (click)="vote(false)" [disabled]="didVote">Disagree</button> <button (click)="vote(false)" [disabled]="voted">Disagree</button>
` `
}) })
export class VoterComponent { export class VoterComponent {
@Input() name: string; @Input() name: string;
@Output() voted = new EventEmitter<boolean>(); @Output() onVoted = new EventEmitter<boolean>();
didVote = false; voted = false;
vote(agreed: boolean) { vote(agreed: boolean) {
this.voted.emit(agreed); this.onVoted.emit(agreed);
this.didVote = true; this.voted = true;
} }
} }
// #enddocregion // #enddocregion

View File

@ -8,7 +8,7 @@ import { Component } from '@angular/core';
<h3>Agree: {{agreed}}, Disagree: {{disagreed}}</h3> <h3>Agree: {{agreed}}, Disagree: {{disagreed}}</h3>
<app-voter *ngFor="let voter of voters" <app-voter *ngFor="let voter of voters"
[name]="voter" [name]="voter"
(voted)="onVoted($event)"> (onVoted)="onVoted($event)">
</app-voter> </app-voter>
` `
}) })

View File

@ -1,7 +1,7 @@
// #docregion // #docregion
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http'; import { HttpModule } from '@angular/http';
// import { AppRoutingModule } from './app-routing.module'; // import { AppRoutingModule } from './app-routing.module';
import { LocationStrategy, import { LocationStrategy,
@ -54,7 +54,7 @@ const c_components = [
imports: [ imports: [
BrowserModule, BrowserModule,
FormsModule, FormsModule,
HttpClientModule, HttpModule,
InMemoryWebApiModule.forRoot(HeroData) InMemoryWebApiModule.forRoot(HeroData)
// AppRoutingModule TODO: add routes // AppRoutingModule TODO: add routes
], ],

View File

@ -1,38 +1,26 @@
import { Injector } from '@angular/core'; import { ReflectiveInjector } from '@angular/core';
import { Car, Engine, Tires } from './car'; import { Car, Engine, Tires } from './car';
import { Logger } from '../logger.service'; import { Logger } from '../logger.service';
// #docregion injector // #docregion injector
export function useInjector() { export function useInjector() {
let injector: Injector; let injector: ReflectiveInjector;
// #enddocregion injector // #enddocregion injector
/* /*
// #docregion injector-no-new // #docregion injector-no-new
// Cannot instantiate an Injector like this! // Cannot instantiate an ReflectiveInjector like this!
let injector = new Injector([ let injector = new ReflectiveInjector([Car, Engine, Tires]);
{ provide: Car, deps: [Engine, Tires] },
{ provide: Engine, deps: [] },
{ provide: Tires, deps: [] }
]);
// #enddocregion injector-no-new // #enddocregion injector-no-new
*/ */
// #docregion injector, injector-create-and-call // #docregion injector, injector-create-and-call
injector = Injector.create({ injector = ReflectiveInjector.resolveAndCreate([Car, Engine, Tires]);
providers: [
{ provide: Car, deps: [Engine, Tires] },
{ provide: Engine, deps: [] },
{ provide: Tires, deps: [] }
]
});
// #docregion injector-call // #docregion injector-call
let car = injector.get(Car); let car = injector.get(Car);
// #enddocregion injector-call, injector-create-and-call // #enddocregion injector-call, injector-create-and-call
car.description = 'Injector'; car.description = 'Injector';
injector = Injector.create({ injector = ReflectiveInjector.resolveAndCreate([Logger]);
providers: [{ provide: Logger, deps: [] }]
});
let logger = injector.get(Logger); let logger = injector.get(Logger);
logger.log('Injector car.drive() said: ' + car.drive()); logger.log('Injector car.drive() said: ' + car.drive());
return car; return car;

View File

@ -1,69 +0,0 @@
'use strict'; // necessary for es6 output in node
import { browser, by, element } from 'protractor';
/* tslint:disable:quotemark */
describe('Elements', () => {
const messageInput = element(by.css('input'));
const popupButtons = element.all(by.css('button'));
beforeEach(() => browser.get(''));
describe('popup component', () => {
const popupComponentButton = popupButtons.get(0);
const popupComponent = element(by.css('popup-component'));
const closeButton = popupComponent.element(by.css('button'));
it('should be displayed on button click', () => {
expect(popupComponent.isPresent()).toBe(false);
popupComponentButton.click();
expect(popupComponent.isPresent()).toBe(true);
});
it('should display the specified message', () => {
messageInput.clear();
messageInput.sendKeys('Angular rocks!');
popupComponentButton.click();
expect(popupComponent.getText()).toContain('Popup: Angular rocks!');
});
it('should be closed on "close" button click', () => {
popupComponentButton.click();
expect(popupComponent.isPresent()).toBe(true);
closeButton.click();
expect(popupComponent.isPresent()).toBe(false);
});
});
describe('popup element', () => {
const popupElementButton = popupButtons.get(1);
const popupElement = element(by.css('popup-element'));
const closeButton = popupElement.element(by.css('button'));
it('should be displayed on button click', () => {
expect(popupElement.isPresent()).toBe(false);
popupElementButton.click();
expect(popupElement.isPresent()).toBe(true);
});
it('should display the specified message', () => {
messageInput.clear();
messageInput.sendKeys('Angular rocks!');
popupElementButton.click();
expect(popupElement.getText()).toContain('Popup: Angular rocks!');
});
it('should be closed on "close" button click', () => {
popupElementButton.click();
expect(popupElement.isPresent()).toBe(true);
closeButton.click();
expect(popupElement.isPresent()).toBe(false);
});
});
});

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