Compare commits
1370 Commits
4.0.0-rc.3
...
4.3.2
Author | SHA1 | Date | |
---|---|---|---|
8de2ace80a | |||
c977994864 | |||
12b8e1af55 | |||
9a188485f5 | |||
45a10419bc | |||
2245748c14 | |||
bcea196530 | |||
b9e32c833a | |||
be49e0ee93 | |||
bf95655a1a | |||
6bf5b84fa4 | |||
4836565ca7 | |||
750e4e8156 | |||
a0846194b7 | |||
bcf6b90c95 | |||
3ca2a0aa37 | |||
b4be96c65d | |||
434ff5fecb | |||
a1bb9c2d42 | |||
7e626bef0a | |||
a1e83a8ed2 | |||
cbeb197aa5 | |||
0330fa6b82 | |||
97135e8fd5 | |||
35bd07fc7b | |||
a8ac77b645 | |||
9ecd377a51 | |||
76171bd8b4 | |||
1f106d75bc | |||
a4fae8c405 | |||
33c07b3394 | |||
c9d06e676f | |||
c7c65d9fda | |||
257a9e3e6f | |||
c7c0a1688e | |||
7e95e2b0ba | |||
ddc286f4b5 | |||
3d17a3672e | |||
61d253f5fd | |||
54be25a7a1 | |||
b1757037fb | |||
f0476fcff0 | |||
a5c4bb5b96 | |||
4c1f32b0db | |||
383d8969ab | |||
333ffd8d32 | |||
d4679a0bc2 | |||
4ce29f3a5b | |||
17b7bc3e06 | |||
f19bd5f4f3 | |||
d503d25f29 | |||
5d275e994a | |||
d8c8b13bb8 | |||
4671168635 | |||
1ac78bfd5d | |||
4340beacea | |||
ec89f378fc | |||
4dd6863bc2 | |||
37c626e673 | |||
f0a110928b | |||
c39e7d1eb2 | |||
799bffb431 | |||
fda607cc2f | |||
cc3aa68123 | |||
306621d2d6 | |||
d204f7aa2a | |||
a94f5e8cbb | |||
1390afef23 | |||
b0346a6e45 | |||
e5da059994 | |||
ac92c3bb26 | |||
87157d7089 | |||
611dd12f0f | |||
969ce9dc2b | |||
34834a9e79 | |||
6e2ddccc2c | |||
55742e4737 | |||
0091b1e8db | |||
a0b06befb6 | |||
4fbb5b29ea | |||
e0fa727594 | |||
3ecc5e5398 | |||
f7686d4124 | |||
01a2688848 | |||
8e56c3cb30 | |||
7955cacec4 | |||
dd04f09483 | |||
3d85f72652 | |||
9f28e838d3 | |||
ddb766e456 | |||
72143e80da | |||
bc1ea8c54b | |||
45ffe54ae4 | |||
1bf7ba87a0 | |||
db96c963a8 | |||
18559897a0 | |||
ce0f4f0d7c | |||
4f1e4ffa4e | |||
f0beb4d750 | |||
bc3b2ac251 | |||
ffda3e41e0 | |||
a301dba68f | |||
04f3a4a7a5 | |||
f06ce9adc8 | |||
660eec4a23 | |||
be3352a084 | |||
998049ec9b | |||
a7ea0086ee | |||
edb8375a5f | |||
26b9492315 | |||
e110a80caf | |||
20127c1456 | |||
a50d935a48 | |||
7c479f073e | |||
bbf2133fa9 | |||
4300439ab2 | |||
ec14679668 | |||
df06e8b7a4 | |||
1b1a6ba0bb | |||
876ca9ee3a | |||
d9b03be08f | |||
b6aad07634 | |||
3d0406c247 | |||
db3bcc939e | |||
4d45fe6fb5 | |||
076ea2281f | |||
aec39c28d8 | |||
b9525ece77 | |||
719101338a | |||
e131f6bbe8 | |||
a9757ec674 | |||
9003770f02 | |||
e8bbf86e66 | |||
3a0886dc12 | |||
062a7aa2cf | |||
e28f097fc2 | |||
b30c5fc874 | |||
d52ab8e2c9 | |||
df7b875f6c | |||
0e71836cd5 | |||
470a7c6bcd | |||
3abf208235 | |||
92c18d167e | |||
99b38f52cb | |||
633ec30291 | |||
57cfcb0830 | |||
433d479a1e | |||
7c4ac68e66 | |||
20556346a3 | |||
5a417b8514 | |||
8cfc2e2ec0 | |||
11647e4c78 | |||
9e1b61326c | |||
cb7609109d | |||
b3d90365b6 | |||
cc611c93b6 | |||
8928a58796 | |||
05a33d5035 | |||
09f1609f81 | |||
c723d42d0a | |||
9dd550fa1e | |||
abbac4bc69 | |||
671a175dfb | |||
c1474f33be | |||
0ede642cb9 | |||
9c1f6fd06f | |||
798947efa4 | |||
7ae8ad6aab | |||
9c3386b1b7 | |||
2ba3ada27f | |||
8e28382e4a | |||
3203639d7d | |||
f85b543cc1 | |||
c81ad9d19d | |||
37797e2b4e | |||
2a7ebbe982 | |||
72747e5213 | |||
504500de50 | |||
e1174f3774 | |||
6bae73c076 | |||
11db3bd85e | |||
0193be7c9b | |||
2ea73513ea | |||
67e9c62013 | |||
227dbbcfba | |||
cb16e9c747 | |||
3b2d2c467a | |||
ae27af7399 | |||
c69fff15c9 | |||
dd7c1134e3 | |||
b116901400 | |||
70981c601e | |||
f2f61c9cf0 | |||
1bb2476804 | |||
ec58246a1b | |||
6fc5174a13 | |||
105e920b69 | |||
858dea98e5 | |||
71ee0c5b03 | |||
578bdeb522 | |||
6282a86135 | |||
e9b67243ed | |||
fa1c187abc | |||
b51697c197 | |||
c6b75b0823 | |||
0d7e1a9b4e | |||
9d15d85391 | |||
92d7ecf627 | |||
9263da570f | |||
dc88e0a881 | |||
fa34ed8bf3 | |||
f54a901b8d | |||
8a1a989a1c | |||
b479ed9407 | |||
d5dc53ead8 | |||
8e00161601 | |||
01d4eae984 | |||
154154dde2 | |||
4459e0c1c8 | |||
b052ef5f1e | |||
40921bb927 | |||
dfcca66fdc | |||
1ac9085b0a | |||
1cfe67dac4 | |||
8d01db4638 | |||
4268c82898 | |||
3c4eef99be | |||
96b17034e1 | |||
e47a77f941 | |||
af14b1e384 | |||
40f77cb563 | |||
6c1a8daafc | |||
d699c354db | |||
34f3832af9 | |||
f1626574dd | |||
68fc65dbcb | |||
693f79e88a | |||
448d9f9f46 | |||
8786ba95fb | |||
97bb374218 | |||
233044e337 | |||
f365a0f45c | |||
263c1a1d7e | |||
3097083277 | |||
43c187b624 | |||
3165fd3dc9 | |||
e80851d98b | |||
b754e600e3 | |||
81734cf7b6 | |||
30f4fe26e0 | |||
d6265dfcbe | |||
d51f86291f | |||
97ace57d39 | |||
86949e0c20 | |||
6924780ae9 | |||
1b0b69eeec | |||
fa85389f62 | |||
2e55857c82 | |||
ca970f5ee5 | |||
204a2cf942 | |||
0440251919 | |||
08ecfd891d | |||
7395a64668 | |||
979bfd07e1 | |||
b6ce814279 | |||
66088fef1a | |||
808bd4af41 | |||
f90b35a85e | |||
8ae0eec230 | |||
0fe685102f | |||
a98440bb85 | |||
3112311134 | |||
1b13bdea4b | |||
3ce9d51a9c | |||
14d2de13bb | |||
5713e7c9b6 | |||
87206e1986 | |||
414c7e956b | |||
6191d53a78 | |||
7d30ccc4a9 | |||
494a0d064a | |||
849200b576 | |||
60273a941f | |||
7ba720a62a | |||
eacc36bbd5 | |||
8b4acbbcbf | |||
b1fe63d081 | |||
f2ee1dcdb7 | |||
21018af2bf | |||
67ffbae6f9 | |||
5dd5bfde72 | |||
400486ced7 | |||
1a947e4b75 | |||
92bcfefc35 | |||
133b5e6e36 | |||
5c576d3b9d | |||
68b64a261a | |||
68f939ea8c | |||
8c129d73b8 | |||
1fc0d05565 | |||
20a04f9076 | |||
02a38d3ea5 | |||
37cdc4f759 | |||
b1dab181e0 | |||
fd6c4e371b | |||
4352dd27c4 | |||
34cc3f2982 | |||
97fd2480e7 | |||
1d93cf2e85 | |||
3fb98fe4ea | |||
bb804dd3e9 | |||
0034bb28e5 | |||
ca51e020cb | |||
d16852898f | |||
46ddf501a9 | |||
8c89cc4fc5 | |||
00874c27f4 | |||
c59c390cdc | |||
009651e14f | |||
f194f18dbd | |||
4e6be15069 | |||
3e685f98c6 | |||
6c8e7dd63e | |||
2447bd1bac | |||
1c6a252596 | |||
d3c92a307a | |||
8f5836cb14 | |||
319ce182db | |||
aa92f3a721 | |||
afbb6bb797 | |||
4f37f86433 | |||
3093c55e9e | |||
5ac3919259 | |||
f58211d9d8 | |||
fe126cb737 | |||
adc1b129e4 | |||
9315ab88d7 | |||
1ddbddb0db | |||
232bd9395d | |||
956a7e95d7 | |||
ce00fa3627 | |||
3515860b15 | |||
77e717e872 | |||
b46cc744b3 | |||
2cc931ed2a | |||
e096a85874 | |||
2c3e948e61 | |||
53f57d74b8 | |||
2b1de07f02 | |||
fb877696bf | |||
01173b9441 | |||
0564dd25e2 | |||
ba0f6decc3 | |||
06a0cf2e31 | |||
a8afa65a54 | |||
f73a4c229c | |||
d378a29565 | |||
2bdf2feea7 | |||
607fb1fff8 | |||
38fc2a0055 | |||
0c07f8c099 | |||
d8d21c77d5 | |||
b4cd20cbbc | |||
f840afb983 | |||
93d1b4ed9d | |||
fa81c8eeb3 | |||
98308cd79c | |||
052331fabc | |||
541c9a94bf | |||
12452d4ab4 | |||
709a3f6de7 | |||
7caa0a8aa4 | |||
0f56296c24 | |||
0a846a2fce | |||
bffccf4622 | |||
fc774a1871 | |||
d647db222c | |||
0c7eb93889 | |||
0658e1da7f | |||
d2d8e5d40f | |||
5e794492c1 | |||
7a2d2efff8 | |||
e95062d1df | |||
0268818fbb | |||
234268eec2 | |||
ed73d4f3ac | |||
6ca46929fa | |||
185075d870 | |||
451257a2fd | |||
3d5f520ff0 | |||
a1065bfaf0 | |||
d6f345cc3c | |||
d961911a98 | |||
e543272eb3 | |||
c8c0ceeee9 | |||
afbc2b0082 | |||
69ec1e2e31 | |||
ff15509bc7 | |||
7e22a2fc0b | |||
0a48c92d2c | |||
991ca92a9d | |||
c550f885a4 | |||
851038c3a8 | |||
df7774c018 | |||
15090a8ad4 | |||
8575e3f71c | |||
90b0713e32 | |||
d56b7ed96d | |||
db5e5067a0 | |||
0020dad595 | |||
c2d31fb01e | |||
65d49d5c94 | |||
3a99af2696 | |||
503fd1fbaf | |||
4c74dba2f3 | |||
33df13ad65 | |||
ce18c68eca | |||
2d5623911a | |||
a0b30e5dfb | |||
76a920651c | |||
ef6609a723 | |||
c0dcb342f3 | |||
8a9a5ecdd7 | |||
12c5ead39c | |||
3db6b6ca7a | |||
e894f5c399 | |||
8524187869 | |||
f7422a9607 | |||
cf0a9e0730 | |||
8e5beb024f | |||
576d6d8f86 | |||
41a765d715 | |||
f370fd36e0 | |||
a222c3e609 | |||
5a71df0cb3 | |||
6a46cabb10 | |||
74673545c0 | |||
75a311f250 | |||
391ed6334d | |||
0940e6d6ed | |||
4d2ee51bb0 | |||
680128bc09 | |||
5364b51979 | |||
67ef0f0c8f | |||
e9886d701d | |||
022835bab2 | |||
8be2e4c325 | |||
33ba3e31ed | |||
cba2b3c72d | |||
a4a2901294 | |||
bb46f54ad7 | |||
c9b930dd82 | |||
a39f7d63bb | |||
e317f7d51c | |||
3ddd28d37d | |||
fe6b39d585 | |||
d837bfc2d7 | |||
4759975be6 | |||
078a4b00a7 | |||
b00b80a45b | |||
269bbe0e7d | |||
bb2fc6b8da | |||
d4e196035c | |||
b8979c8701 | |||
bfdd3398f6 | |||
3a121a621f | |||
c06f4fc702 | |||
95f1ea2f12 | |||
784347f61f | |||
42176a7ac4 | |||
76b4b80a23 | |||
7c78282ce8 | |||
47c2a2e411 | |||
5faf520067 | |||
02d74cafba | |||
734f30d14c | |||
f2d810febc | |||
819514aeba | |||
b55adee982 | |||
4c32cb952f | |||
35f714e438 | |||
d5ce086089 | |||
82ec02daf8 | |||
8e7a3f031b | |||
7822187b17 | |||
068133ec85 | |||
598fdad089 | |||
ad6a57e0a3 | |||
230255f887 | |||
1338995ee9 | |||
47e4fca7fd | |||
665e7071fa | |||
c20f60b144 | |||
1408357198 | |||
1f9a3dd1e6 | |||
11505fa0d8 | |||
9326e062d8 | |||
cb2cb7c3bd | |||
92d99ba279 | |||
dfbbbb5e3e | |||
89f317915d | |||
4d5fa5c855 | |||
2f35392cd8 | |||
b056adc032 | |||
0d894a18fc | |||
e5138081ec | |||
8ffa483bb6 | |||
ea8a43def0 | |||
535d9da6b6 | |||
18bf77204e | |||
160221c815 | |||
6220b49463 | |||
aa683a765d | |||
b8b91d3418 | |||
0018acfede | |||
51d7a65a2b | |||
a1724f7816 | |||
d108479d84 | |||
cd5bc64658 | |||
5f723cb92c | |||
7ffb75f476 | |||
c0981b8e13 | |||
80e506563c | |||
694951096c | |||
9e7e178585 | |||
db3113ba16 | |||
3117f565c1 | |||
8443d199b2 | |||
8e2f72c644 | |||
0c1d1e2396 | |||
740450287d | |||
0c691af1d2 | |||
5aa53d70aa | |||
3ab86bd661 | |||
2538094e13 | |||
b37a0484d4 | |||
4c5e28e53a | |||
e10d763446 | |||
573b8611bc | |||
966eb2fbd0 | |||
b0c5018c70 | |||
19a509a92c | |||
9e17a147ec | |||
eba59aaf87 | |||
fa809ec8cf | |||
1651a8f189 | |||
27761b4500 | |||
a80ac0a8d3 | |||
5af143e8e4 | |||
255d7226d1 | |||
50abca4583 | |||
de8d7c65f2 | |||
6123b9c0c6 | |||
3b28c75d1f | |||
368169dc15 | |||
f5b2ce0206 | |||
78e3be12a4 | |||
e7d9fd8056 | |||
08dfe91b95 | |||
85d4c4b82e | |||
6e41add867 | |||
11c10b2ab8 | |||
98849de127 | |||
afd703d08c | |||
ce18fdb399 | |||
670771da33 | |||
c8b08f3a59 | |||
3d382dc750 | |||
9081f84543 | |||
f1a9e3c1bb | |||
b10029c18b | |||
5d4f5434fd | |||
81ca51a8f0 | |||
6cb93c1fac | |||
eed67ddafb | |||
be9e8b99ff | |||
8a0e5659c0 | |||
2842b0cc3d | |||
a42322da0c | |||
4ccb2269a5 | |||
b836aca999 | |||
7d9f96abf0 | |||
86b7bd9c8e | |||
a0a6029915 | |||
6531806996 | |||
3a604ba0bf | |||
39f2977fa8 | |||
faacbe4dac | |||
47c72e4627 | |||
59136fdbe4 | |||
35d1922006 | |||
b40aae54b7 | |||
baa3fbab46 | |||
3361a7b834 | |||
0e13a5956c | |||
9466908c22 | |||
93d27d283a | |||
6f59a4a5b2 | |||
71c3103312 | |||
198edb3109 | |||
44d48d9d7a | |||
712630ca65 | |||
6416e79933 | |||
238c5238a5 | |||
593fe5ed25 | |||
54a6e4ff9e | |||
8a6eb1ac78 | |||
16c8167886 | |||
d761059e4d | |||
c805082648 | |||
9a7f5d580f | |||
af99cf2a41 | |||
221f85af3f | |||
a68ad6d58d | |||
7ae0440cca | |||
2ee7662737 | |||
44195858e6 | |||
39b92f7e54 | |||
1eba623d12 | |||
ea2b24fb4e | |||
a9d9aa18a0 | |||
ecc356ecea | |||
98e31290a7 | |||
7a759df49e | |||
38c524d655 | |||
06264645fd | |||
21d213dfc7 | |||
3065fc6cca | |||
bcefc61da4 | |||
569b1e0eb7 | |||
b83fe6f2a4 | |||
b016984c04 | |||
90bc5b221b | |||
37f1fcbfc8 | |||
4a599eec45 | |||
fdfeaaf1f3 | |||
decb4cc4a3 | |||
44c7ac0fe9 | |||
abb36e3cba | |||
84c30be164 | |||
5afaa39e68 | |||
ce1d7c4a6e | |||
b9521b568f | |||
2eca6e67e1 | |||
c757e5794f | |||
67dc970ce4 | |||
56ccd5e6a1 | |||
bcbee13e26 | |||
d28a3f7878 | |||
162dffb7e8 | |||
0f0b9896b7 | |||
e242e20ca3 | |||
f148ebe99e | |||
f795f649cf | |||
063db641f8 | |||
b44f5c69e1 | |||
56833a6171 | |||
efa2928547 | |||
f5335d17ec | |||
dc7d24267d | |||
412ab3f20c | |||
954c08e97c | |||
a2dcb7b476 | |||
bb0902c592 | |||
9e661e58d1 | |||
d0e72a8f8f | |||
b44eb328e0 | |||
7b4a8d53a7 | |||
73505e2ff0 | |||
e11f5294ab | |||
0190df9cf3 | |||
1af42aedfa | |||
215611aa9e | |||
98d83b2e2d | |||
cf7689ea7b | |||
2848f0499f | |||
5d4b36f80f | |||
415a0f8047 | |||
e0a8376237 | |||
f0f65443c6 | |||
fcc91d862f | |||
3887d8a429 | |||
041f57cb7d | |||
c5ce0408a1 | |||
98dd609f89 | |||
6dc67772b0 | |||
5cf64266f8 | |||
03513e9d70 | |||
b9ed97c0d9 | |||
799be9c98a | |||
566dab1140 | |||
42dc2c19ee | |||
518eb540aa | |||
309ada5df5 | |||
9da63408b0 | |||
7ae7a8440c | |||
aef524506b | |||
547c363473 | |||
2714644528 | |||
d27588b5fb | |||
a8379a46cf | |||
b7caa3e024 | |||
3e33482386 | |||
5057e16874 | |||
9449eff6fd | |||
03610526e5 | |||
cd28df627c | |||
59d62bc5aa | |||
8f46db3701 | |||
109229246c | |||
5856298798 | |||
7f9c589ba3 | |||
8931e71c5c | |||
5bc435eba3 | |||
4dabec6a48 | |||
978376a46e | |||
895f47a9c2 | |||
aec65dee71 | |||
2f66932bd1 | |||
919ff12377 | |||
6748aeabb6 | |||
b3e63c09ab | |||
77b8a76f2e | |||
55b8de9fdd | |||
eb56ab38dc | |||
f29c6bbc6f | |||
464701a899 | |||
5b96fb9320 | |||
1cfb263ee3 | |||
9ca2b4c967 | |||
673d8ae583 | |||
8760bf7be4 | |||
ea02073c84 | |||
b051d7ff48 | |||
cade722e48 | |||
58817f55e1 | |||
3f1d7f7a76 | |||
c8dc116951 | |||
9684d78cae | |||
71f5b73296 | |||
5ba8c14893 | |||
ea9d8a6bc7 | |||
9650ff0824 | |||
00dce1698a | |||
c946a929b7 | |||
21c96a5af1 | |||
a0b9c23100 | |||
cb5bc76766 | |||
1c8772a879 | |||
79ed0e7121 | |||
0c69903123 | |||
04dc24820d | |||
e263e19a2a | |||
ac220fc2bb | |||
3b80472bad | |||
ca17d4f639 | |||
64335d3521 | |||
9945ce2259 | |||
6d9da73090 | |||
c889fb1ef5 | |||
d32bc5df3e | |||
efaf502e95 | |||
ff32c53099 | |||
235eb17cca | |||
aaa562898f | |||
c8fd904c32 | |||
65a8f7f6c9 | |||
f32bfcdbe5 | |||
24b9067047 | |||
de8de84cea | |||
8d300ffbfc | |||
f74dafcb07 | |||
065b76d14c | |||
253345c0c0 | |||
270d694ed2 | |||
f4b771a0c6 | |||
a4de214e2b | |||
aa8bba4865 | |||
392d584572 | |||
900a88b15d | |||
cb384e8ed3 | |||
a2b2afb21c | |||
b70b9606eb | |||
938406122e | |||
951a575db3 | |||
8c50457385 | |||
8c09d10ba9 | |||
c04e51cb15 | |||
7b94f493b9 | |||
f1f04fa782 | |||
4be1966a21 | |||
de25cfc0cb | |||
de36a9b718 | |||
883a3250e4 | |||
1ceb2f9c79 | |||
fbde2a8010 | |||
81925fa66d | |||
6e2abcd5fc | |||
3f46645f5f | |||
2a7f63650c | |||
cd5b1f306a | |||
749bcf3d9c | |||
a9027a2570 | |||
72e32b1f17 | |||
96648ba98a | |||
6561d46349 | |||
2e5e37ac58 | |||
6bac4e65ce | |||
a28f5eeed3 | |||
c0c50133e3 | |||
b9723f9765 | |||
cb35c26d70 | |||
bb52e22ecf | |||
35a2dfc177 | |||
d8e2829e5b | |||
ac5e6baced | |||
710b4a3503 | |||
5331fc1aab | |||
9b7f2ce6c3 | |||
11b2f62ed2 | |||
e7c37d77a8 | |||
1762047bc0 | |||
9a4c8d543d | |||
f5aaa55f21 | |||
23e6502ef3 | |||
ff0f53c915 | |||
64ef69fb34 | |||
249cd8c2ec | |||
d1fb066d07 | |||
2f977312be | |||
feda017c50 | |||
2991221551 | |||
d7719aa0f5 | |||
54e587a46d | |||
4ac5096232 | |||
3d06b18fee | |||
446153675f | |||
bb1850a912 | |||
93516ea8a1 | |||
c3fa8803d3 | |||
3cad5da5a4 | |||
7a8bd99ab1 | |||
7520ddcf40 | |||
9c1318d731 | |||
062fc4afee | |||
87ec8d13ce | |||
cf034f759a | |||
b0c5d21f31 | |||
cbe5f78c1a | |||
4598af23c0 | |||
d855e90524 | |||
897fc87620 | |||
b37f5fc59b | |||
9730351048 | |||
ce687550ae | |||
563b909279 | |||
c390b06da2 | |||
db4e9ea04a | |||
5e6a3ff6a7 | |||
d0dcabd700 | |||
6ed812ff75 | |||
5461182e25 | |||
cbe95f3d25 | |||
ce57b6fb45 | |||
134f542b36 | |||
c3727f330b | |||
d313aad671 | |||
a54fe634ee | |||
ccb6c45466 | |||
800b1b060e | |||
ab03852234 | |||
f90258162a | |||
745731e1a3 | |||
11b0213d20 | |||
cd29d68f3c | |||
da668848c9 | |||
bfd5f27525 | |||
9f66c9c1d1 | |||
1259fe75c9 | |||
e9db74f937 | |||
f99cb96533 | |||
1e848d696b | |||
1bfa7c6f14 | |||
05d1008a08 | |||
9a98fc0f05 | |||
2ca6258a0f | |||
24c34385ee | |||
e07b7ea114 | |||
73c7503ae0 | |||
79db6c3d19 | |||
b6ddacdccf | |||
c9983e6440 | |||
51098c4f86 | |||
4624406ce8 | |||
76269f4a1f | |||
50174d9fb5 | |||
648de7dd86 | |||
9d40ab9e20 | |||
691e86c9bf | |||
8fa4453aec | |||
93d8f8cd63 | |||
c2fca3d9e0 | |||
8b31178647 | |||
e951612af2 | |||
b668c2c781 | |||
ecd0348d96 | |||
69b86925b3 | |||
5293794316 | |||
728c9d0632 | |||
01d93f3af8 | |||
a0d124bd91 | |||
57bc245cb4 | |||
bc431888f3 | |||
ec028b8109 | |||
014594fe8f | |||
3c8a61e40c | |||
bccfaa46ec | |||
2f442062d2 | |||
840cb3d69e | |||
7dfa0b9da9 | |||
f5d0fac800 | |||
baecb553a6 | |||
6e89f0bf8d | |||
f21ff904c2 | |||
38a7e0d1c7 | |||
90814e4449 | |||
e927aeae86 | |||
a77b126d72 | |||
82417b3ca5 | |||
5b141fbf27 | |||
268884296a | |||
ea8ffc9841 | |||
6d930d2fc5 | |||
2ddf3bcdd1 | |||
0a3a9afe58 | |||
5a88d2f68b | |||
7165eb15bd | |||
e20a72280b | |||
abecf3eb6d | |||
7c418a7e06 | |||
58f080a325 | |||
be719e4817 | |||
11b54f69e5 | |||
4a3c66fe22 | |||
9a5084412d | |||
24670667f1 | |||
3f307ff061 | |||
7433da546f | |||
eda2a7b2dc | |||
196203f6d7 | |||
46b0c7a18c | |||
b6a0098aa3 | |||
7d986ae5dc | |||
a2bdcc9ba8 | |||
624b5a5f83 | |||
b57c9605ce | |||
49dfc9fe2d | |||
8f9ba62dc3 | |||
16673fa38b | |||
49d97e1216 | |||
a73050de48 | |||
2535769a65 | |||
b1a3c47766 | |||
8479e9e6d7 | |||
c0b1bbea3e | |||
092f0df7a6 | |||
dfc81c3dab | |||
6649743a2d | |||
56c46d70f7 | |||
c36ec9bf60 | |||
86396a43e9 | |||
590e68c251 | |||
bd704c90dd | |||
ea4afebeb9 | |||
886cca028f | |||
4054055d0d | |||
09c4cb2540 | |||
8ad464d90e | |||
65af9641c2 | |||
cb5a7efa91 | |||
ce47d33cd9 | |||
2e47a0d19f | |||
6a2e08d0a8 | |||
fdb3f26448 | |||
14b7dfa007 | |||
9394835db4 | |||
70b1d6dd9d | |||
d263595c63 | |||
9cb5964b4d | |||
3ad0cc5736 | |||
f09fd6ec16 | |||
8cfa58715c | |||
909264feb5 | |||
cc1ed77dd8 | |||
7d69a91bfe | |||
a6545ddd4d | |||
498bd64d9c | |||
ad9a3a2d3b | |||
2da0f1639f | |||
3ec58ecea0 | |||
061475402c | |||
8f1359d25f | |||
8659bd2c4b | |||
4a052cd343 | |||
28c2a71cfc | |||
69b37fff26 | |||
df619adc76 | |||
374bf1ed98 | |||
540581da3e | |||
6f5fccfeb7 | |||
14669f20bf | |||
075f3f8c9f | |||
2798c1bbec | |||
38d75d410e | |||
04fb29b589 | |||
9037593ab8 | |||
3ced940b5a | |||
f5a98d98c8 | |||
0799f184dc | |||
c530c5d317 | |||
91b2e394d5 | |||
a487563768 | |||
0ab04bd62c | |||
46ce3317c3 | |||
83527fd4fb | |||
6d12aa978d | |||
96aa2365ae | |||
258d5392d5 | |||
09d9f5fe54 | |||
d8b73e4223 | |||
4c566dbfbb | |||
bde9771991 | |||
fe0d02fc47 | |||
7b005aadc1 | |||
7764c5c697 | |||
c6917d9d4f | |||
25132bff86 | |||
0e38bf9de0 | |||
309bae0a0b | |||
ae70293df3 | |||
1e623a3710 | |||
928e2543cd | |||
92e14a36f9 | |||
34a1990c57 | |||
e4c3882a81 | |||
62f9738a9a | |||
a562c64ed6 | |||
8c726ea87e | |||
c8be3960a0 | |||
759af8b56b | |||
97deb01b1f | |||
ad639d783f | |||
e1c2e50d92 | |||
dd4e3f8704 | |||
08941aa0c7 | |||
03fb766428 | |||
2454a3b641 | |||
3e793f079d | |||
5fbb0d050c | |||
71a8627c5d | |||
5c38012980 | |||
7c2ce296d3 | |||
9d37d86223 | |||
21ef5a1961 | |||
46f8a6dd85 | |||
0aa90c6be4 | |||
c05a8cf7bb | |||
4c21114087 | |||
65bb2373ae | |||
8c925bca71 | |||
330a8c90e4 | |||
d8c35c893b | |||
0fe308c0ef | |||
2d14c3b17a | |||
8ef621ad2a | |||
28bf222a6a | |||
9e883f5873 | |||
43a9619e57 | |||
f518e3a75f | |||
146b30796e | |||
5942b2b27c | |||
330b1090f4 | |||
3ccbe28d9f | |||
67719f2185 | |||
6388768d73 | |||
1dda05a965 | |||
14fd78fd85 | |||
a9321b1387 | |||
19cb503531 | |||
9f2acf54bc | |||
9c77a7cdaf | |||
d58a242fe7 | |||
910c0d9ee7 | |||
331b9f6425 | |||
49162784a8 | |||
0c36f2353d | |||
a580f8c61f | |||
f368381d12 | |||
7c2f795ea6 | |||
93d48f1d89 | |||
861953c95c | |||
aa16ccda79 | |||
6269d28bb0 | |||
d438b88f19 | |||
8e03f65645 | |||
606b8fafb0 | |||
b7fa5dec21 | |||
a5c972aa8b | |||
d05aa70c6b | |||
eac99c1b16 | |||
b7a89cec59 | |||
9e0d7be014 | |||
99951911d5 | |||
fd72fad8fd | |||
ff82756415 | |||
d28243d5fc | |||
447e534350 | |||
cda887896a | |||
09b548db75 | |||
920bf373fe | |||
a9ae4daab2 | |||
15662efec4 | |||
5846c46b76 | |||
816b389759 | |||
6605dd1c7c | |||
837ed788f4 | |||
2e4fe7fd2e | |||
52ea193638 | |||
5e3ef775d5 | |||
228238e602 | |||
a6fd22c399 | |||
1616cae5cf | |||
d5cf684d99 | |||
c9e51d9911 | |||
b2830384f5 | |||
487a0e1b5d | |||
8fe42e58b3 | |||
afb17a5b6e | |||
b8f3533d53 | |||
4918cd241f | |||
45983301d5 | |||
acf57def13 | |||
800591db00 | |||
159c98c202 | |||
c837b3dfb9 | |||
53b89ec312 | |||
c17b912eb9 | |||
8785b2bf6d | |||
fb1be83a1b | |||
ea848f74af | |||
b8d5f87f96 | |||
95afaf495b | |||
d92930e975 | |||
c2892dada3 | |||
b2b1195534 | |||
e1b09e3bcc | |||
c65b75443e | |||
db0dca3fc1 | |||
7a715b2403 | |||
1ba296644d | |||
b800a0c824 | |||
0dda01e37c | |||
c8ab5cb0c5 | |||
92084f2b6a | |||
08f2f08d74 | |||
376088da70 | |||
73808dd38b | |||
ee03418b10 | |||
da700d1842 | |||
de87c47dd9 | |||
1060805a1f | |||
08d86751b9 | |||
9319b5f329 | |||
26f6bd4d3b | |||
edb2571a59 | |||
98cb974796 | |||
8b414222aa | |||
ea49a95bd9 | |||
a50d79df47 | |||
64285a2171 | |||
941f194a83 | |||
8b4edcc7ad | |||
c58499786c | |||
1bcbcfd56f | |||
90d2518d9a | |||
7354949763 | |||
5efc86069f | |||
97149f9424 | |||
bac265fdc2 | |||
e59e5e24b9 | |||
9e5d4781cb | |||
fc1f6efe0d | |||
c9710d4fb5 | |||
cf16f3b0dd | |||
a9e91115bf | |||
90f699fdcf | |||
fd7b855cfc | |||
20aab64c65 | |||
2eb027a793 | |||
b0a7bc77ee | |||
4e10faf1eb | |||
a0c6d44e18 | |||
9bc998c7a1 | |||
1a0c6d89b1 | |||
8c12374c4c | |||
45e2126273 | |||
41497b052d | |||
068cad1c1b | |||
31ef92fc36 | |||
b4081e3713 | |||
c8b4a33a7f | |||
a13ddf2e8a | |||
64beae9527 | |||
15a082c74e | |||
fbccd5cd38 | |||
1e8b132ade | |||
431eb309f3 | |||
8e6995c91e | |||
0759911431 | |||
1d7693c1e1 | |||
16e0423085 | |||
c2ffb6bfcd | |||
2489e4ba1b | |||
94da80148e | |||
764e90f9bb | |||
9bf2fb4a74 | |||
de3d2eeeba | |||
a805d00256 | |||
61135bc842 | |||
fa36ffda14 | |||
d3eda7a5b5 | |||
a755b715ed | |||
a9d5de0e56 | |||
f634c62cb3 | |||
f8c075ae27 | |||
aeb99645bb | |||
0d3e314df0 | |||
28ce68a13d | |||
80075afe8a | |||
bcc29ffdd1 | |||
0c43535ccc | |||
49829b4a4d | |||
5c5c2ae405 | |||
6e9264a79c | |||
a6fb78ee3c | |||
d0bc83ca27 | |||
b5b2fed54d | |||
4cef5dddc6 | |||
f92591054b | |||
2a0e55ffb5 | |||
9429032da1 | |||
791534f2f4 | |||
604546c287 | |||
c66437fc13 | |||
8415910375 | |||
5486e5417b | |||
2d78c8cc05 | |||
52bed7f9b3 | |||
7fb45283df | |||
b7212f5afe | |||
4e25601c4d | |||
add7829cb7 | |||
a52184bdda | |||
410aa33005 | |||
0ab49d4cec | |||
994089d36b | |||
d2fbbb44ae | |||
77fd91d615 | |||
492153a986 | |||
c0e05e6f03 | |||
73a46205a0 | |||
992aa17361 | |||
a4076c70cc | |||
52bbc9baf4 | |||
26d4ce29e8 | |||
41f61b0b5b | |||
f92d644c95 | |||
923d0c56e7 | |||
dd36d413ba | |||
013d806b79 | |||
6e98757665 | |||
313158132d | |||
6772c913c7 | |||
b11d0119ac | |||
637a489996 | |||
c2bd357825 | |||
4870f910d6 | |||
c95a3048ce | |||
0e6eb6d719 | |||
09574fc285 | |||
4347cb2119 | |||
f600d4e9e4 | |||
eaa04354d5 | |||
3f7cfde476 | |||
62d5543b01 | |||
f1b33ab7b1 | |||
029d0f25e5 | |||
3f38c6fdcd | |||
80112a9ea1 | |||
795638e18b | |||
322bf7a0d5 | |||
cd981499f9 | |||
c439742a54 | |||
a3e32fb7e1 | |||
1171f91a80 | |||
8e2c8b3e4d | |||
b00fe20afd | |||
36ce0afff6 | |||
5c0ea20bd0 | |||
49764a5bff | |||
4f7d62adac | |||
c10c060d20 | |||
5fe2d8fd80 | |||
5c34066058 | |||
50ab06e29d | |||
06fc42bc44 | |||
959a03a61f | |||
3b1956bbf2 | |||
3c15916e17 | |||
ff60c041f6 | |||
13686bb518 | |||
f093501501 | |||
80649ea03c | |||
778f7d6f33 | |||
2c5a671341 | |||
0aad270267 | |||
221899a930 | |||
060a2d11e5 | |||
abbbb4d52c | |||
ccd38dd54d | |||
cdc882bd36 | |||
1c1085b140 | |||
914797a8ff | |||
ab40fcb068 | |||
1847550ad1 | |||
6497633529 | |||
8850098ea4 | |||
eedca09d73 | |||
7b6dbf0952 | |||
e6c81d2a42 | |||
498a95148b | |||
21a18d6ceb | |||
195b863ea4 | |||
75147ff008 | |||
018e5c979b | |||
e7dab7e6c1 | |||
26efa3a25c | |||
893652a813 | |||
6559425b07 | |||
df914ef4bf | |||
4e1cf5b41a | |||
0c5f893f6e | |||
17f5f3b32c | |||
3bb59902f7 | |||
b804a488c5 | |||
cbde75e77b | |||
413e11fac2 | |||
0e2dd76c3b | |||
ff71eff157 | |||
fa1920a02b | |||
71cd2957f7 | |||
6c8638cf01 | |||
8b5c6b2732 | |||
601494734c | |||
1aebea52e1 | |||
920b3d259d | |||
fce55d87d2 | |||
53d62fa7d0 | |||
a69afeb614 | |||
5f9fb911f7 |
6
.bazelrc
Normal file
6
.bazelrc
Normal file
@ -0,0 +1,6 @@
|
||||
# Disable sandboxing because it's too slow.
|
||||
# https://github.com/bazelbuild/bazel/issues/2424
|
||||
build --spawn_strategy=standalone
|
||||
|
||||
# Performance: avoid stat'ing input files
|
||||
build --watchfs
|
56
.circleci/config.yml
Normal file
56
.circleci/config.yml
Normal file
@ -0,0 +1,56 @@
|
||||
# Configuration file for https://circleci.com/gh/angular/angular
|
||||
|
||||
# Note: YAML anchors allow an object to be re-used, reducing duplication.
|
||||
# The ampersand declares an alias for an object, then later the `<<: *name`
|
||||
# syntax dereferences it.
|
||||
# See http://blog.daemonl.com/2016/02/yaml.html
|
||||
# To validate changes, use an online parser, eg.
|
||||
# http://yaml-online-parser.appspot.com/
|
||||
|
||||
# Settings common to each job
|
||||
anchor_1: &job_defaults
|
||||
working_directory: ~/ng
|
||||
docker:
|
||||
- image: angular/ngcontainer
|
||||
|
||||
# After checkout, rebase on top of master.
|
||||
# Similar to travis behavior, but not quite the same.
|
||||
# See https://discuss.circleci.com/t/1662
|
||||
anchor_2: &post_checkout
|
||||
post: git pull --ff-only origin "refs/pull/${CI_PULL_REQUEST//*pull\//}/merge"
|
||||
|
||||
version: 2
|
||||
jobs:
|
||||
lint:
|
||||
<<: *job_defaults
|
||||
steps:
|
||||
- checkout:
|
||||
<<: *post_checkout
|
||||
- restore_cache:
|
||||
key: angular-{{ .Branch }}-{{ checksum "npm-shrinkwrap.json" }}
|
||||
|
||||
- run: npm install
|
||||
- run: npm run postinstall
|
||||
- run: ./node_modules/.bin/gulp lint
|
||||
|
||||
build:
|
||||
<<: *job_defaults
|
||||
steps:
|
||||
- checkout:
|
||||
<<: *post_checkout
|
||||
- restore_cache:
|
||||
key: angular-{{ .Branch }}-{{ checksum "npm-shrinkwrap.json" }}
|
||||
|
||||
- run: bazel run @build_bazel_rules_typescript_node//:bin/npm install
|
||||
- run: bazel build ...
|
||||
- save_cache:
|
||||
key: angular-{{ .Branch }}-{{ checksum "npm-shrinkwrap.json" }}
|
||||
paths:
|
||||
- "node_modules"
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
default_workflow:
|
||||
jobs:
|
||||
- lint
|
||||
- build
|
64
.github/ISSUE_TEMPLATE.md
vendored
64
.github/ISSUE_TEMPLATE.md
vendored
@ -1,39 +1,57 @@
|
||||
<!--
|
||||
IF YOU DON'T FILL OUT THE FOLLOWING INFORMATION WE MIGHT CLOSE YOUR ISSUE WITHOUT INVESTIGATING
|
||||
PLEASE HELP US PROCESS GITHUB ISSUES FASTER BY PROVIDING THE FOLLOWING INFORMATION.
|
||||
|
||||
ISSUES MISSING IMPORTANT INFORMATION MAY BE CLOSED WITHOUT INVESTIGATION.
|
||||
-->
|
||||
|
||||
**I'm submitting a ...** (check one with "x")
|
||||
```
|
||||
[ ] bug report => search github for a similar issue or PR before submitting
|
||||
[ ] feature request
|
||||
[ ] support request => Please do not submit support request here, instead see https://github.com/angular/angular/blob/master/CONTRIBUTING.md#question
|
||||
```
|
||||
## I'm submitting a...
|
||||
<!-- Check one of the following options with "x" -->
|
||||
<pre><code>
|
||||
[ ] Regression (a behavior that used to work and stopped working in a new release)
|
||||
[ ] Bug report <!-- Please search GitHub for a similar issue or PR before submitting -->
|
||||
[ ] Feature request
|
||||
[ ] Documentation issue or request
|
||||
[ ] Support request => Please do not submit support request here, instead see https://github.com/angular/angular/blob/master/CONTRIBUTING.md#question
|
||||
</code></pre>
|
||||
|
||||
**Current behavior**
|
||||
<!-- Describe how the bug manifests. -->
|
||||
## Current behavior
|
||||
<!-- Describe how the issue manifests. -->
|
||||
|
||||
**Expected behavior**
|
||||
<!-- Describe what the behavior would be without the bug. -->
|
||||
|
||||
**Minimal reproduction of the problem with instructions**
|
||||
## Expected behavior
|
||||
<!-- Describe what the desired behavior would be. -->
|
||||
|
||||
|
||||
## Minimal reproduction of the problem with instructions
|
||||
<!--
|
||||
If the current behavior is a bug or you can illustrate your feature request better with an example,
|
||||
please provide the *STEPS TO REPRODUCE* and if possible a *MINIMAL DEMO* of the problem via
|
||||
For bug reports please provide the *STEPS TO REPRODUCE* and if possible a *MINIMAL DEMO* of the problem via
|
||||
https://plnkr.co or similar (you can use this template as a starting point: http://plnkr.co/edit/tpl:AvJOMERrnz94ekVua0u5).
|
||||
-->
|
||||
|
||||
**What is the motivation / use case for changing the behavior?**
|
||||
<!-- Describe the motivation or the concrete use case -->
|
||||
## What is the motivation / use case for changing the behavior?
|
||||
<!-- Describe the motivation or the concrete use case. -->
|
||||
|
||||
**Please tell us about your environment:**
|
||||
<!-- Operating system, IDE, package manager, HTTP server, ... -->
|
||||
|
||||
* **Angular version:** 2.0.X
|
||||
## Environment
|
||||
|
||||
<pre><code>
|
||||
Angular version: X.Y.Z
|
||||
<!-- Check whether this is still an issue in the most recent Angular version -->
|
||||
|
||||
* **Browser:** [all | Chrome XX | Firefox XX | IE XX | Safari XX | Mobile Chrome XX | Android X.X Web Browser | iOS XX Safari | iOS XX UIWebView | iOS XX WKWebView ]
|
||||
<!-- All browsers where this could be reproduced -->
|
||||
Browser:
|
||||
- [ ] Chrome (desktop) version XX
|
||||
- [ ] Chrome (Android) version XX
|
||||
- [ ] Chrome (iOS) version XX
|
||||
- [ ] Firefox version XX
|
||||
- [ ] Safari (desktop) version XX
|
||||
- [ ] Safari (iOS) version XX
|
||||
- [ ] IE version XX
|
||||
- [ ] Edge version XX
|
||||
|
||||
* **Language:** [all | TypeScript X.X | ES6/7 | ES5]
|
||||
For Tooling issues:
|
||||
- Node version: XX <!-- run `node --version` -->
|
||||
- Platform: <!-- Mac, Linux, Windows -->
|
||||
|
||||
* **Node (for AoT issues):** `node --version` =
|
||||
Others:
|
||||
<!-- Anything else relevant? Operating system version, IDE, package manager, HTTP server, ... -->
|
||||
</code></pre>
|
||||
|
27
.github/PULL_REQUEST_TEMPLATE.md
vendored
27
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -1,10 +1,15 @@
|
||||
**Please check if the PR fulfills these requirements**
|
||||
## PR Checklist
|
||||
Please check if your PR fulfills the following requirements:
|
||||
|
||||
- [ ] The commit message follows our guidelines: https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit
|
||||
- [ ] Tests for the changes have been added (for bug fixes / features)
|
||||
- [ ] Docs have been added / updated (for bug fixes / features)
|
||||
|
||||
|
||||
**What kind of change does this PR introduce?** (check one with "x")
|
||||
## PR Type
|
||||
What kind of change does this PR introduce?
|
||||
|
||||
<!-- Please check the one that applies to this PR using "x". -->
|
||||
```
|
||||
[ ] Bugfix
|
||||
[ ] Feature
|
||||
@ -12,25 +17,27 @@
|
||||
[ ] Refactoring (no functional changes, no api changes)
|
||||
[ ] Build related changes
|
||||
[ ] CI related changes
|
||||
[ ] Documentation content changes
|
||||
[ ] angular.io application / infrastructure changes
|
||||
[ ] Other... Please describe:
|
||||
```
|
||||
|
||||
**What is the current behavior?** (You can also link to an open issue here)
|
||||
## What is the current behavior?
|
||||
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->
|
||||
|
||||
Issue Number: N/A
|
||||
|
||||
|
||||
|
||||
**What is the new behavior?**
|
||||
## What is the new behavior?
|
||||
|
||||
|
||||
|
||||
**Does this PR introduce a breaking change?** (check one with "x")
|
||||
## Does this PR introduce a breaking change?
|
||||
```
|
||||
[ ] Yes
|
||||
[ ] No
|
||||
```
|
||||
|
||||
If this PR contains a breaking change, please describe the impact and migration path for existing applications: ...
|
||||
<!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below. -->
|
||||
|
||||
|
||||
**Other information**:
|
||||
|
||||
## Other information
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
.DS_STORE
|
||||
|
||||
/dist/
|
||||
bazel-*
|
||||
e2e_test.*
|
||||
node_modules
|
||||
bower_components
|
||||
|
||||
@ -26,7 +28,3 @@ yarn-error.log
|
||||
|
||||
# rollup-test output
|
||||
/modules/rollup-test/dist/
|
||||
|
||||
# angular.io
|
||||
/aio/src/content/docs
|
||||
/aio/dist
|
||||
|
@ -8,9 +8,11 @@
|
||||
# alexeagle - Alex Eagle
|
||||
# alxhub - Alex Rickabaugh
|
||||
# chuckjaz - Chuck Jazdzewski
|
||||
# Foxandxss - Jesús Rodríguez
|
||||
# gkalpak - George Kalpakas
|
||||
# IgorMinar - Igor Minar
|
||||
# jasonaden - Jason Aden
|
||||
# juleskremer - Jules Kremer
|
||||
# kara - Kara Erickson
|
||||
# matsko - Matias Niemelä
|
||||
# mhevery - Misko Hevery
|
||||
@ -18,8 +20,11 @@
|
||||
# pkozlowski-opensource - Pawel Kozlowski
|
||||
# robwormald - Rob Wormald
|
||||
# tbosch - Tobias Bosch
|
||||
# tinayuangao - Tina Gao
|
||||
# vicb - Victor Berchet
|
||||
# vikerman - Vikram Subramanian
|
||||
# wardbell - Ward Bell
|
||||
|
||||
|
||||
version: 2
|
||||
|
||||
@ -40,6 +45,7 @@ groups:
|
||||
- "aio/*"
|
||||
- "integration/*"
|
||||
- "modules/*"
|
||||
- "packages/*"
|
||||
- "tools/*"
|
||||
users:
|
||||
- IgorMinar
|
||||
@ -90,19 +96,21 @@ groups:
|
||||
- "packages/core/*"
|
||||
users:
|
||||
- tbosch #primary
|
||||
- chuckjaz
|
||||
- mhevery
|
||||
- vicb
|
||||
- IgorMinar #fallback
|
||||
|
||||
compiler/animations:
|
||||
animations:
|
||||
conditions:
|
||||
files:
|
||||
- "packages/compiler/src/animation/*"
|
||||
- "packages/animation/*"
|
||||
- "packages/platform-browser/animations/*"
|
||||
users:
|
||||
- matsko #primary
|
||||
- tbosch
|
||||
- IgorMinar #fallback
|
||||
- chuckjaz #fallback
|
||||
- mhevery #fallback
|
||||
- IgorMinar #fallback
|
||||
|
||||
compiler/i18n:
|
||||
conditions:
|
||||
@ -133,6 +141,7 @@ groups:
|
||||
users:
|
||||
- alexeagle
|
||||
- chuckjaz
|
||||
- vicb
|
||||
- tbosch
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
@ -153,7 +162,7 @@ groups:
|
||||
- "packages/forms/*"
|
||||
users:
|
||||
- kara #primary
|
||||
# needs secondary
|
||||
- tinayuangao #secondary
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
|
||||
@ -173,7 +182,8 @@ groups:
|
||||
- "packages/language-service/*"
|
||||
users:
|
||||
- chuckjaz #primary
|
||||
# needs secondary
|
||||
- tbosch #secondary
|
||||
- vicb
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
|
||||
@ -182,8 +192,8 @@ groups:
|
||||
files:
|
||||
- "packages/router/*"
|
||||
users:
|
||||
- vicb #primary
|
||||
# needs secondary
|
||||
- jasonaden
|
||||
- vicb
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
|
||||
@ -244,9 +254,46 @@ groups:
|
||||
angular.io:
|
||||
conditions:
|
||||
files:
|
||||
include:
|
||||
- "aio/*"
|
||||
exclude:
|
||||
- "aio/content/*"
|
||||
users:
|
||||
- petebacondarwin #primary
|
||||
- IgorMinar
|
||||
- robwormald
|
||||
- petebacondarwin
|
||||
- gkalpak
|
||||
- mhevery #fallback
|
||||
|
||||
angular.io-guide-and-tutorial:
|
||||
conditions:
|
||||
files:
|
||||
include:
|
||||
- "aio/content/*"
|
||||
exclude:
|
||||
- "aio/content/marketing/*"
|
||||
- "aio/content/navigation.json"
|
||||
- "aio/content/license.md"
|
||||
users:
|
||||
- juleskremer #primary
|
||||
- Foxandxss
|
||||
- stephenfluin
|
||||
- wardbell
|
||||
- petebacondarwin
|
||||
- gkalpak
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
|
||||
angular.io-marketing:
|
||||
conditions:
|
||||
files:
|
||||
include:
|
||||
- "aio/content/marketing/*"
|
||||
- "aio/content/navigation.json"
|
||||
- "aio/content/license.md"
|
||||
users:
|
||||
- juleskremer #primary
|
||||
- stephenfluin
|
||||
- petebacondarwin
|
||||
- gkalpak
|
||||
- IgorMinar #fallback
|
||||
- mhevery #fallback
|
||||
|
23
.travis.yml
23
.travis.yml
@ -1,9 +1,12 @@
|
||||
language: node_js
|
||||
sudo: false
|
||||
# force trusty as Google Chrome addon is not supported on Precise
|
||||
dist: trusty
|
||||
node_js:
|
||||
- '6.9.5'
|
||||
|
||||
addons:
|
||||
chrome: stable
|
||||
# firefox: "38.0"
|
||||
apt:
|
||||
sources:
|
||||
@ -19,7 +22,7 @@ addons:
|
||||
- secure: "L7nrZwkAtFtYrP2DykPXgZvEKjkv0J/TwQ/r2QGxFTaBq4VZn+2Dw0YS7uCxoMqYzDwH0aAOqxoutibVpk8Z/16nE3tNmU5RzltMd6Xmt3qU2f/JDQLMo6PSlBodnjOUsDHJgmtrcbjhqrx/znA237BkNUu6UZRT7mxhXIZpn0U="
|
||||
branches:
|
||||
except:
|
||||
- g3_v2_0
|
||||
- g3
|
||||
|
||||
cache:
|
||||
yarn: true
|
||||
@ -30,16 +33,21 @@ cache:
|
||||
|
||||
env:
|
||||
global:
|
||||
# GITHUB_TOKEN_ANGULAR
|
||||
# GITHUB_TOKEN_ANGULAR=<github token, a personal access token of the angular-builds account, account access in valentine>
|
||||
# This is needed for the e2e Travis matrix task to publish packages to github for continuous packages delivery.
|
||||
- secure: "fq/U7VDMWO8O8SnAQkdbkoSe2X92PVqg4d044HmRYVmcf6YbO48+xeGJ8yOk0pCBwl3ISO4Q2ot0x546kxfiYBuHkZetlngZxZCtQiFT9kyId8ZKcYdXaIW9OVdw3Gh3tQyUwDucfkVhqcs52D6NZjyE2aWZ4/d1V4kWRO/LMgo="
|
||||
- secure: "aCdHveZuY8AT4Jr1JoJB4LxZsnGWRe/KseZh1YXYe5UtufFCtTVHvUcLn0j2aLBF0KpdyS+hWf0i4np9jthKu2xPKriefoPgCMpisYeC0MFkwbmv+XlgkUbgkgVZMGiVyX7DCYXVahxIoOUjVMEDCbNiHTIrfEuyq24U3ok2tHc="
|
||||
# FIREBASE_TOKEN
|
||||
# This is needed for publishing builds to the "aio-staging" firebase site.
|
||||
# TODO(i): the token was generated using the iminar@google account, we should switch to a shared/role-base account.
|
||||
- secure: "MPx3UM77o5IlhT75PKHL0FXoB5tSXDc3vnCXCd1sRy4XUTZ9vjcV6nNuyqEf+SOw659bGbC1FI4mACGx1Q+z7MQDR85b1mcA9uSgHDkh+IR82CnCVdaX9d1RXafdJIArahxfmorbiiPPLyPIKggo7ituRm+2c+iraoCkE/pXxYg="
|
||||
# This is needed for publishing builds to the "aio-staging" and "angular-io" firebase projects.
|
||||
# This token was generated using the aio-deploy@angular.io account using `firebase login:ci` and password from valentine
|
||||
- secure: "L5CyQmpwWtoR4Qi4xlWQh/cL1M6ZeJL4W4QAr4HdKFMgYt9h+Whqkymyh2NxwmCbPvWa7yUd+OiLQUDCY7L2VIg16hTwoe2CgYDyQA0BEwLzxtRrJXl93TfwMlrUx5JSIzAccD6D4sjtz8kSFMomK2Nls33xOXOukwyhVMjd0Cg="
|
||||
# ANGULAR_PAYLOAD_FIREBASE_TOKEN
|
||||
# This is for payload size data to "angular-payload-size" firebase project
|
||||
# This token was generated using the payload@angular.io account using `firebase login:ci` and password from valentine
|
||||
- secure: "SxotP/ymNy6uWAVbfwM9BlwETPEBpkRvU/F7fCtQDDic99WfQHzzUSQqHTk8eKk3GrGAOSL09vT0WfStQYEIGEoS5UHWNgOnelxhw+d5EnaoB8vQ0dKQBTK092hQg4feFprr+B/tCasyMV6mVwpUzZMbIJNn/Rx7H5g1bp+Gkfg="
|
||||
matrix:
|
||||
# Order: a slower build first, so that we don't occupy an idle travis worker waiting for others to complete.
|
||||
- CI_MODE=e2e
|
||||
- CI_MODE=e2e_2
|
||||
- CI_MODE=js
|
||||
- CI_MODE=saucelabs_required
|
||||
- CI_MODE=browserstack_required
|
||||
@ -47,12 +55,15 @@ env:
|
||||
- CI_MODE=browserstack_optional
|
||||
- CI_MODE=docs_test
|
||||
- CI_MODE=aio
|
||||
- CI_MODE=aio_e2e
|
||||
- CI_MODE=bazel
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
allow_failures:
|
||||
- env: "CI_MODE=saucelabs_optional"
|
||||
- env: "CI_MODE=browserstack_optional"
|
||||
- env: "CI_MODE=aio_e2e"
|
||||
|
||||
before_install:
|
||||
# source the env.sh script so that the exported variables are available to other scripts later on
|
||||
|
25
BUILD.bazel
Normal file
25
BUILD.bazel
Normal file
@ -0,0 +1,25 @@
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
exports_files(["tsconfig.json"])
|
||||
|
||||
# This rule belongs in node_modules/BUILD
|
||||
# It's here as a workaround for
|
||||
# https://github.com/bazelbuild/bazel/issues/374#issuecomment-296217940
|
||||
filegroup(
|
||||
name = "node_modules",
|
||||
srcs = glob([
|
||||
# Performance workaround: list individual files
|
||||
# This won't scale in the general case.
|
||||
# TODO(alexeagle): figure out what to do
|
||||
"node_modules/typescript/**",
|
||||
"node_modules/zone.js/**",
|
||||
"node_modules/rxjs/**/*.d.ts",
|
||||
"node_modules/rxjs/**/*.js",
|
||||
"node_modules/@types/**/*.d.ts",
|
||||
"node_modules/tsickle/**",
|
||||
"node_modules/hammerjs/**/*.d.ts",
|
||||
"node_modules/protobufjs/**",
|
||||
"node_modules/bytebuffer/**",
|
||||
"node_modules/reflect-metadata/**",
|
||||
"node_modules/minimist/**/*.js",
|
||||
]),
|
||||
)
|
4821
CHANGELOG.md
4821
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -17,15 +17,15 @@ Help us keep Angular open and inclusive. Please read and follow our [Code of Con
|
||||
|
||||
## <a name="question"></a> Got a Question or Problem?
|
||||
|
||||
Please, do not open issues for the general support questions as we want to keep GitHub issues for bug reports and feature requests. You've got much better chances of getting your question answered on [StackOverflow](https://stackoverflow.com/questions/tagged/angular) where the questions should be tagged with tag `angular`.
|
||||
Do not open issues for general support questions as we want to keep GitHub issues for bug reports and feature requests. You've got much better chances of getting your question answered on [Stack Overflow](https://stackoverflow.com/questions/tagged/angular) where the questions should be tagged with tag `angular`.
|
||||
|
||||
StackOverflow is a much better place to ask questions since:
|
||||
Stack Overflow is a much better place to ask questions since:
|
||||
|
||||
- there are thousands of people willing to help on StackOverflow
|
||||
- there are thousands of people willing to help on Stack Overflow
|
||||
- questions and answers stay available for public viewing so your question / answer might help someone else
|
||||
- StackOverflow's voting system assures that the best answers are prominently visible.
|
||||
- Stack Overflow's voting system assures that the best answers are prominently visible.
|
||||
|
||||
To save your and our time we will be systematically closing all the issues that are requests for general support and redirecting people to StackOverflow.
|
||||
To save your and our time, we will systematically close all issues that are requests for general support and redirect people to Stack Overflow.
|
||||
|
||||
If you would like to chat about the question in real-time, you can reach out via [our gitter channel][gitter].
|
||||
|
||||
@ -147,7 +147,7 @@ To ensure consistency throughout the source code, keep these rules in mind as yo
|
||||
* All public API methods **must be documented**. (Details TBC).
|
||||
* We follow [Google's JavaScript Style Guide][js-style-guide], but wrap all code at
|
||||
**100 characters**. An automated formatter is available, see
|
||||
[DEVELOPER.md](DEVELOPER.md#clang-format).
|
||||
[DEVELOPER.md](docs/DEVELOPER.md#clang-format).
|
||||
|
||||
## <a name="commit"></a> Commit Message Guidelines
|
||||
|
||||
@ -198,8 +198,7 @@ Must be one of the following:
|
||||
* **fix**: A bug fix
|
||||
* **perf**: A code change that improves performance
|
||||
* **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing
|
||||
semi-colons, etc)
|
||||
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
|
||||
* **test**: Adding missing tests or correcting existing tests
|
||||
|
||||
### Scope
|
||||
@ -207,6 +206,7 @@ The scope should be the name of the npm package affected (as perceived by person
|
||||
|
||||
The following is the list of supported scopes:
|
||||
|
||||
* **animations**
|
||||
* **common**
|
||||
* **compiler**
|
||||
* **compiler-cli**
|
||||
@ -223,7 +223,7 @@ The following is the list of supported scopes:
|
||||
* **upgrade**
|
||||
* **tsc-wrapped**
|
||||
|
||||
There is currently few exception 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. 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
|
||||
@ -263,7 +263,7 @@ changes to be accepted, the CLA must be signed. It's a quick process, we promise
|
||||
[coc]: https://github.com/angular/code-of-conduct/blob/master/CODE_OF_CONDUCT.md
|
||||
[commit-message-format]: https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit#
|
||||
[corporate-cla]: http://code.google.com/legal/corporate-cla-v1.0.html
|
||||
[dev-doc]: https://github.com/angular/angular/blob/master/DEVELOPER.md
|
||||
[dev-doc]: https://github.com/angular/angular/blob/master/docs/DEVELOPER.md
|
||||
[github]: https://github.com/angular/angular
|
||||
[gitter]: https://gitter.im/angular/angular
|
||||
[individual-cla]: http://code.google.com/legal/individual-cla-v1.0.html
|
||||
|
11
README.md
11
README.md
@ -3,22 +3,21 @@
|
||||
[](https://gitter.im/angular/angular?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](http://issuestats.com/github/angular/angular)
|
||||
[](http://issuestats.com/github/angular/angular)
|
||||
[](https://badge.fury.io/js/%40angular%2Fcore)
|
||||
[](https://www.npmjs.com/@angular/core)
|
||||
|
||||
|
||||
[](https://saucelabs.com/u/angular2-ci)
|
||||
|
||||
*Safari (7+), iOS (7+), Edge (14) and IE mobile (11) are tested on [BrowserStack][browserstack].*
|
||||
|
||||
Angular
|
||||
=========
|
||||
|
||||
Angular is a development platform for building mobile and desktop web applications using Typescript/JavaScript (JS) and other languages.
|
||||
# Angular
|
||||
|
||||
Angular is a development platform for building mobile and desktop web applications using Typescript/JavaScript and other languages.
|
||||
|
||||
## Quickstart
|
||||
|
||||
[Get started in 5 minutes][quickstart].
|
||||
|
||||
|
||||
## Want to help?
|
||||
|
||||
Want to file a bug, contribute some code, or improve documentation? Excellent! Read up on our
|
||||
|
17
WORKSPACE
Normal file
17
WORKSPACE
Normal file
@ -0,0 +1,17 @@
|
||||
load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
|
||||
|
||||
git_repository(
|
||||
name = "build_bazel_rules_typescript",
|
||||
remote = "https://github.com/bazelbuild/rules_typescript.git",
|
||||
tag = "0.0.5",
|
||||
)
|
||||
|
||||
load("@build_bazel_rules_typescript//:defs.bzl", "node_repositories")
|
||||
|
||||
node_repositories(package_json = "//:package.json")
|
||||
|
||||
git_repository(
|
||||
name = "build_bazel_rules_angular",
|
||||
remote = "https://github.com/bazelbuild/rules_angular.git",
|
||||
tag = "0.0.1",
|
||||
)
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"project": {
|
||||
"version": "1.0.0-beta.32.3",
|
||||
"name": "site"
|
||||
},
|
||||
"apps": [
|
||||
@ -9,25 +9,29 @@
|
||||
"outDir": "dist",
|
||||
"assets": [
|
||||
"assets",
|
||||
"content",
|
||||
"generated",
|
||||
"app/search/search-worker.js",
|
||||
"favicon.ico"
|
||||
"favicon.ico",
|
||||
"pwa-manifest.json",
|
||||
"google385281288605d160.html"
|
||||
],
|
||||
"index": "index.html",
|
||||
"main": "main.ts",
|
||||
"polyfills": "polyfills.ts",
|
||||
"test": "test.ts",
|
||||
"tsconfig": "tsconfig.json",
|
||||
"tsconfig": "tsconfig.app.json",
|
||||
"testTsconfig": "tsconfig.spec.json",
|
||||
"prefix": "aio",
|
||||
"serviceWorker": false,
|
||||
"styles": [
|
||||
"styles.scss"
|
||||
],
|
||||
"scripts": [
|
||||
|
||||
],
|
||||
"environmentSource": "environments/environment.ts",
|
||||
"environments": {
|
||||
"dev": "environments/environment.ts",
|
||||
"stage": "environments/environment.stage.ts",
|
||||
"prod": "environments/environment.prod.ts"
|
||||
}
|
||||
}
|
||||
@ -39,12 +43,13 @@
|
||||
},
|
||||
"lint": [
|
||||
{
|
||||
"files": "src/**/*.ts",
|
||||
"project": "src/tsconfig.json"
|
||||
"project": "src/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"files": "e2e/**/*.ts",
|
||||
"project": "e2e/tsconfig.json"
|
||||
"project": "src/tsconfig.spec.json"
|
||||
},
|
||||
{
|
||||
"project": "e2e/tsconfig.e2e.json"
|
||||
}
|
||||
],
|
||||
"test": {
|
||||
@ -54,19 +59,8 @@
|
||||
},
|
||||
"defaults": {
|
||||
"styleExt": "scss",
|
||||
"component": {},
|
||||
"prefixInterfaces": false,
|
||||
"inline": {
|
||||
"style": false,
|
||||
"template": false
|
||||
},
|
||||
"spec": {
|
||||
"class": false,
|
||||
"component": true,
|
||||
"directive": true,
|
||||
"module": false,
|
||||
"pipe": true,
|
||||
"service": true
|
||||
"component": {
|
||||
"inlineStyle": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"projects": {
|
||||
"staging": "aio-staging"
|
||||
}
|
||||
}
|
@ -2,6 +2,8 @@
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/out-tsc
|
||||
/src/generated
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
@ -14,9 +16,10 @@
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
@ -25,18 +28,21 @@
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage/*
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
testem.log
|
||||
/typings
|
||||
yarn-error.log
|
||||
|
||||
# e2e
|
||||
/e2e/*.js
|
||||
/e2e/*.map
|
||||
protractor-results*.txt
|
||||
|
||||
#System Files
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
!src/styles.css
|
||||
# copied dependencies
|
||||
src/assets/js/lunr*
|
116
aio/README.md
116
aio/README.md
@ -1,31 +1,113 @@
|
||||
# Site
|
||||
# Angular documentation project (https://angular.io)
|
||||
|
||||
This project was generated with [angular-cli](https://github.com/angular/angular-cli) version 1.0.0-beta.26.
|
||||
Everything in this folder is part of the documentation project. This includes
|
||||
|
||||
## 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.
|
||||
* the web site for displaying the documentation
|
||||
* the dgeni configuration for converting source files to rendered files that can be viewed in the web site.
|
||||
* the tooling for setting up examples for development; and generating plunkers and zip files from the examples.
|
||||
|
||||
## Code scaffolding
|
||||
## Developer tasks
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive/pipe/service/class/module`.
|
||||
We use `yarn` to manage the dependencies and to run build tasks.
|
||||
You should run all these tasks from the `angular/aio` folder.
|
||||
Here are the most important tasks you might need to use:
|
||||
|
||||
## Build
|
||||
* `yarn` - install all the dependencies.
|
||||
* `yarn setup` - Install all the dependencies, boilerplate, plunkers, zips and runs dgeni on the docs.
|
||||
|
||||
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.
|
||||
* `yarn start` - run a development web server that watches the files; then builds the doc-viewer and reloads the page, as necessary.
|
||||
* `yarn serve-and-sync` - run both the `docs-watch` and `start` in the same console.
|
||||
* `yarn lint` - check that the doc-viewer code follows our style rules.
|
||||
* `yarn test` - watch all the source files, for the doc-viewer, and run all the unit tests when any change.
|
||||
* `yarn e2e` - run all the e2e tests for the doc-viewer.
|
||||
|
||||
## Running unit tests
|
||||
* `yarn docs` - generate all the docs from the source files.
|
||||
* `yarn docs-watch` - watch the Angular source and the docs files and run a short-circuited doc-gen for the docs that changed.
|
||||
* `yarn docs-lint` - check that the doc gen code follows our style rules.
|
||||
* `yarn docs-test` - run the unit tests for the doc generation code.
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
* `yarn boilerplate:add` - generate all the boilerplate code for the examples, so that they can be run locally.
|
||||
* `yarn boilerplate:remove` - remove all the boilerplate code that was added via `yarn boilerplate:add`.
|
||||
* `yarn generate-plunkers` - generate the plunker files that are used by the `live-example` tags in the docs.
|
||||
* `yarn generate-zips` - generate the zip files from the examples. Zip available via the `live-example` tags in the docs.
|
||||
|
||||
## Running end-to-end tests
|
||||
* `yarn example-e2e` - run all e2e tests for examples
|
||||
- `yarn example-e2e -- --setup` - force webdriver update & other setup, then run tests
|
||||
- `yarn example-e2e -- --filter=foo` - limit e2e tests to those containing the word "foo"
|
||||
|
||||
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`.
|
||||
* `yarn build-ie-polyfills` - generates a js file of polyfills that can be loaded in Internet Explorer.
|
||||
|
||||
## Deploying to GitHub Pages
|
||||
## Using ServiceWorker locally
|
||||
|
||||
Run `ng github-pages:deploy` to deploy to GitHub Pages.
|
||||
Since abb36e3cb, running `yarn start -- --prod` will no longer set up the ServiceWorker, which
|
||||
would require manually running `yarn sw-manifest` and `yarn sw-copy` (something that is not possible
|
||||
with webpack serving the files from memory).
|
||||
|
||||
## Further help
|
||||
If you want to test ServiceWorker locally, you can use `yarn build` and serve the files in `dist/`
|
||||
with `yarn http-server -- dist -p 4200`.
|
||||
|
||||
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).
|
||||
For more details see #16745.
|
||||
|
||||
|
||||
## Guide to authoring
|
||||
|
||||
There are two types of content in the documentatation:
|
||||
|
||||
* **API docs**: descriptions of the modules, classes, interfaces, decorators, etc that make up the Angular platform.
|
||||
API docs are generated directly from the source code.
|
||||
The source code is contained in TypeScript files, located in the `angular/packages` folder.
|
||||
Each API item may have a preceding comment, which contains JSDoc style tags and content.
|
||||
The content is written in markdown.
|
||||
|
||||
* **Other content**: guides, tutorials, and other marketing material.
|
||||
All other content is written using markdown in text files, located in the `angular/aio/content` folder.
|
||||
More specifically, there are sub-folders that contain particular types of content: guides, tutorial and marketing.
|
||||
|
||||
We use the [dgeni](https://github.com/angular/dgeni) tool to convert these files into docs that can be viewed in the doc-viewer.
|
||||
|
||||
The [Authors Style Guide](https://angular.io/guide/docs-style-guide) prescribes guidelines for
|
||||
writing guide pages, explains how to use the documentation classes and components, and how to markup sample source code to produce code snippets.
|
||||
|
||||
### Generating the complete docs
|
||||
|
||||
The main task for generating the docs is `yarn docs`. This will process all the source files (API and other),
|
||||
extracting the documentation and generating JSON files that can be consumed by the doc-viewer.
|
||||
|
||||
### Partial doc generation for editors
|
||||
|
||||
Full doc generation can take up to one minute. That's too slow for efficient document creation and editing.
|
||||
|
||||
You can make small changes in a smart editor that displays formatted markdown:
|
||||
>In VS Code, _Cmd-K, V_ opens markdown preview in side pane; _Cmd-B_ toggles left sidebar
|
||||
|
||||
You also want to see those changes displayed properly in the doc viewer
|
||||
with a quick, edit/view cycle time.
|
||||
|
||||
For this purpose, use the `yarn docs-watch` task, which watches for changes to source files and only
|
||||
re-processes the the files necessary to generate the docs that are related to the file that has changed.
|
||||
Since this task takes shortcuts, it is much faster (often less than 1 second) but it won't produce full
|
||||
fidelity content. For example, links to other docs and code examples may not render correctly. This is
|
||||
most particularly noticed in links to other docs and in the embedded examples, which may not always render
|
||||
correctly.
|
||||
|
||||
The general setup is as follows:
|
||||
|
||||
* Open a terminal, ensure the dependencies are installed; run an initial doc generation; then start the doc-viewer:
|
||||
|
||||
```bash
|
||||
yarn
|
||||
yarn docs
|
||||
yarn start
|
||||
```
|
||||
|
||||
* Open a second terminal and start watching the docs
|
||||
|
||||
```bash
|
||||
yarn docs-watch
|
||||
```
|
||||
|
||||
* Open a browser at https://localhost:4200/ and navigate to the document on which you want to work.
|
||||
You can automatically open the browser by using `yarn start -- -o` in the first terminal.
|
||||
|
||||
* Make changes to the page's associated doc or example files. Every time a file is saved, the doc will
|
||||
be regenerated, the app will rebuild and the page will reload.
|
||||
|
@ -19,8 +19,8 @@ ARG AIO_DOMAIN_NAME=ngbuilds.io
|
||||
ARG TEST_AIO_DOMAIN_NAME=$AIO_DOMAIN_NAME.localhost
|
||||
ARG AIO_GITHUB_ORGANIZATION=angular
|
||||
ARG TEST_AIO_GITHUB_ORGANIZATION=angular
|
||||
ARG AIO_GITHUB_TEAM_SLUGS=angular-core
|
||||
ARG TEST_AIO_GITHUB_TEAM_SLUGS=angular-core
|
||||
ARG AIO_GITHUB_TEAM_SLUGS=angular-core,aio-contributors
|
||||
ARG TEST_AIO_GITHUB_TEAM_SLUGS=angular-core,aio-contributors
|
||||
ARG AIO_NGINX_HOSTNAME=$AIO_DOMAIN_NAME
|
||||
ARG TEST_AIO_NGINX_HOSTNAME=$TEST_AIO_DOMAIN_NAME
|
||||
ARG AIO_NGINX_PORT_HTTP=80
|
||||
@ -29,6 +29,8 @@ ARG AIO_NGINX_PORT_HTTPS=443
|
||||
ARG TEST_AIO_NGINX_PORT_HTTPS=4433
|
||||
ARG AIO_REPO_SLUG=angular/angular
|
||||
ARG TEST_AIO_REPO_SLUG=test-repo/test-slug
|
||||
ARG AIO_TRUSTED_PR_LABEL="aio: preview"
|
||||
ARG TEST_AIO_TRUSTED_PR_LABEL="aio: preview"
|
||||
ARG AIO_UPLOAD_HOSTNAME=upload.localhost
|
||||
ARG TEST_AIO_UPLOAD_HOSTNAME=upload.localhost
|
||||
ARG AIO_UPLOAD_MAX_SIZE=20971520
|
||||
@ -42,14 +44,17 @@ ENV AIO_BUILDS_DIR=$AIO_BUILDS_DIR TEST_AIO_BUILDS_DIR=$TEST
|
||||
AIO_GITHUB_TEAM_SLUGS=$AIO_GITHUB_TEAM_SLUGS TEST_AIO_GITHUB_TEAM_SLUGS=$TEST_AIO_GITHUB_TEAM_SLUGS \
|
||||
AIO_LOCALCERTS_DIR=/etc/ssl/localcerts TEST_AIO_LOCALCERTS_DIR=/etc/ssl/localcerts-test \
|
||||
AIO_NGINX_HOSTNAME=$AIO_NGINX_HOSTNAME TEST_AIO_NGINX_HOSTNAME=$TEST_AIO_NGINX_HOSTNAME \
|
||||
AIO_NGINX_LOGS_DIR=/var/log/aio/nginx TEST_AIO_NGINX_LOGS_DIR=/var/log/aio/nginx-test \
|
||||
AIO_NGINX_PORT_HTTP=$AIO_NGINX_PORT_HTTP TEST_AIO_NGINX_PORT_HTTP=$TEST_AIO_NGINX_PORT_HTTP \
|
||||
AIO_NGINX_PORT_HTTPS=$AIO_NGINX_PORT_HTTPS TEST_AIO_NGINX_PORT_HTTPS=$TEST_AIO_NGINX_PORT_HTTPS \
|
||||
AIO_REPO_SLUG=$AIO_REPO_SLUG TEST_AIO_REPO_SLUG=$TEST_AIO_REPO_SLUG \
|
||||
AIO_SCRIPTS_JS_DIR=/usr/share/aio-scripts-js \
|
||||
AIO_SCRIPTS_SH_DIR=/usr/share/aio-scripts-sh \
|
||||
AIO_TRUSTED_PR_LABEL=$AIO_TRUSTED_PR_LABEL TEST_AIO_TRUSTED_PR_LABEL=$TEST_AIO_TRUSTED_PR_LABEL \
|
||||
AIO_UPLOAD_HOSTNAME=$AIO_UPLOAD_HOSTNAME TEST_AIO_UPLOAD_HOSTNAME=$TEST_AIO_UPLOAD_HOSTNAME \
|
||||
AIO_UPLOAD_MAX_SIZE=$AIO_UPLOAD_MAX_SIZE TEST_AIO_UPLOAD_MAX_SIZE=$TEST_AIO_UPLOAD_MAX_SIZE \
|
||||
AIO_UPLOAD_PORT=$AIO_UPLOAD_PORT TEST_AIO_UPLOAD_PORT=$TEST_AIO_UPLOAD_PORT \
|
||||
AIO_WWW_USER=www-data \
|
||||
NODE_ENV=production
|
||||
|
||||
|
||||
@ -62,6 +67,7 @@ RUN apt-get update -y && apt-get install -y curl
|
||||
RUN curl --silent --show-error --location https://deb.nodesource.com/setup_6.x | bash -
|
||||
RUN curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
|
||||
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
|
||||
RUN echo "deb http://ftp.debian.org/debian jessie-backports main" | tee /etc/apt/sources.list.d/backports.list
|
||||
|
||||
|
||||
# Install packages
|
||||
@ -70,26 +76,32 @@ RUN apt-get update -y && apt-get install -y \
|
||||
cron \
|
||||
dnsmasq \
|
||||
nano \
|
||||
nginx \
|
||||
nodejs \
|
||||
openssl \
|
||||
rsyslog \
|
||||
yarn
|
||||
RUN apt-get install -t jessie-backports -y nginx
|
||||
RUN yarn global add pm2@2
|
||||
|
||||
|
||||
# Set up log rotation
|
||||
COPY logrotate/* /etc/logrotate.d/
|
||||
RUN chmod 0644 /etc/logrotate.d/*
|
||||
|
||||
|
||||
# Set up cronjobs
|
||||
COPY cronjobs/aio-builds-cleanup /etc/cron.d/
|
||||
RUN chmod 0744 /etc/cron.d/aio-builds-cleanup
|
||||
RUN crontab /etc/cron.d/aio-builds-cleanup
|
||||
RUN printenv | grep AIO_ >> /etc/environment
|
||||
|
||||
|
||||
# Set up dnsmasq
|
||||
COPY dnsmasq/dnsmasq.conf /etc/
|
||||
RUN sed -i "s|{{\$AIO_NGINX_HOSTNAME}}|$AIO_NGINX_HOSTNAME|" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_HOSTNAME}}|$AIO_UPLOAD_HOSTNAME|" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$TEST_AIO_NGINX_HOSTNAME}}|$TEST_AIO_NGINX_HOSTNAME|" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$TEST_AIO_UPLOAD_HOSTNAME}}|$TEST_AIO_UPLOAD_HOSTNAME|" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$AIO_NGINX_HOSTNAME}}|$AIO_NGINX_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_HOSTNAME}}|$AIO_UPLOAD_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$TEST_AIO_NGINX_HOSTNAME}}|$TEST_AIO_NGINX_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
RUN sed -i "s|{{\$TEST_AIO_UPLOAD_HOSTNAME}}|$TEST_AIO_UPLOAD_HOSTNAME|g" /etc/dnsmasq.conf
|
||||
|
||||
|
||||
# Set up SSL/TLS certificates
|
||||
@ -102,29 +114,31 @@ RUN update-ca-certificates
|
||||
|
||||
|
||||
# Set up nginx (for production and testing)
|
||||
RUN rm /etc/nginx/sites-enabled/*
|
||||
RUN sed -i -E "s|^user\s+\S+;|user $AIO_WWW_USER;|" /etc/nginx/nginx.conf
|
||||
RUN rm -f /etc/nginx/conf.d/*
|
||||
RUN rm -f /etc/nginx/sites-enabled/*
|
||||
|
||||
COPY nginx/aio-builds.conf /etc/nginx/sites-available/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_BUILDS_DIR}}|$AIO_BUILDS_DIR|" /etc/nginx/sites-available/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_DOMAIN_NAME}}|$AIO_DOMAIN_NAME|" /etc/nginx/sites-available/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_LOCALCERTS_DIR}}|$AIO_LOCALCERTS_DIR|" /etc/nginx/sites-available/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_NGINX_PORT_HTTP}}|$AIO_NGINX_PORT_HTTP|" /etc/nginx/sites-available/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_NGINX_PORT_HTTPS}}|$AIO_NGINX_PORT_HTTPS|" /etc/nginx/sites-available/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_HOSTNAME}}|$AIO_UPLOAD_HOSTNAME|" /etc/nginx/sites-available/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_MAX_SIZE}}|$AIO_UPLOAD_MAX_SIZE|" /etc/nginx/sites-available/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_PORT}}|$AIO_UPLOAD_PORT|" /etc/nginx/sites-available/aio-builds-prod.conf
|
||||
RUN ln -s /etc/nginx/sites-available/aio-builds-prod.conf /etc/nginx/sites-enabled/aio-builds-prod.conf
|
||||
COPY nginx/aio-builds.conf /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_BUILDS_DIR}}|$AIO_BUILDS_DIR|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_DOMAIN_NAME}}|$AIO_DOMAIN_NAME|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_LOCALCERTS_DIR}}|$AIO_LOCALCERTS_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_HTTPS}}|$AIO_NGINX_PORT_HTTPS|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_HOSTNAME}}|$AIO_UPLOAD_HOSTNAME|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_MAX_SIZE}}|$AIO_UPLOAD_MAX_SIZE|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_PORT}}|$AIO_UPLOAD_PORT|g" /etc/nginx/conf.d/aio-builds-prod.conf
|
||||
|
||||
COPY nginx/aio-builds.conf /etc/nginx/sites-available/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_BUILDS_DIR}}|$TEST_AIO_BUILDS_DIR|" /etc/nginx/sites-available/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_DOMAIN_NAME}}|$TEST_AIO_DOMAIN_NAME|" /etc/nginx/sites-available/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_LOCALCERTS_DIR}}|$TEST_AIO_LOCALCERTS_DIR|" /etc/nginx/sites-available/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_NGINX_PORT_HTTP}}|$TEST_AIO_NGINX_PORT_HTTP|" /etc/nginx/sites-available/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_NGINX_PORT_HTTPS}}|$TEST_AIO_NGINX_PORT_HTTPS|" /etc/nginx/sites-available/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_HOSTNAME}}|$TEST_AIO_UPLOAD_HOSTNAME|" /etc/nginx/sites-available/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_MAX_SIZE}}|$TEST_AIO_UPLOAD_MAX_SIZE|" /etc/nginx/sites-available/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_PORT}}|$TEST_AIO_UPLOAD_PORT|" /etc/nginx/sites-available/aio-builds-test.conf
|
||||
RUN ln -s /etc/nginx/sites-available/aio-builds-test.conf /etc/nginx/sites-enabled/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_DOMAIN_NAME}}|$TEST_AIO_DOMAIN_NAME|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_LOCALCERTS_DIR}}|$TEST_AIO_LOCALCERTS_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_HTTPS}}|$TEST_AIO_NGINX_PORT_HTTPS|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_HOSTNAME}}|$TEST_AIO_UPLOAD_HOSTNAME|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_MAX_SIZE}}|$TEST_AIO_UPLOAD_MAX_SIZE|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
RUN sed -i "s|{{\$AIO_UPLOAD_PORT}}|$TEST_AIO_UPLOAD_PORT|g" /etc/nginx/conf.d/aio-builds-test.conf
|
||||
|
||||
|
||||
# Set up pm2
|
||||
|
@ -1,2 +1,2 @@
|
||||
# Periodically clean up builds that do not correspond to currently open PRs
|
||||
0 4 * * * root /usr/local/bin/aio-clean-up >> /var/log/cron.log 2>&1
|
||||
0 12 * * * root /usr/local/bin/aio-clean-up >> /var/log/cron.log 2>&1
|
||||
|
9
aio/aio-builds-setup/dockerbuild/logrotate/aio-misc
Normal file
9
aio/aio-builds-setup/dockerbuild/logrotate/aio-misc
Normal file
@ -0,0 +1,9 @@
|
||||
/var/log/aio/clean-up.log /var/log/aio/init.log /var/log/aio/verify-setup.log {
|
||||
compress
|
||||
create
|
||||
delaycompress
|
||||
missingok
|
||||
monthly
|
||||
notifempty
|
||||
rotate 6
|
||||
}
|
13
aio/aio-builds-setup/dockerbuild/logrotate/aio-nginx
Normal file
13
aio/aio-builds-setup/dockerbuild/logrotate/aio-nginx
Normal file
@ -0,0 +1,13 @@
|
||||
/var/log/aio/nginx/*.log /var/log/aio/nginx-test/*.log {
|
||||
compress
|
||||
create
|
||||
delaycompress
|
||||
missingok
|
||||
monthly
|
||||
notifempty
|
||||
rotate 6
|
||||
sharedscripts
|
||||
postrotate
|
||||
service nginx rotate >/dev/null 2>&1
|
||||
endscript
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
/var/log/aio/upload-server-*.log {
|
||||
compress
|
||||
copytruncate
|
||||
delaycompress
|
||||
missingok
|
||||
monthly
|
||||
notifempty
|
||||
rotate 6
|
||||
}
|
@ -1,44 +1,73 @@
|
||||
# Redirect all HTTP traffic to HTTPS
|
||||
server {
|
||||
server_name _;
|
||||
|
||||
listen {{$AIO_NGINX_PORT_HTTP}} default_server;
|
||||
listen [::]:{{$AIO_NGINX_PORT_HTTP}};
|
||||
|
||||
access_log {{$AIO_NGINX_LOGS_DIR}}/access.log;
|
||||
error_log {{$AIO_NGINX_LOGS_DIR}}/error.log;
|
||||
|
||||
# Ideally we want 308 (permanent + keep original method),
|
||||
# but it is relatively new and not supported by some clients (e.g. cURL).
|
||||
return 307 https://$host:{{$AIO_NGINX_PORT_HTTPS}}$request_uri;
|
||||
}
|
||||
|
||||
# Serve PR-preview requests
|
||||
server {
|
||||
server_name "~^pr(?<pr>[1-9][0-9]*)-(?<sha>[0-9a-f]{40})\.";
|
||||
server_name "~^pr(?<pr>[1-9][0-9]*)-(?<sha>[0-9a-f]{7,40})\.";
|
||||
|
||||
listen {{$AIO_NGINX_PORT_HTTP}};
|
||||
listen [::]:{{$AIO_NGINX_PORT_HTTP}};
|
||||
listen {{$AIO_NGINX_PORT_HTTPS}} ssl;
|
||||
listen [::]:{{$AIO_NGINX_PORT_HTTPS}} ssl;
|
||||
listen {{$AIO_NGINX_PORT_HTTPS}} ssl http2;
|
||||
listen [::]:{{$AIO_NGINX_PORT_HTTPS}} ssl http2;
|
||||
|
||||
ssl_certificate {{$AIO_LOCALCERTS_DIR}}/{{$AIO_DOMAIN_NAME}}.crt;
|
||||
ssl_certificate_key {{$AIO_LOCALCERTS_DIR}}/{{$AIO_DOMAIN_NAME}}.key;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
|
||||
root {{$AIO_BUILDS_DIR}}/$pr/$sha;
|
||||
disable_symlinks on from=$document_root;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
gzip on;
|
||||
gzip_comp_level 7;
|
||||
gzip_types *;
|
||||
|
||||
access_log {{$AIO_NGINX_LOGS_DIR}}/access.log;
|
||||
error_log {{$AIO_NGINX_LOGS_DIR}}/error.log;
|
||||
|
||||
location "~/[^/]+\.[^/]+$" {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html =404;
|
||||
}
|
||||
}
|
||||
|
||||
# Handle all other requests
|
||||
server {
|
||||
server_name _;
|
||||
|
||||
listen {{$AIO_NGINX_PORT_HTTP}} default_server;
|
||||
listen [::]:{{$AIO_NGINX_PORT_HTTP}};
|
||||
listen {{$AIO_NGINX_PORT_HTTPS}} ssl default_server;
|
||||
listen [::]:{{$AIO_NGINX_PORT_HTTPS}} ssl;
|
||||
listen {{$AIO_NGINX_PORT_HTTPS}} ssl http2 default_server;
|
||||
listen [::]:{{$AIO_NGINX_PORT_HTTPS}} ssl http2;
|
||||
|
||||
ssl_certificate {{$AIO_LOCALCERTS_DIR}}/{{$AIO_DOMAIN_NAME}}.crt;
|
||||
ssl_certificate_key {{$AIO_LOCALCERTS_DIR}}/{{$AIO_DOMAIN_NAME}}.key;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
|
||||
access_log {{$AIO_NGINX_LOGS_DIR}}/access.log;
|
||||
error_log {{$AIO_NGINX_LOGS_DIR}}/error.log;
|
||||
|
||||
# Health check
|
||||
location "~^\/health-check\/?$" {
|
||||
location "~^/health-check/?$" {
|
||||
add_header Content-Type text/plain;
|
||||
return 200 '';
|
||||
}
|
||||
|
||||
# Upload builds
|
||||
location "~^\/create-build\/(?<pr>[1-9][0-9]*)\/(?<sha>[0-9a-f]{40})\/?$" {
|
||||
location "~^/create-build/(?<pr>[1-9][0-9]*)/(?<sha>[0-9a-f]{40})/?$" {
|
||||
if ($request_method != "POST") {
|
||||
add_header Allow "POST";
|
||||
return 405;
|
||||
@ -59,6 +88,21 @@ server {
|
||||
resolver 127.0.0.1;
|
||||
}
|
||||
|
||||
# Notify about PR changes
|
||||
location "~^/pr-updated/?$" {
|
||||
if ($request_method != "POST") {
|
||||
add_header Allow "POST";
|
||||
return 405;
|
||||
}
|
||||
|
||||
proxy_pass_request_headers on;
|
||||
proxy_redirect off;
|
||||
proxy_method POST;
|
||||
proxy_pass http://{{$AIO_UPLOAD_HOSTNAME}}:{{$AIO_UPLOAD_PORT}}$request_uri;
|
||||
|
||||
resolver 127.0.0.1;
|
||||
}
|
||||
|
||||
# Everything else
|
||||
location / {
|
||||
return 404;
|
||||
|
@ -1 +1,2 @@
|
||||
/dist/
|
||||
/dist
|
||||
/node_modules
|
||||
|
@ -2,6 +2,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {HIDDEN_DIR_PREFIX} from '../common/constants';
|
||||
import {GithubPullRequests} from '../common/github-pull-requests';
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
|
||||
@ -31,6 +32,7 @@ export class BuildCleaner {
|
||||
}
|
||||
|
||||
const buildNumbers = files.
|
||||
map(name => name.replace(HIDDEN_DIR_PREFIX, '')). // Remove the "hidden dir" prefix
|
||||
map(Number). // Convert string to number
|
||||
filter(Boolean); // Ignore NaN (or 0), because they are not builds
|
||||
|
||||
@ -49,9 +51,11 @@ export class BuildCleaner {
|
||||
|
||||
protected removeDir(dir: string) {
|
||||
try {
|
||||
if (shell.test('-d', dir)) {
|
||||
// Undocumented signature (see https://github.com/shelljs/shelljs/pull/663).
|
||||
(shell as any).chmod('-R', 'a+w', dir);
|
||||
shell.rm('-rf', dir);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`ERROR: Unable to remove '${dir}' due to:`, err);
|
||||
}
|
||||
@ -64,8 +68,14 @@ export class BuildCleaner {
|
||||
console.log(`Open pull requests: ${openPrNumbers.length}`);
|
||||
console.log(`Removing ${toRemove.length} build(s): ${toRemove.join(', ')}`);
|
||||
|
||||
// Try removing public dirs.
|
||||
toRemove.
|
||||
map(num => path.join(this.buildsDir, String(num))).
|
||||
forEach(dir => this.removeDir(dir));
|
||||
|
||||
// Try removing hidden dirs.
|
||||
toRemove.
|
||||
map(num => path.join(this.buildsDir, HIDDEN_DIR_PREFIX + String(num))).
|
||||
forEach(dir => this.removeDir(dir));
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,8 @@ _main();
|
||||
|
||||
// Functions
|
||||
function _main() {
|
||||
console.log(`[${new Date()}] - Cleaning up builds...`);
|
||||
|
||||
const buildCleaner = new BuildCleaner(AIO_BUILDS_DIR, AIO_REPO_SLUG, AIO_GITHUB_TOKEN);
|
||||
|
||||
buildCleaner.cleanUp().catch(err => {
|
||||
|
@ -0,0 +1,3 @@
|
||||
// Constants
|
||||
export const HIDDEN_DIR_PREFIX = 'hidden--';
|
||||
export const SHORT_SHA_LEN = 7;
|
@ -63,7 +63,7 @@ export class GithubApi {
|
||||
return items;
|
||||
}
|
||||
|
||||
return this.getPaginated(pathname, baseParams, currentPage + 1).then(moreItems => [...items, ...moreItems]);
|
||||
return this.getPaginated<T>(pathname, baseParams, currentPage + 1).then(moreItems => [...items, ...moreItems]);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,7 @@ import {GithubApi} from './github-api';
|
||||
export interface PullRequest {
|
||||
number: number;
|
||||
user: {login: string};
|
||||
labels: {name: string}[];
|
||||
}
|
||||
|
||||
export type PullRequestState = 'all' | 'closed' | 'open';
|
||||
@ -30,7 +31,8 @@ export class GithubPullRequests extends GithubApi {
|
||||
}
|
||||
|
||||
public fetch(pr: number): Promise<PullRequest> {
|
||||
return this.get<PullRequest>(`/repos/${this.repoSlug}/pulls/${pr}`);
|
||||
// Using the `/issues/` URL, because the `/pulls/` one does not provide labels.
|
||||
return this.get<PullRequest>(`/repos/${this.repoSlug}/issues/${pr}`);
|
||||
}
|
||||
|
||||
public fetchAll(state: PullRequestState = 'all'): Promise<PullRequest[]> {
|
||||
|
@ -4,8 +4,9 @@ import {EventEmitter} from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {HIDDEN_DIR_PREFIX, SHORT_SHA_LEN} from '../common/constants';
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
import {CreatedBuildEvent} from './build-events';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from './build-events';
|
||||
import {UploadError} from './upload-error';
|
||||
|
||||
// Classes
|
||||
@ -17,16 +18,21 @@ export class BuildCreator extends EventEmitter {
|
||||
}
|
||||
|
||||
// Methods - Public
|
||||
public create(pr: string, sha: string, archivePath: string): Promise<any> {
|
||||
const prDir = path.join(this.buildsDir, pr);
|
||||
public create(pr: string, sha: string, archivePath: string, isPublic: boolean): Promise<void> {
|
||||
// Use only part of the SHA for more readable URLs.
|
||||
sha = sha.substr(0, SHORT_SHA_LEN);
|
||||
|
||||
const {newPrDir: prDir} = this.getCandidatePrDirs(pr, isPublic);
|
||||
const shaDir = path.join(prDir, sha);
|
||||
let dirToRemoveOnError: string;
|
||||
|
||||
return Promise.
|
||||
all([this.exists(prDir), this.exists(shaDir)]).
|
||||
return Promise.resolve().
|
||||
// If the same PR exists with different visibility, update the visibility first.
|
||||
then(() => this.updatePrVisibility(pr, isPublic)).
|
||||
then(() => Promise.all([this.exists(prDir), this.exists(shaDir)])).
|
||||
then(([prDirExisted, shaDirExisted]) => {
|
||||
if (shaDirExisted) {
|
||||
throw new UploadError(403, `Request to overwrite existing directory: ${shaDir}`);
|
||||
throw new UploadError(409, `Request to overwrite existing directory: ${shaDir}`);
|
||||
}
|
||||
|
||||
dirToRemoveOnError = prDirExisted ? shaDir : prDir;
|
||||
@ -34,7 +40,8 @@ export class BuildCreator extends EventEmitter {
|
||||
return Promise.resolve().
|
||||
then(() => shell.mkdir('-p', shaDir)).
|
||||
then(() => this.extractArchive(archivePath, shaDir)).
|
||||
then(() => this.emit(CreatedBuildEvent.type, new CreatedBuildEvent(+pr, sha)));
|
||||
then(() => this.emit(CreatedBuildEvent.type, new CreatedBuildEvent(+pr, sha, isPublic))).
|
||||
then(() => undefined);
|
||||
}).
|
||||
catch(err => {
|
||||
if (dirToRemoveOnError) {
|
||||
@ -49,6 +56,36 @@ export class BuildCreator extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
public updatePrVisibility(pr: string, makePublic: boolean): Promise<boolean> {
|
||||
const {oldPrDir: otherVisPrDir, newPrDir: targetVisPrDir} = this.getCandidatePrDirs(pr, makePublic);
|
||||
|
||||
return Promise.
|
||||
all([this.exists(otherVisPrDir), this.exists(targetVisPrDir)]).
|
||||
then(([otherVisPrDirExisted, targetVisPrDirExisted]) => {
|
||||
if (!otherVisPrDirExisted) {
|
||||
// No visibility change: Either the visibility is up-to-date or the PR does not exist.
|
||||
return false;
|
||||
} else if (targetVisPrDirExisted) {
|
||||
// Error: Directories for both visibilities exist.
|
||||
throw new UploadError(409, `Request to move '${otherVisPrDir}' to existing directory '${targetVisPrDir}'.`);
|
||||
}
|
||||
|
||||
// Visibility change: Moving `otherVisPrDir` to `targetVisPrDir`.
|
||||
return Promise.resolve().
|
||||
then(() => shell.mv(otherVisPrDir, targetVisPrDir)).
|
||||
then(() => this.listShasByDate(targetVisPrDir)).
|
||||
then(shas => this.emit(ChangedPrVisibilityEvent.type, new ChangedPrVisibilityEvent(+pr, shas, makePublic))).
|
||||
then(() => true);
|
||||
}).
|
||||
catch(err => {
|
||||
if (!(err instanceof UploadError)) {
|
||||
err = new UploadError(500, `Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\n${err}`);
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// Methods - Protected
|
||||
protected exists(fileOrDir: string): Promise<boolean> {
|
||||
return new Promise(resolve => fs.access(fileOrDir, err => resolve(!err)));
|
||||
@ -78,4 +115,26 @@ export class BuildCreator extends EventEmitter {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected getCandidatePrDirs(pr: string, isPublic: boolean) {
|
||||
const hiddenPrDir = path.join(this.buildsDir, HIDDEN_DIR_PREFIX + pr);
|
||||
const publicPrDir = path.join(this.buildsDir, pr);
|
||||
|
||||
const oldPrDir = isPublic ? hiddenPrDir : publicPrDir;
|
||||
const newPrDir = isPublic ? publicPrDir : hiddenPrDir;
|
||||
|
||||
return {oldPrDir, newPrDir};
|
||||
}
|
||||
|
||||
protected listShasByDate(inputDir: string): Promise<string[]> {
|
||||
return Promise.resolve().
|
||||
then(() => shell.ls('-l', inputDir) as any as Promise<(fs.Stats & {name: string})[]>).
|
||||
// Keep directories only.
|
||||
// (Also, convert to standard Array - ShellJS provides custom `sort()` method for sorting file contents.)
|
||||
then(items => items.filter(item => item.isDirectory())).
|
||||
// Sort by modification date.
|
||||
then(items => items.sort((a, b) => a.mtime.getTime() - b.mtime.getTime())).
|
||||
// Return directory names.
|
||||
then(items => items.map(item => item.name));
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,16 @@
|
||||
// Classes
|
||||
export class BuildEvent {
|
||||
export class ChangedPrVisibilityEvent {
|
||||
// Properties - Public, Static
|
||||
public static type = 'pr.changedVisibility';
|
||||
|
||||
// Constructor
|
||||
constructor(public type: string, public pr: number, public sha: string) {}
|
||||
constructor(public pr: number, public shas: string[], public isPublic: boolean) {}
|
||||
}
|
||||
|
||||
export class CreatedBuildEvent extends BuildEvent {
|
||||
export class CreatedBuildEvent {
|
||||
// Properties - Public, Static
|
||||
public static type = 'build.created';
|
||||
|
||||
// Constructor
|
||||
constructor(pr: number, sha: string) {
|
||||
super(CreatedBuildEvent.type, pr, sha);
|
||||
}
|
||||
constructor(public pr: number, public sha: string, public isPublic: boolean) {}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Imports
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import {GithubPullRequests} from '../common/github-pull-requests';
|
||||
import {GithubPullRequests, PullRequest} from '../common/github-pull-requests';
|
||||
import {GithubTeams} from '../common/github-teams';
|
||||
import {assertNotMissingOrEmpty} from '../common/utils';
|
||||
import {UploadError} from './upload-error';
|
||||
@ -11,6 +11,12 @@ interface JwtPayload {
|
||||
'pull-request': number;
|
||||
}
|
||||
|
||||
// Enums
|
||||
export enum BUILD_VERIFICATION_STATUS {
|
||||
verifiedAndTrusted,
|
||||
verifiedNotTrusted,
|
||||
}
|
||||
|
||||
// Classes
|
||||
export class BuildVerifier {
|
||||
// Properties - Protected
|
||||
@ -19,27 +25,27 @@ export class BuildVerifier {
|
||||
|
||||
// Constructor
|
||||
constructor(protected secret: string, githubToken: string, protected repoSlug: string, organization: string,
|
||||
protected allowedTeamSlugs: 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 getPrAuthorTeamMembership(pr: number): Promise<{author: string, isMember: boolean}> {
|
||||
public getPrIsTrusted(pr: number): Promise<boolean> {
|
||||
return Promise.resolve().
|
||||
then(() => this.githubPullRequests.fetch(pr)).
|
||||
then(prInfo => prInfo.user.login).
|
||||
then(author => this.githubTeams.isMemberBySlug(author, this.allowedTeamSlugs).
|
||||
then(isMember => ({author, isMember})));
|
||||
then(prInfo => this.hasLabel(prInfo, this.trustedPrLabel) ||
|
||||
this.githubTeams.isMemberBySlug(prInfo.user.login, this.allowedTeamSlugs));
|
||||
}
|
||||
|
||||
public verify(expectedPr: number, authHeader: string): Promise<void> {
|
||||
public verify(expectedPr: number, authHeader: string): Promise<BUILD_VERIFICATION_STATUS> {
|
||||
return Promise.resolve().
|
||||
then(() => this.extractJwtString(authHeader)).
|
||||
then(jwtString => this.verifyJwt(expectedPr, jwtString)).
|
||||
@ -52,9 +58,13 @@ export class BuildVerifier {
|
||||
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) => {
|
||||
jwt.verify(token, this.secret, {issuer: 'Travis CI, GmbH'}, (err, payload: JwtPayload) => {
|
||||
if (err) {
|
||||
reject(err.message || err);
|
||||
} else if (payload.slug !== this.repoSlug) {
|
||||
@ -68,11 +78,10 @@ export class BuildVerifier {
|
||||
});
|
||||
}
|
||||
|
||||
protected verifyPr(pr: number): Promise<void> {
|
||||
return this.getPrAuthorTeamMembership(pr).
|
||||
then(({author, isMember}) => isMember ? Promise.resolve() : Promise.reject(
|
||||
`User '${author}' is not an active member of any of the following teams: ` +
|
||||
`${this.allowedTeamSlugs.join(', ')}`,
|
||||
));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
@ -12,28 +12,28 @@ function _main() {
|
||||
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);
|
||||
const buildVerifier = new BuildVerifier(secret, githubToken, repoSlug, organization, allowedTeamSlugs,
|
||||
trustedPrLabel);
|
||||
|
||||
// Exit codes:
|
||||
// - 0: The PR author is a member.
|
||||
// - 1: The PR author is not a member.
|
||||
// - 2: An error occurred.
|
||||
buildVerifier.getPrAuthorTeamMembership(pr).
|
||||
then(({author, isMember}) => {
|
||||
if (isMember) {
|
||||
process.exit(0);
|
||||
} else {
|
||||
const errorMessage = `User '${author}' is not an active member of any of the following teams: ` +
|
||||
`${allowedTeamSlugs.join(', ')}`;
|
||||
onError(errorMessage, 1);
|
||||
// - 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(', ')}`);
|
||||
}
|
||||
}).
|
||||
catch(err => onError(err, 2));
|
||||
}
|
||||
|
||||
function onError(err: string, exitCode: number) {
|
||||
process.exit(isTrusted ? 0 : 2);
|
||||
}).
|
||||
catch(err => {
|
||||
console.error(err);
|
||||
process.exit(exitCode || 1);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
// Imports
|
||||
import {GithubPullRequests} from '../common/github-pull-requests';
|
||||
import {BuildVerifier} from './build-verifier';
|
||||
|
||||
// Run
|
||||
// TODO(gkalpak): Add e2e tests to cover these interactions as well.
|
||||
GithubPullRequests.prototype.addComment = () => Promise.resolve();
|
||||
BuildVerifier.prototype.verify = () => Promise.resolve();
|
||||
// tslint:disable-next-line: no-var-requires
|
||||
require('./index');
|
@ -1,6 +1,3 @@
|
||||
// TODO(gkalpak): Find more suitable way to run as `www-data`.
|
||||
process.setuid('www-data');
|
||||
|
||||
// Imports
|
||||
import {getEnvVar} from '../common/utils';
|
||||
import {uploadServerFactory} from './upload-server-factory';
|
||||
@ -13,10 +10,13 @@ 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');
|
||||
const AIO_WWW_USER = getEnvVar('AIO_WWW_USER');
|
||||
|
||||
// Run
|
||||
process.setuid(AIO_WWW_USER); // TODO(gkalpak): Find more suitable way to run as `www-data`.
|
||||
_main();
|
||||
|
||||
// Functions
|
||||
@ -30,6 +30,7 @@ function _main() {
|
||||
githubToken: AIO_GITHUB_TOKEN,
|
||||
repoSlug: AIO_REPO_SLUG,
|
||||
secret: AIO_PREVIEW_DEPLOYMENT_TOKEN,
|
||||
trustedPrLabel: AIO_TRUSTED_PR_LABEL,
|
||||
}).
|
||||
listen(AIO_UPLOAD_PORT, AIO_UPLOAD_HOSTNAME);
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
// 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 {CreatedBuildEvent} from './build-events';
|
||||
import {BuildVerifier} from './build-verifier';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from './build-events';
|
||||
import {BUILD_VERIFICATION_STATUS, BuildVerifier} from './build-verifier';
|
||||
import {UploadError} from './upload-error';
|
||||
|
||||
// Constants
|
||||
@ -21,6 +22,7 @@ interface UploadServerConfig {
|
||||
githubToken: string;
|
||||
repoSlug: string;
|
||||
secret: string;
|
||||
trustedPrLabel: string;
|
||||
}
|
||||
|
||||
// Classes
|
||||
@ -34,14 +36,16 @@ class UploadServerFactory {
|
||||
githubToken,
|
||||
repoSlug,
|
||||
secret,
|
||||
trustedPrLabel,
|
||||
}: UploadServerConfig): http.Server {
|
||||
assertNotMissingOrEmpty('domainName', domainName);
|
||||
|
||||
const buildVerifier = new BuildVerifier(secret, githubToken, repoSlug, githubOrganization, githubTeamSlugs);
|
||||
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);
|
||||
const httpServer = http.createServer(middleware as any);
|
||||
|
||||
httpServer.on('listening', () => {
|
||||
const info = httpServer.address();
|
||||
@ -56,12 +60,24 @@ class UploadServerFactory {
|
||||
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');
|
||||
|
||||
buildCreator.on(CreatedBuildEvent.type, ({pr, sha}: CreatedBuildEvent) => {
|
||||
const body = `The angular.io preview for ${sha.slice(0, 7)} is available [here][1].\n\n` +
|
||||
`[1]: https://pr${pr}-${sha}.${domainName}/`;
|
||||
return githubPullRequests.addComment(pr, body);
|
||||
};
|
||||
|
||||
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;
|
||||
@ -69,6 +85,7 @@ class UploadServerFactory {
|
||||
|
||||
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];
|
||||
@ -80,17 +97,33 @@ class UploadServerFactory {
|
||||
this.throwRequestError(401, `Missing or empty '${AUTHORIZATION_HEADER}' header`, req);
|
||||
} else if (!archive) {
|
||||
this.throwRequestError(400, `Missing or empty '${X_FILE_HEADER}' header`, req);
|
||||
}
|
||||
|
||||
buildVerifier.
|
||||
verify(+pr, authHeader).
|
||||
then(() => buildCreator.create(pr, sha, archive)).
|
||||
then(() => res.sendStatus(201)).
|
||||
} 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.get('*', req => this.throwRequestError(404, 'Unknown resource', req));
|
||||
middleware.all('*', req => this.throwRequestError(405, 'Unsupported method', req));
|
||||
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;
|
||||
@ -109,7 +142,10 @@ class UploadServerFactory {
|
||||
}
|
||||
|
||||
protected throwRequestError(status: number, error: string, req: express.Request) {
|
||||
throw new UploadError(status, `${error} in request: ${req.method} ${req.originalUrl}`);
|
||||
const message = `${error} in request: ${req.method} ${req.originalUrl}` +
|
||||
(!req.body ? '' : ` ${JSON.stringify(req.body)}`);
|
||||
|
||||
throw new UploadError(status, message);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,16 @@
|
||||
// Using the values below, we can fake the response of the corresponding methods in tests. This is
|
||||
// necessary, because the test upload-server will be running as a separate node process, so we will
|
||||
// not have direct access to the code (e.g. for mocking).
|
||||
// (See also 'lib/verify-setup/start-test-upload-server.ts'.)
|
||||
|
||||
/* tslint:disable: variable-name */
|
||||
|
||||
// Special values to be used as `authHeader` in `BuildVerifier#verify()`.
|
||||
export const BV_verify_error = 'FAKE_VERIFICATION_ERROR';
|
||||
export const BV_verify_verifiedNotTrusted = 'FAKE_VERIFIED_NOT_TRUSTED';
|
||||
|
||||
// Special values to be used as `pr` in `BuildVerifier#getPrIsTrusted()`.
|
||||
export const BV_getPrIsTrusted_error = 32203;
|
||||
export const BV_getPrIsTrusted_notTrusted = 72457;
|
||||
|
||||
/* tslint:enable: variable-name */
|
@ -4,10 +4,10 @@ import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {HIDDEN_DIR_PREFIX, SHORT_SHA_LEN} from '../common/constants';
|
||||
import {getEnvVar} from '../common/utils';
|
||||
|
||||
// Constans
|
||||
const SERVER_USER = 'www-data';
|
||||
const TEST_AIO_BUILDS_DIR = getEnvVar('TEST_AIO_BUILDS_DIR');
|
||||
const TEST_AIO_NGINX_HOSTNAME = getEnvVar('TEST_AIO_NGINX_HOSTNAME');
|
||||
const TEST_AIO_NGINX_PORT_HTTP = +getEnvVar('TEST_AIO_NGINX_PORT_HTTP');
|
||||
@ -15,6 +15,7 @@ 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
|
||||
export interface CmdResult { success: boolean; err: Error; stdout: string; stderr: string; }
|
||||
@ -31,10 +32,10 @@ class Helper {
|
||||
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 serverUser() { return SERVER_USER; }
|
||||
public get uploadHostname() { return TEST_AIO_UPLOAD_HOSTNAME; }
|
||||
public get uploadPort() { return TEST_AIO_UPLOAD_PORT; }
|
||||
public get uploadMaxSize() { return TEST_AIO_UPLOAD_MAX_SIZE; }
|
||||
public get wwwUser() { return WWW_USER; }
|
||||
|
||||
// Properties - Protected
|
||||
protected cleanUpFns: CleanUpFn[] = [];
|
||||
@ -46,10 +47,16 @@ class Helper {
|
||||
// Constructor
|
||||
constructor() {
|
||||
shell.mkdir('-p', this.buildsDir);
|
||||
shell.exec(`chown -R ${this.serverUser} ${this.buildsDir}`);
|
||||
shell.exec(`chown -R ${this.wwwUser} ${this.buildsDir}`);
|
||||
}
|
||||
|
||||
// Methods - Public
|
||||
public buildExists(pr: string, sha = '', isPublic = true, legacy = false): boolean {
|
||||
const prDir = this.getPrDir(pr, isPublic);
|
||||
const dir = !sha ? prDir : this.getShaDir(prDir, sha, legacy);
|
||||
return fs.existsSync(dir);
|
||||
}
|
||||
|
||||
public cleanUp() {
|
||||
while (this.cleanUpFns.length) {
|
||||
// Clean-up fns remove themselves from the list.
|
||||
@ -62,11 +69,11 @@ class Helper {
|
||||
}
|
||||
|
||||
public createDummyArchive(pr: string, sha: string, archivePath: string): CleanUpFn {
|
||||
const inputDir = path.join(this.buildsDir, 'uploaded', pr, sha);
|
||||
const inputDir = this.getShaDir(this.getPrDir(`uploaded/${pr}`, true), sha);
|
||||
const cmd1 = `tar --create --gzip --directory "${inputDir}" --file "${archivePath}" .`;
|
||||
const cmd2 = `chown ${this.serverUser} ${archivePath}`;
|
||||
const cmd2 = `chown ${this.wwwUser} ${archivePath}`;
|
||||
|
||||
const cleanUpTemp = this.createDummyBuild(`uploaded/${pr}`, sha, true);
|
||||
const cleanUpTemp = this.createDummyBuild(`uploaded/${pr}`, sha, true, true);
|
||||
shell.exec(cmd1);
|
||||
shell.exec(cmd2);
|
||||
cleanUpTemp();
|
||||
@ -74,21 +81,21 @@ class Helper {
|
||||
return this.createCleanUpFn(() => shell.rm('-rf', archivePath));
|
||||
}
|
||||
|
||||
public createDummyBuild(pr: string, sha: string, force = false): CleanUpFn {
|
||||
const prDir = path.join(this.buildsDir, pr);
|
||||
const shaDir = path.join(prDir, sha);
|
||||
public createDummyBuild(pr: string, sha: string, isPublic = true, force = false, legacy = false): CleanUpFn {
|
||||
const prDir = this.getPrDir(pr, isPublic);
|
||||
const shaDir = this.getShaDir(prDir, sha, legacy);
|
||||
const idxPath = path.join(shaDir, 'index.html');
|
||||
const barPath = path.join(shaDir, 'foo', 'bar.js');
|
||||
|
||||
this.writeFile(idxPath, {content: `PR: ${pr} | SHA: ${sha} | File: /index.html`}, force);
|
||||
this.writeFile(barPath, {content: `PR: ${pr} | SHA: ${sha} | File: /foo/bar.js`}, force);
|
||||
shell.exec(`chown -R ${this.serverUser} ${prDir}`);
|
||||
shell.exec(`chown -R ${this.wwwUser} ${prDir}`);
|
||||
|
||||
return this.createCleanUpFn(() => shell.rm('-rf', prDir));
|
||||
}
|
||||
|
||||
public deletePrDir(pr: string) {
|
||||
const prDir = path.join(this.buildsDir, pr);
|
||||
public deletePrDir(pr: string, isPublic = true) {
|
||||
const prDir = this.getPrDir(pr, isPublic);
|
||||
|
||||
if (fs.existsSync(prDir)) {
|
||||
// Undocumented signature (see https://github.com/shelljs/shelljs/pull/663).
|
||||
@ -97,8 +104,22 @@ class Helper {
|
||||
}
|
||||
}
|
||||
|
||||
public readBuildFile(pr: string, sha: string, relFilePath: string): string {
|
||||
const absFilePath = path.join(this.buildsDir, pr, sha, relFilePath);
|
||||
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 {
|
||||
return path.join(prDir, legacy ? sha : this.getShordSha(sha));
|
||||
}
|
||||
|
||||
public getShordSha(sha: string): string {
|
||||
return sha.substr(0, SHORT_SHA_LEN);
|
||||
}
|
||||
|
||||
public readBuildFile(pr: string, sha: string, relFilePath: string, isPublic = true, legacy = false): string {
|
||||
const shaDir = this.getShaDir(this.getPrDir(pr, isPublic), sha, legacy);
|
||||
const absFilePath = path.join(shaDir, relFilePath);
|
||||
return fs.readFileSync(absFilePath, 'utf8');
|
||||
}
|
||||
|
||||
@ -129,7 +150,8 @@ class Helper {
|
||||
const [headers, body] = result.stdout.
|
||||
split(/(?:\r?\n){2,}/).
|
||||
map(s => s.trim()).
|
||||
slice(-2);
|
||||
slice(-2); // In case of redirect, discard the previous headers.
|
||||
// Only keep the last to sections (final headers and body).
|
||||
|
||||
if (!result.success) {
|
||||
console.log('Stdout:', result.stdout);
|
||||
@ -143,8 +165,10 @@ class Helper {
|
||||
};
|
||||
}
|
||||
|
||||
public writeBuildFile(pr: string, sha: string, relFilePath: string, content: string): CleanUpFn {
|
||||
const absFilePath = path.join(this.buildsDir, pr, sha, relFilePath);
|
||||
public writeBuildFile(pr: string, sha: string, relFilePath: string, content: string, isPublic = true,
|
||||
legacy = false): CleanUpFn {
|
||||
const shaDir = this.getShaDir(this.getPrDir(pr, isPublic), sha, legacy);
|
||||
const absFilePath = path.join(shaDir, relFilePath);
|
||||
return this.writeFile(absFilePath, {content}, true);
|
||||
}
|
||||
|
||||
@ -166,7 +190,7 @@ class Helper {
|
||||
// Create a file with the specified content.
|
||||
fs.writeFileSync(filePath, content || '');
|
||||
}
|
||||
shell.exec(`chown ${this.serverUser} ${filePath}`);
|
||||
shell.exec(`chown ${this.wwwUser} ${filePath}`);
|
||||
|
||||
return this.createCleanUpFn(() => shell.rm('-rf', cleanUpTarget));
|
||||
}
|
||||
|
@ -3,19 +3,48 @@ import * as path from 'path';
|
||||
import {helper as h} from './helper';
|
||||
|
||||
// Tests
|
||||
h.runForAllSupportedSchemes((scheme, port) => describe(`nginx (on ${scheme.toUpperCase()})`, () => {
|
||||
const hostname = h.nginxHostname;
|
||||
const host = `${hostname}:${port}`;
|
||||
const pr = '9';
|
||||
const sha9 = '9'.repeat(40);
|
||||
const sha0 = '0'.repeat(40);
|
||||
describe(`nginx`, () => {
|
||||
|
||||
beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000);
|
||||
afterEach(() => h.cleanUp());
|
||||
|
||||
|
||||
it('should redirect HTTP to HTTPS', done => {
|
||||
const httpHost = `${h.nginxHostname}:${h.nginxPortHttp}`;
|
||||
const httpsHost = `${h.nginxHostname}:${h.nginxPortHttps}`;
|
||||
const urlMap = {
|
||||
[`http://${httpHost}/`]: `https://${httpsHost}/`,
|
||||
[`http://${httpHost}/foo`]: `https://${httpsHost}/foo`,
|
||||
[`http://foo.${httpHost}/`]: `https://foo.${httpsHost}/`,
|
||||
};
|
||||
|
||||
const verifyRedirection = (httpUrl: string) => h.runCmd(`curl -i ${httpUrl}`).then(result => {
|
||||
h.verifyResponse(307)(result);
|
||||
|
||||
const headers = result.stdout.split(/(?:\r?\n){2,}/)[0];
|
||||
expect(headers).toContain(`Location: ${urlMap[httpUrl]}`);
|
||||
});
|
||||
|
||||
Promise.
|
||||
all(Object.keys(urlMap).map(verifyRedirection)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
h.runForAllSupportedSchemes((scheme, port) => describe(`(on ${scheme.toUpperCase()})`, () => {
|
||||
const hostname = h.nginxHostname;
|
||||
const host = `${hostname}:${port}`;
|
||||
const pr = '9';
|
||||
const sha9 = '9'.repeat(40);
|
||||
const sha0 = '0'.repeat(40);
|
||||
const shortSha9 = h.getShordSha(sha9);
|
||||
const shortSha0 = h.getShordSha(sha0);
|
||||
|
||||
|
||||
describe(`pr<pr>-<sha>.${host}/*`, () => {
|
||||
|
||||
describe('(for public builds)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
h.createDummyBuild(pr, sha9);
|
||||
h.createDummyBuild(pr, sha0);
|
||||
@ -23,87 +52,168 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`nginx (on ${scheme.toUpp
|
||||
|
||||
|
||||
it('should return /index.html', done => {
|
||||
const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
|
||||
const bodyegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /index\\.html$`);
|
||||
const origin = `${scheme}://pr${pr}-${shortSha9}.${host}`;
|
||||
const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /index\\.html$`);
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${origin}/index.html`).then(h.verifyResponse(200, bodyegex)),
|
||||
h.runCmd(`curl -iL ${origin}/`).then(h.verifyResponse(200, bodyegex)),
|
||||
h.runCmd(`curl -iL ${origin}`).then(h.verifyResponse(200, bodyegex)),
|
||||
h.runCmd(`curl -iL ${origin}/index.html`).then(h.verifyResponse(200, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${origin}/`).then(h.verifyResponse(200, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${origin}`).then(h.verifyResponse(200, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should return /index.html (for legacy builds)', done => {
|
||||
const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
|
||||
const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /index\\.html$`);
|
||||
|
||||
h.createDummyBuild(pr, sha9, true, false, true);
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${origin}/index.html`).then(h.verifyResponse(200, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${origin}/`).then(h.verifyResponse(200, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${origin}`).then(h.verifyResponse(200, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should return /foo/bar.js', done => {
|
||||
const bodyegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /foo/bar\\.js$`);
|
||||
const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /foo/bar\\.js$`);
|
||||
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${sha9}.${host}/foo/bar.js`).
|
||||
then(h.verifyResponse(200, bodyegex)).
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${shortSha9}.${host}/foo/bar.js`).
|
||||
then(h.verifyResponse(200, bodyRegex)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should return /foo/bar.js (for legacy builds)', done => {
|
||||
const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
|
||||
const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /foo/bar\\.js$`);
|
||||
|
||||
h.createDummyBuild(pr, sha9, true, false, true);
|
||||
|
||||
h.runCmd(`curl -iL ${origin}/foo/bar.js`).
|
||||
then(h.verifyResponse(200, bodyRegex)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 403 for directories', done => {
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${sha9}.${host}/foo/`).then(h.verifyResponse(403)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${sha9}.${host}/foo`).then(h.verifyResponse(403)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${shortSha9}.${host}/foo/`).then(h.verifyResponse(403)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${shortSha9}.${host}/foo`).then(h.verifyResponse(403)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown paths', done => {
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${sha9}.${host}/foo/baz.css`).
|
||||
it('should respond with 404 for unknown paths to files', done => {
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${shortSha9}.${host}/foo/baz.css`).
|
||||
then(h.verifyResponse(404)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown PRs/SHAs', done => {
|
||||
const otherPr = 54321;
|
||||
const otherSha = '8'.repeat(40);
|
||||
it('should rewrite to \'index.html\' for unknown paths that don\'t look like files', done => {
|
||||
const origin = `${scheme}://pr${pr}-${shortSha9}.${host}`;
|
||||
const bodyRegex = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /index\\.html$`);
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}9-${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${otherPr}-${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${sha9}9.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${otherSha}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${origin}/foo/baz`).then(h.verifyResponse(200, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${origin}/foo/baz/`).then(h.verifyResponse(200, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown PRs/SHAs', done => {
|
||||
const otherPr = 54321;
|
||||
const otherShortSha = h.getShordSha('8'.repeat(40));
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}9-${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${otherPr}-${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${shortSha9}9.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${otherShortSha}.${host}`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 if the subdomain format is wrong', done => {
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${scheme}://xpr${pr}-${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://prx${pr}-${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://xx${pr}-${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://p${pr}-${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://r${pr}-${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://${pr}-${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}_${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://xpr${pr}-${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://prx${pr}-${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://xx${pr}-${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://p${pr}-${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://r${pr}-${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://${pr}-${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}_${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject PRs with leading zeros', done => {
|
||||
h.runCmd(`curl -iL ${scheme}://pr0${pr}-${sha9}.${host}`).
|
||||
h.runCmd(`curl -iL ${scheme}://pr0${pr}-${shortSha9}.${host}`).
|
||||
then(h.verifyResponse(404)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should accept SHAs with leading zeros (but not ignore them)', done => {
|
||||
const bodyegex = new RegExp(`^PR: ${pr} | SHA: ${sha0} | File: /index\\.html$`);
|
||||
it('should accept SHAs with leading zeros (but not trim the zeros)', done => {
|
||||
const bodyRegex9 = new RegExp(`^PR: ${pr} | SHA: ${sha9} | File: /index\\.html$`);
|
||||
const bodyRegex0 = new RegExp(`^PR: ${pr} | SHA: ${sha0} | File: /index\\.html$`);
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-0${sha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${sha0}.${host}`).then(h.verifyResponse(200, bodyegex)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-0${shortSha9}.${host}`).then(h.verifyResponse(404)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${shortSha9}.${host}`).then(h.verifyResponse(200, bodyRegex9)),
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${shortSha0}.${host}`).then(h.verifyResponse(200, bodyRegex0)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('(for hidden builds)', () => {
|
||||
|
||||
it('should respond with 404 for any file or directory', done => {
|
||||
const origin = `${scheme}://pr${pr}-${shortSha9}.${host}`;
|
||||
const assert404 = h.verifyResponse(404);
|
||||
|
||||
h.createDummyBuild(pr, sha9, false);
|
||||
expect(h.buildExists(pr, sha9, false)).toBe(true);
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${origin}/index.html`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo/bar.js`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo/`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo`).then(assert404),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for any file or directory (for legacy builds)', done => {
|
||||
const origin = `${scheme}://pr${pr}-${sha9}.${host}`;
|
||||
const assert404 = h.verifyResponse(404);
|
||||
|
||||
h.createDummyBuild(pr, sha9, false, false, true);
|
||||
expect(h.buildExists(pr, sha9, false, true)).toBe(true);
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${origin}/index.html`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo/bar.js`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo/`).then(assert404),
|
||||
h.runCmd(`curl -iL ${origin}/foo`).then(assert404),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/health-check`, () => {
|
||||
|
||||
it('should respond with 200', done => {
|
||||
@ -194,7 +304,7 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`nginx (on ${scheme.toUpp
|
||||
});
|
||||
|
||||
|
||||
it('should accept SHAs with leading zeros (but not ignore them)', 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/;
|
||||
|
||||
@ -207,9 +317,54 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`nginx (on ${scheme.toUpp
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/pr-updated`, () => {
|
||||
const url = `${scheme}://${host}/pr-updated`;
|
||||
|
||||
|
||||
it('should disallow non-POST requests', done => {
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iLX GET ${url}`).then(h.verifyResponse([405, 'Not Allowed'])),
|
||||
h.runCmd(`curl -iLX PUT ${url}`).then(h.verifyResponse([405, 'Not Allowed'])),
|
||||
h.runCmd(`curl -iLX PATCH ${url}`).then(h.verifyResponse([405, 'Not Allowed'])),
|
||||
h.runCmd(`curl -iLX DELETE ${url}`).then(h.verifyResponse([405, 'Not Allowed'])),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should pass requests through to the upload server', done => {
|
||||
const cmdPrefix = `curl -iLX POST --header "Content-Type: application/json"`;
|
||||
|
||||
const cmd1 = `${cmdPrefix} ${url}`;
|
||||
const cmd2 = `${cmdPrefix} --data '{"number":${pr}}' ${url}`;
|
||||
const cmd3 = `${cmdPrefix} --data '{"number":${pr},"action":"foo"}' ${url}`;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(cmd1).then(h.verifyResponse(400, /Missing or empty 'number' field/)),
|
||||
h.runCmd(cmd2).then(h.verifyResponse(200)),
|
||||
h.runCmd(cmd3).then(h.verifyResponse(200)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 404 for unknown paths', done => {
|
||||
const cmdPrefix = `curl -iLX POST ${scheme}://${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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe(`${host}/*`, () => {
|
||||
|
||||
it('should respond with 404 for unkown URLs (even if the resource exists)', done => {
|
||||
it('should respond with 404 for unknown URLs (even if the resource exists)', done => {
|
||||
['index.html', 'foo.js', 'foo/index.html'].forEach(relFilePath => {
|
||||
const absFilePath = path.join(h.buildsDir, relFilePath);
|
||||
h.writeFile(absFilePath, {content: `File: /${relFilePath}`});
|
||||
@ -229,4 +384,6 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`nginx (on ${scheme.toUpp
|
||||
|
||||
});
|
||||
|
||||
}));
|
||||
}));
|
||||
|
||||
});
|
||||
|
@ -1,5 +1,6 @@
|
||||
// Imports
|
||||
import * as path from 'path';
|
||||
import * as c from './constants';
|
||||
import {helper as h} from './helper';
|
||||
|
||||
// Tests
|
||||
@ -12,20 +13,28 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme
|
||||
const archivePath = path.join(h.buildsDir, 'snapshot.tar.gz');
|
||||
|
||||
const getFile = (pr: string, sha: string, file: string) =>
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${sha}.${host}/${file}`);
|
||||
const uploadBuild = (pr: string, sha: string, archive: string) => {
|
||||
const curlPost = 'curl -iLX POST --header "Authorization: Token FOO"';
|
||||
h.runCmd(`curl -iL ${scheme}://pr${pr}-${h.getShordSha(sha)}.${host}/${file}`);
|
||||
const uploadBuild = (pr: string, sha: string, archive: string, authHeader = 'Token FOO') => {
|
||||
const curlPost = `curl -iLX POST --header "Authorization: ${authHeader}"`;
|
||||
return h.runCmd(`${curlPost} --data-binary "@${archive}" ${scheme}://${host}/create-build/${pr}/${sha}`);
|
||||
};
|
||||
const prUpdated = (pr: number, action?: string) => {
|
||||
const url = `${scheme}://${host}/pr-updated`;
|
||||
const payloadStr = JSON.stringify({number: pr, action});
|
||||
return h.runCmd(`curl -iLX POST --header "Content-Type: application/json" --data '${payloadStr}' ${url}`);
|
||||
};
|
||||
|
||||
beforeEach(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000);
|
||||
afterEach(() => {
|
||||
h.deletePrDir(pr9);
|
||||
h.deletePrDir(pr9, false);
|
||||
h.cleanUp();
|
||||
});
|
||||
|
||||
|
||||
it('should be able to upload and serve a build for a new PR', done => {
|
||||
describe('for a new/non-existing PR', () => {
|
||||
|
||||
it('should be able to upload and serve a public build', done => {
|
||||
const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
@ -41,7 +50,60 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme
|
||||
});
|
||||
|
||||
|
||||
it('should be able to upload and serve a build for an existing PR', done => {
|
||||
it('should be able to upload but not serve a hidden build', done => {
|
||||
const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath, c.BV_verify_verifiedNotTrusted).
|
||||
then(() => Promise.all([
|
||||
getFile(pr9, sha9, 'index.html').then(h.verifyResponse(404)),
|
||||
getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(404)),
|
||||
])).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr9, sha9)).toBe(false);
|
||||
expect(h.buildExists(pr9, sha9, false)).toBe(true);
|
||||
expect(h.readBuildFile(pr9, sha9, 'index.html', false)).toMatch(idxContentRegex9);
|
||||
expect(h.readBuildFile(pr9, sha9, 'foo/bar.js', false)).toMatch(barContentRegex9);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject an upload if verification fails', done => {
|
||||
const errorRegex9 = new RegExp(`Error while verifying upload for PR ${pr9}: Test`);
|
||||
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath, c.BV_verify_error).
|
||||
then(h.verifyResponse(403, errorRegex9)).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr9)).toBe(false);
|
||||
expect(h.buildExists(pr9, '', false)).toBe(false);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should be able to notify that a PR has been updated (and do nothing)', done => {
|
||||
prUpdated(+pr9).
|
||||
then(h.verifyResponse(200)).
|
||||
then(() => {
|
||||
// The PR should still not exist.
|
||||
expect(h.buildExists(pr9, '', false)).toBe(false);
|
||||
expect(h.buildExists(pr9, '', true)).toBe(false);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('for an existing PR', () => {
|
||||
|
||||
it('should be able to upload and serve a public build', done => {
|
||||
const regexPrefix0 = `^PR: ${pr9} \\| SHA: ${sha0} \\| File:`;
|
||||
const idxContentRegex0 = new RegExp(`${regexPrefix0} \\/index\\.html$`);
|
||||
const barContentRegex0 = new RegExp(`${regexPrefix0} \\/foo\\/bar\\.js$`);
|
||||
@ -64,7 +126,56 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme
|
||||
});
|
||||
|
||||
|
||||
it('should not be able to overwrite a build', done => {
|
||||
it('should be able to upload but not serve a hidden build', done => {
|
||||
const regexPrefix0 = `^PR: ${pr9} \\| SHA: ${sha0} \\| File:`;
|
||||
const idxContentRegex0 = new RegExp(`${regexPrefix0} \\/index\\.html$`);
|
||||
const barContentRegex0 = new RegExp(`${regexPrefix0} \\/foo\\/bar\\.js$`);
|
||||
|
||||
const regexPrefix9 = `^PR: uploaded\\/${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
|
||||
h.createDummyBuild(pr9, sha0, false);
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath, c.BV_verify_verifiedNotTrusted).
|
||||
then(() => Promise.all([
|
||||
getFile(pr9, sha0, 'index.html').then(h.verifyResponse(404)),
|
||||
getFile(pr9, sha0, 'foo/bar.js').then(h.verifyResponse(404)),
|
||||
getFile(pr9, sha9, 'index.html').then(h.verifyResponse(404)),
|
||||
getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(404)),
|
||||
])).
|
||||
then(() => {
|
||||
expect(h.buildExists(pr9, sha9)).toBe(false);
|
||||
expect(h.buildExists(pr9, sha9, false)).toBe(true);
|
||||
expect(h.readBuildFile(pr9, sha0, 'index.html', false)).toMatch(idxContentRegex0);
|
||||
expect(h.readBuildFile(pr9, sha0, 'foo/bar.js', false)).toMatch(barContentRegex0);
|
||||
expect(h.readBuildFile(pr9, sha9, 'index.html', false)).toMatch(idxContentRegex9);
|
||||
expect(h.readBuildFile(pr9, sha9, 'foo/bar.js', false)).toMatch(barContentRegex9);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject an upload if verification fails', done => {
|
||||
const errorRegex9 = new RegExp(`Error while verifying upload for PR ${pr9}: Test`);
|
||||
|
||||
h.createDummyBuild(pr9, sha0);
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
|
||||
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);
|
||||
|
||||
});
|
||||
|
||||
|
||||
it('should not be able to overwrite an existing public build', done => {
|
||||
const regexPrefix9 = `^PR: ${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
@ -73,7 +184,7 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath).
|
||||
then(h.verifyResponse(403)).
|
||||
then(h.verifyResponse(409)).
|
||||
then(() => Promise.all([
|
||||
getFile(pr9, sha9, 'index.html').then(h.verifyResponse(200, idxContentRegex9)),
|
||||
getFile(pr9, sha9, 'foo/bar.js').then(h.verifyResponse(200, barContentRegex9)),
|
||||
@ -81,4 +192,128 @@ h.runForAllSupportedSchemes((scheme, port) => describe(`integration (on ${scheme
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should not be able to overwrite an existing hidden build', done => {
|
||||
const regexPrefix9 = `^PR: ${pr9} \\| SHA: ${sha9} \\| File:`;
|
||||
const idxContentRegex9 = new RegExp(`${regexPrefix9} \\/index\\.html$`);
|
||||
const barContentRegex9 = new RegExp(`${regexPrefix9} \\/foo\\/bar\\.js$`);
|
||||
|
||||
h.createDummyBuild(pr9, sha9, false);
|
||||
h.createDummyArchive(pr9, sha9, archivePath);
|
||||
|
||||
uploadBuild(pr9, sha9, archivePath, c.BV_verify_verifiedNotTrusted).
|
||||
then(h.verifyResponse(409)).
|
||||
then(() => {
|
||||
expect(h.readBuildFile(pr9, sha9, 'index.html', false)).toMatch(idxContentRegex9);
|
||||
expect(h.readBuildFile(pr9, sha9, 'foo/bar.js', false)).toMatch(barContentRegex9);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should be able to request re-checking visibility (if outdated)', done => {
|
||||
const publicPr = pr9;
|
||||
const hiddenPr = String(c.BV_getPrIsTrusted_notTrusted);
|
||||
|
||||
h.createDummyBuild(publicPr, sha9, false);
|
||||
h.createDummyBuild(hiddenPr, sha9, true);
|
||||
|
||||
// PR visibilities are outdated (i.e. the opposte of what the should).
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(false);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(true);
|
||||
|
||||
Promise.
|
||||
all([
|
||||
prUpdated(+publicPr).then(h.verifyResponse(200)),
|
||||
prUpdated(+hiddenPr).then(h.verifyResponse(200)),
|
||||
]).
|
||||
then(() => {
|
||||
// PR visibilities should have been updated.
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(false);
|
||||
}).
|
||||
then(() => {
|
||||
h.deletePrDir(publicPr, true);
|
||||
h.deletePrDir(hiddenPr, false);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should be able to request re-checking visibility (if up-to-date)', done => {
|
||||
const publicPr = pr9;
|
||||
const hiddenPr = String(c.BV_getPrIsTrusted_notTrusted);
|
||||
|
||||
h.createDummyBuild(publicPr, sha9, true);
|
||||
h.createDummyBuild(hiddenPr, sha9, false);
|
||||
|
||||
// PR visibilities are already up-to-date.
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(false);
|
||||
|
||||
Promise.
|
||||
all([
|
||||
prUpdated(+publicPr).then(h.verifyResponse(200)),
|
||||
prUpdated(+hiddenPr).then(h.verifyResponse(200)),
|
||||
]).
|
||||
then(() => {
|
||||
// PR visibilities are still up-to-date.
|
||||
expect(h.buildExists(publicPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(publicPr, '', true)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', false)).toBe(true);
|
||||
expect(h.buildExists(hiddenPr, '', true)).toBe(false);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject a request if re-checking visibility fails', done => {
|
||||
const errorPr = String(c.BV_getPrIsTrusted_error);
|
||||
|
||||
h.createDummyBuild(errorPr, sha9, true);
|
||||
|
||||
expect(h.buildExists(errorPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(errorPr, '', true)).toBe(true);
|
||||
|
||||
prUpdated(+errorPr).
|
||||
then(h.verifyResponse(500, /Test/)).
|
||||
then(() => {
|
||||
// PR visibility should not have been updated.
|
||||
expect(h.buildExists(errorPr, '', false)).toBe(false);
|
||||
expect(h.buildExists(errorPr, '', true)).toBe(true);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
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.
|
||||
h.createDummyBuild(pr9, sha9, false);
|
||||
h.createDummyBuild(pr9, sha9, true);
|
||||
|
||||
const hiddenPrDir = h.getPrDir(pr9, false);
|
||||
const publicPrDir = h.getPrDir(pr9, true);
|
||||
const bodyRegex = new RegExp(`Request to move '${hiddenPrDir}' to existing directory '${publicPrDir}'`);
|
||||
|
||||
expect(h.buildExists(pr9, '', false)).toBe(true);
|
||||
expect(h.buildExists(pr9, '', true)).toBe(true);
|
||||
|
||||
prUpdated(+pr9).
|
||||
then(h.verifyResponse(409, bodyRegex)).
|
||||
then(() => {
|
||||
// PR visibility should not have been updated.
|
||||
expect(h.buildExists(pr9, '', false)).toBe(true);
|
||||
expect(h.buildExists(pr9, '', true)).toBe(true);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
}));
|
||||
|
@ -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');
|
@ -1,6 +1,7 @@
|
||||
// Imports
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as c from './constants';
|
||||
import {CmdResult, helper as h} from './helper';
|
||||
|
||||
// Tests
|
||||
@ -19,18 +20,19 @@ describe('upload-server (on HTTP)', () => {
|
||||
describe(`${host}/create-build/<pr>/<sha>`, () => {
|
||||
const authorizationHeader = `--header "Authorization: Token FOO"`;
|
||||
const xFileHeader = `--header "X-File: ${h.buildsDir}/snapshot.tar.gz"`;
|
||||
const curl = `curl -iL ${authorizationHeader} ${xFileHeader}`;
|
||||
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 = /^Unsupported method/;
|
||||
const bodyRegex = /^Unknown resource/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iLX PUT ${url}`).then(h.verifyResponse(405, bodyRegex)),
|
||||
h.runCmd(`curl -iLX POST ${url}`).then(h.verifyResponse(405, bodyRegex)),
|
||||
h.runCmd(`curl -iLX PATCH ${url}`).then(h.verifyResponse(405, bodyRegex)),
|
||||
h.runCmd(`curl -iLX DELETE ${url}`).then(h.verifyResponse(405, bodyRegex)),
|
||||
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);
|
||||
});
|
||||
|
||||
@ -42,8 +44,8 @@ describe('upload-server (on HTTP)', () => {
|
||||
const bodyRegex = /^Missing or empty 'AUTHORIZATION' header/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${headers1} ${url}`).then(h.verifyResponse(401, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${headers2} ${url}`).then(h.verifyResponse(401, bodyRegex)),
|
||||
h.runCmd(curl(url, headers1)).then(h.verifyResponse(401, bodyRegex)),
|
||||
h.runCmd(curl(url, headers2)).then(h.verifyResponse(401, bodyRegex)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
@ -55,14 +57,25 @@ describe('upload-server (on HTTP)', () => {
|
||||
const bodyRegex = /^Missing or empty 'X-FILE' header/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iL ${headers1} ${url}`).then(h.verifyResponse(400, bodyRegex)),
|
||||
h.runCmd(`curl -iL ${headers2} ${url}`).then(h.verifyResponse(400, bodyRegex)),
|
||||
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}`;
|
||||
const cmdPrefix = curl(`http://${host}`);
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`${cmdPrefix}/foo/create-build/${pr}/${sha9}`).then(h.verifyResponse(404)),
|
||||
@ -78,55 +91,78 @@ describe('upload-server (on HTTP)', () => {
|
||||
|
||||
|
||||
it('should reject PRs with leading zeros', done => {
|
||||
h.runCmd(`${curl} http://${host}/create-build/0${pr}/${sha9}`).
|
||||
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 ignore them)', 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}/${sha0}`).then(h.verifyResponse(500)),
|
||||
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}`);
|
||||
|
||||
|
||||
it('should not overwrite existing builds', done => {
|
||||
h.createDummyBuild(pr, sha9);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html')).toContain('index.html');
|
||||
h.createDummyBuild(pr, sha9, isPublic);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toContain('index.html');
|
||||
|
||||
h.writeBuildFile(pr, sha9, 'index.html', 'My content');
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html')).toBe('My content');
|
||||
h.writeBuildFile(pr, sha9, 'index.html', 'My content', isPublic);
|
||||
expect(h.readBuildFile(pr, sha9, 'index.html', isPublic)).toBe('My content');
|
||||
|
||||
h.runCmd(`${curl} http://${host}/create-build/${pr}/${sha9}`).
|
||||
then(h.verifyResponse(403, /^Request to overwrite existing directory/)).
|
||||
then(() => expect(h.readBuildFile(pr, sha9, 'index.html')).toBe('My content')).
|
||||
h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9}`).
|
||||
then(h.verifyResponse(409, /^Request to overwrite existing directory/)).
|
||||
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, /^Request to overwrite existing directory/)).
|
||||
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 => {
|
||||
const prDir = path.join(h.buildsDir, pr);
|
||||
|
||||
h.runCmd(`${curl} http://${host}/create-build/${pr}/${sha9}`).
|
||||
h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9}`).
|
||||
then(h.verifyResponse(500)).
|
||||
then(() => expect(fs.existsSync(prDir)).toBe(false)).
|
||||
then(() => expect(h.buildExists(pr, '', isPublic)).toBe(false)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should only delete the SHA directory on error (for existing PR)', done => {
|
||||
const prDir = path.join(h.buildsDir, pr);
|
||||
const shaDir = path.join(prDir, sha9);
|
||||
h.createDummyBuild(pr, sha0, isPublic);
|
||||
|
||||
h.createDummyBuild(pr, sha0);
|
||||
|
||||
h.runCmd(`${curl} http://${host}/create-build/${pr}/${sha9}`).
|
||||
h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9}`).
|
||||
then(h.verifyResponse(500)).
|
||||
then(() => {
|
||||
expect(fs.existsSync(shaDir)).toBe(false);
|
||||
expect(fs.existsSync(prDir)).toBe(true);
|
||||
expect(h.buildExists(pr, sha9, isPublic)).toBe(false);
|
||||
expect(h.buildExists(pr, '', isPublic)).toBe(true);
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
@ -134,39 +170,41 @@ describe('upload-server (on HTTP)', () => {
|
||||
|
||||
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(`${curl} http://${host}/create-build/${pr}/${sha9}`);
|
||||
uploadPromise = h.runCmd(`${cmdPrefix} http://${host}/create-build/${pr}/${sha9}`);
|
||||
});
|
||||
afterEach(() => h.deletePrDir(pr));
|
||||
afterEach(() => h.deletePrDir(pr, isPublic));
|
||||
|
||||
|
||||
it('should respond with 201', done => {
|
||||
uploadPromise.then(h.verifyResponse(201)).then(done);
|
||||
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')).toContain(`uploaded/${pr}`);
|
||||
expect(h.readBuildFile(pr, sha9, 'foo/bar.js')).toContain(`uploaded/${pr}`);
|
||||
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.serverUser}'`, done => {
|
||||
const shaDir = path.join(h.buildsDir, pr, sha9);
|
||||
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.serverUser}`),
|
||||
h.runCmd(`find ${shaDir} -user ${h.wwwUser}`),
|
||||
])).
|
||||
then(([{stdout: allFiles}, {stdout: userFiles}]) => {
|
||||
expect(userFiles).toBe(allFiles);
|
||||
@ -187,7 +225,8 @@ describe('upload-server (on HTTP)', () => {
|
||||
|
||||
|
||||
it('should make the build directory non-writable', done => {
|
||||
const shaDir = path.join(h.buildsDir, pr, sha9);
|
||||
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');
|
||||
|
||||
@ -207,11 +246,110 @@ describe('upload-server (on HTTP)', () => {
|
||||
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, /^Request to overwrite existing directory/)).
|
||||
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 => {
|
||||
@ -236,27 +374,194 @@ describe('upload-server (on HTTP)', () => {
|
||||
});
|
||||
|
||||
|
||||
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 GET requests to unknown URLs', done => {
|
||||
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)),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 405 for non-GET requests to any URL', done => {
|
||||
const bodyRegex = /^Unsupported method/;
|
||||
|
||||
Promise.all([
|
||||
h.runCmd(`curl -iLX PUT http://${host}`).then(h.verifyResponse(405, bodyRegex)),
|
||||
h.runCmd(`curl -iLX POST http://${host}`).then(h.verifyResponse(405, bodyRegex)),
|
||||
h.runCmd(`curl -iLX PATCH http://${host}`).then(h.verifyResponse(405, bodyRegex)),
|
||||
h.runCmd(`curl -iLX DELETE http://${host}`).then(h.verifyResponse(405, 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);
|
||||
});
|
||||
|
||||
|
@ -6,26 +6,28 @@
|
||||
"author": "Angular",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"prebuild": "yarn run clean",
|
||||
"prebuild": "yarn clean-dist",
|
||||
"build": "tsc",
|
||||
"build-watch": "yarn run tsc -- --watch",
|
||||
"clean": "node --eval \"require('shelljs').rm('-rf', 'dist')\"",
|
||||
"dev": "concurrently --kill-others --raw --success first \"yarn run build-watch\" \"yarn run test-watch\"",
|
||||
"build-watch": "yarn tsc -- --watch",
|
||||
"clean-dist": "node --eval \"require('shelljs').rm('-rf', 'dist')\"",
|
||||
"dev": "concurrently --kill-others --raw --success first \"yarn build-watch\" \"yarn test-watch\"",
|
||||
"lint": "tslint --project tsconfig.json",
|
||||
"pre~~test-only": "yarn run lint",
|
||||
"pre~~test-only": "yarn lint",
|
||||
"~~test-only": "node dist/test",
|
||||
"pretest": "yarn run build",
|
||||
"test": "yarn run ~~test-only",
|
||||
"pretest-watch": "yarn run build",
|
||||
"test-watch": "nodemon --exec \"yarn run ~~test-only\" --watch dist"
|
||||
"pretest": "yarn build",
|
||||
"test": "yarn ~~test-only",
|
||||
"pretest-watch": "yarn build",
|
||||
"test-watch": "nodemon --exec \"yarn ~~test-only\" --watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.17.2",
|
||||
"express": "^4.14.1",
|
||||
"jasmine": "^2.5.3",
|
||||
"jsonwebtoken": "^7.3.0",
|
||||
"shelljs": "^0.7.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/body-parser": "^1.16.4",
|
||||
"@types/express": "^4.0.35",
|
||||
"@types/jasmine": "^2.5.43",
|
||||
"@types/jsonwebtoken": "^7.2.0",
|
||||
|
@ -1,7 +1,9 @@
|
||||
// Imports
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {BuildCleaner} from '../../lib/clean-up/build-cleaner';
|
||||
import {HIDDEN_DIR_PREFIX} from '../../lib/common/constants';
|
||||
import {GithubPullRequests} from '../../lib/common/github-pull-requests';
|
||||
|
||||
// Tests
|
||||
@ -114,7 +116,7 @@ describe('BuildCleaner', () => {
|
||||
|
||||
it('should resolve with the value returned by \'removeUnnecessaryBuilds()\'', done => {
|
||||
promise.then(result => {
|
||||
expect(result).toBe('Test');
|
||||
expect(result as any).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
@ -170,6 +172,16 @@ describe('BuildCleaner', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should remove `HIDDEN_DIR_PREFIX` from the filenames', done => {
|
||||
promise.then(result => {
|
||||
expect(result).toEqual([12, 34, 56]);
|
||||
done();
|
||||
});
|
||||
|
||||
readdirCb(null, [`${HIDDEN_DIR_PREFIX}12`, '34', `${HIDDEN_DIR_PREFIX}56`]);
|
||||
});
|
||||
|
||||
|
||||
it('should ignore files with non-numeric (or zero) names', done => {
|
||||
promise.then(result => {
|
||||
expect(result).toEqual([12, 34, 56]);
|
||||
@ -230,10 +242,22 @@ describe('BuildCleaner', () => {
|
||||
describe('removeDir()', () => {
|
||||
let shellChmodSpy: jasmine.Spy;
|
||||
let shellRmSpy: jasmine.Spy;
|
||||
let shellTestSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
shellChmodSpy = spyOn(shell, 'chmod');
|
||||
shellRmSpy = spyOn(shell, 'rm');
|
||||
shellTestSpy = spyOn(shell, 'test').and.returnValue(true);
|
||||
});
|
||||
|
||||
|
||||
it('should test if the directory exists (and return if is does not)', () => {
|
||||
shellTestSpy.and.returnValue(false);
|
||||
(cleaner as any).removeDir('/foo/bar');
|
||||
|
||||
expect(shellTestSpy).toHaveBeenCalledWith('-d', '/foo/bar');
|
||||
expect(shellChmodSpy).not.toHaveBeenCalled();
|
||||
expect(shellRmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@ -287,17 +311,28 @@ describe('BuildCleaner', () => {
|
||||
it('should construct full paths to directories (by prepending \'buildsDir\')', () => {
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3], []);
|
||||
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith('/foo/bar/1');
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith('/foo/bar/2');
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith('/foo/bar/3');
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/1'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/2'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/3'));
|
||||
});
|
||||
|
||||
|
||||
it('should try removing hidden directories as well', () => {
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3], []);
|
||||
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
|
||||
});
|
||||
|
||||
|
||||
it('should remove the builds that do not correspond to open PRs', () => {
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], [2, 4]);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(2);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith('/foo/bar/1');
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith('/foo/bar/3');
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(4);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/1'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/3'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
|
||||
cleanerRemoveDirSpy.calls.reset();
|
||||
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], [1, 2, 3, 4]);
|
||||
@ -305,11 +340,15 @@ describe('BuildCleaner', () => {
|
||||
cleanerRemoveDirSpy.calls.reset();
|
||||
|
||||
(cleaner as any).removeUnnecessaryBuilds([1, 2, 3, 4], []);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(4);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith('/foo/bar/1');
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith('/foo/bar/2');
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith('/foo/bar/3');
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith('/foo/bar/4');
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledTimes(8);
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/1'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/2'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/3'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize('/foo/bar/4'));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}1`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}2`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}3`));
|
||||
expect(cleanerRemoveDirSpy).toHaveBeenCalledWith(path.normalize(`/foo/bar/${HIDDEN_DIR_PREFIX}4`));
|
||||
cleanerRemoveDirSpy.calls.reset();
|
||||
});
|
||||
|
||||
|
@ -292,7 +292,7 @@ describe('GithubApi', () => {
|
||||
|
||||
|
||||
describe('onResponse', () => {
|
||||
let promise: Promise<void>;
|
||||
let promise: Promise<Object>;
|
||||
let respond: (statusCode: number) => IncomingMessage;
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -66,7 +66,7 @@ describe('GithubPullRequests', () => {
|
||||
|
||||
it('should resolve with the returned response', done => {
|
||||
prs.addComment(42, 'body').then(data => {
|
||||
expect(data).toEqual('Test');
|
||||
expect(data as any).toBe('Test');
|
||||
done();
|
||||
});
|
||||
|
||||
@ -76,6 +76,30 @@ describe('GithubPullRequests', () => {
|
||||
});
|
||||
|
||||
|
||||
describe('fetch()', () => {
|
||||
let prs: GithubPullRequests;
|
||||
let prsGetSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
prs = new GithubPullRequests('12345', 'foo/bar');
|
||||
prsGetSpy = spyOn(prs as any, 'get');
|
||||
});
|
||||
|
||||
|
||||
it('should call \'get()\' with the correct pathname', () => {
|
||||
prs.fetch(42);
|
||||
expect(prsGetSpy).toHaveBeenCalledWith('/repos/foo/bar/issues/42');
|
||||
});
|
||||
|
||||
|
||||
it('should forward the value returned by \'get()\'', () => {
|
||||
prsGetSpy.and.returnValue('Test');
|
||||
expect(prs.fetch(42) as any).toBe('Test');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('fetchAll()', () => {
|
||||
let prs: GithubPullRequests;
|
||||
let prsGetPaginatedSpy: jasmine.Spy;
|
||||
@ -109,7 +133,7 @@ describe('GithubPullRequests', () => {
|
||||
|
||||
it('should forward the value returned by \'getPaginated()\'', () => {
|
||||
prsGetPaginatedSpy.and.returnValue('Test');
|
||||
expect(prs.fetchAll()).toBe('Test');
|
||||
expect(prs.fetchAll() as any).toBe('Test');
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -38,7 +38,7 @@ describe('GithubTeams', () => {
|
||||
|
||||
it('should forward the value returned by \'getPaginated()\'', () => {
|
||||
teamsGetPaginatedSpy.and.returnValue('Test');
|
||||
expect(teams.fetchAll()).toBe('Test');
|
||||
expect(teams.fetchAll() as any).toBe('Test');
|
||||
});
|
||||
|
||||
});
|
||||
@ -50,12 +50,16 @@ describe('GithubTeams', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
teams = new GithubTeams('12345', 'foo');
|
||||
teamsGetSpy = spyOn(teams, 'get');
|
||||
teamsGetSpy = spyOn(teams, 'get').and.returnValue(Promise.resolve(null));
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', () => {
|
||||
expect(teams.isMemberById('user', [1])).toEqual(jasmine.any(Promise));
|
||||
it('should return a promise', done => {
|
||||
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));
|
||||
});
|
||||
|
||||
|
||||
@ -69,7 +73,6 @@ describe('GithubTeams', () => {
|
||||
|
||||
|
||||
it('should call \'get()\' with the correct pathname', done => {
|
||||
teamsGetSpy.and.returnValue(Promise.resolve(null));
|
||||
teams.isMemberById('user', [1]).then(() => {
|
||||
expect(teamsGetSpy).toHaveBeenCalledWith('/teams/1/memberships/user');
|
||||
done();
|
||||
|
@ -2,9 +2,11 @@
|
||||
import * as cp from 'child_process';
|
||||
import {EventEmitter} from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as shell from 'shelljs';
|
||||
import {SHORT_SHA_LEN} from '../../lib/common/constants';
|
||||
import {BuildCreator} from '../../lib/upload-server/build-creator';
|
||||
import {CreatedBuildEvent} from '../../lib/upload-server/build-events';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/upload-server/build-events';
|
||||
import {UploadError} from '../../lib/upload-server/upload-error';
|
||||
import {expectToBeUploadError} from './helpers';
|
||||
|
||||
@ -12,10 +14,13 @@ import {expectToBeUploadError} from './helpers';
|
||||
describe('BuildCreator', () => {
|
||||
const pr = '9';
|
||||
const sha = '9'.repeat(40);
|
||||
const shortSha = sha.substr(0, SHORT_SHA_LEN);
|
||||
const archive = 'snapshot.tar.gz';
|
||||
const buildsDir = 'builds/dir';
|
||||
const prDir = `${buildsDir}/${pr}`;
|
||||
const shaDir = `${prDir}/${sha}`;
|
||||
const hiddenPrDir = path.join(buildsDir, `hidden--${pr}`);
|
||||
const publicPrDir = path.join(buildsDir, pr);
|
||||
const hiddenShaDir = path.join(hiddenPrDir, shortSha);
|
||||
const publicShaDir = path.join(publicPrDir, shortSha);
|
||||
let bc: BuildCreator;
|
||||
|
||||
beforeEach(() => bc = new BuildCreator(buildsDir));
|
||||
@ -42,6 +47,7 @@ describe('BuildCreator', () => {
|
||||
let bcEmitSpy: jasmine.Spy;
|
||||
let bcExistsSpy: jasmine.Spy;
|
||||
let bcExtractArchiveSpy: jasmine.Spy;
|
||||
let bcUpdatePrVisibilitySpy: jasmine.Spy;
|
||||
let shellMkdirSpy: jasmine.Spy;
|
||||
let shellRmSpy: jasmine.Spy;
|
||||
|
||||
@ -49,13 +55,19 @@ describe('BuildCreator', () => {
|
||||
bcEmitSpy = spyOn(bc, 'emit');
|
||||
bcExistsSpy = spyOn(bc as any, 'exists');
|
||||
bcExtractArchiveSpy = spyOn(bc as any, 'extractArchive');
|
||||
bcUpdatePrVisibilitySpy = spyOn(bc, 'updatePrVisibility');
|
||||
shellMkdirSpy = spyOn(shell, 'mkdir');
|
||||
shellRmSpy = spyOn(shell, 'rm');
|
||||
});
|
||||
|
||||
|
||||
[true, false].forEach(isPublic => {
|
||||
const prDir = isPublic ? publicPrDir : hiddenPrDir;
|
||||
const shaDir = isPublic ? publicShaDir : hiddenShaDir;
|
||||
|
||||
|
||||
it('should return a promise', done => {
|
||||
const promise = bc.create(pr, sha, archive);
|
||||
const promise = bc.create(pr, sha, archive, isPublic);
|
||||
promise.then(done); // Do not complete the test (and release the spies) synchronously
|
||||
// to avoid running the actual `extractArchive()`.
|
||||
|
||||
@ -63,24 +75,27 @@ describe('BuildCreator', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should throw if the build does already exist', done => {
|
||||
bcExistsSpy.and.returnValue(true);
|
||||
bc.create(pr, sha, archive).catch(err => {
|
||||
expectToBeUploadError(err, 403, `Request to overwrite existing directory: ${shaDir}`);
|
||||
done();
|
||||
});
|
||||
it('should update the PR\'s visibility first if necessary', done => {
|
||||
bcUpdatePrVisibilitySpy.and.callFake(() => expect(shellMkdirSpy).not.toHaveBeenCalled());
|
||||
|
||||
bc.create(pr, sha, archive, isPublic).
|
||||
then(() => {
|
||||
expect(bcUpdatePrVisibilitySpy).toHaveBeenCalledWith(pr, isPublic);
|
||||
expect(shellMkdirSpy).toHaveBeenCalled();
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should create the build directory (and any parent directories)', done => {
|
||||
bc.create(pr, sha, archive).
|
||||
bc.create(pr, sha, archive, isPublic).
|
||||
then(() => expect(shellMkdirSpy).toHaveBeenCalledWith('-p', shaDir)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should extract the archive contents into the build directory', done => {
|
||||
bc.create(pr, sha, archive).
|
||||
bc.create(pr, sha, archive, isPublic).
|
||||
then(() => expect(bcExtractArchiveSpy).toHaveBeenCalledWith(archive, shaDir)).
|
||||
then(done);
|
||||
});
|
||||
@ -93,22 +108,79 @@ describe('BuildCreator', () => {
|
||||
expect(type).toBe(CreatedBuildEvent.type);
|
||||
expect(evt).toEqual(jasmine.any(CreatedBuildEvent));
|
||||
expect(evt.pr).toBe(+pr);
|
||||
expect(evt.sha).toBe(sha);
|
||||
expect(evt.sha).toBe(shortSha);
|
||||
expect(evt.isPublic).toBe(isPublic);
|
||||
|
||||
emitted = true;
|
||||
});
|
||||
|
||||
bc.create(pr, sha, archive).
|
||||
bc.create(pr, sha, archive, isPublic).
|
||||
then(() => expect(emitted).toBe(true)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
describe('on error', () => {
|
||||
let existsValues: {[dir: string]: boolean};
|
||||
|
||||
beforeEach(() => {
|
||||
existsValues = {
|
||||
[prDir]: false,
|
||||
[shaDir]: false,
|
||||
};
|
||||
|
||||
bcExistsSpy.and.callFake((dir: string) => existsValues[dir]);
|
||||
});
|
||||
|
||||
|
||||
it('should abort and skip further operations if changing the PR\'s visibility fails', done => {
|
||||
const mockError = new UploadError(543, 'Test');
|
||||
bcUpdatePrVisibilitySpy.and.returnValue(Promise.reject(mockError));
|
||||
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
expect(err).toBe(mockError);
|
||||
|
||||
expect(bcExistsSpy).not.toHaveBeenCalled();
|
||||
expect(shellMkdirSpy).not.toHaveBeenCalled();
|
||||
expect(bcExtractArchiveSpy).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should abort and skip further operations if the build does already exist', done => {
|
||||
existsValues[shaDir] = true;
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
expectToBeUploadError(err, 409, `Request to overwrite existing directory: ${shaDir}`);
|
||||
expect(shellMkdirSpy).not.toHaveBeenCalled();
|
||||
expect(bcExtractArchiveSpy).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should detect existing build directory after visibility change', done => {
|
||||
bcUpdatePrVisibilitySpy.and.callFake(() => existsValues[prDir] = existsValues[shaDir] = true);
|
||||
|
||||
expect(bcExistsSpy(prDir)).toBe(false);
|
||||
expect(bcExistsSpy(shaDir)).toBe(false);
|
||||
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
expectToBeUploadError(err, 409, `Request to overwrite existing directory: ${shaDir}`);
|
||||
expect(shellMkdirSpy).not.toHaveBeenCalled();
|
||||
expect(bcExtractArchiveSpy).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should abort and skip further operations if it fails to create the directories', done => {
|
||||
shellMkdirSpy.and.throwError('');
|
||||
bc.create(pr, sha, archive).catch(() => {
|
||||
bc.create(pr, sha, archive, isPublic).catch(() => {
|
||||
expect(shellMkdirSpy).toHaveBeenCalled();
|
||||
expect(bcExtractArchiveSpy).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
@ -119,7 +191,7 @@ describe('BuildCreator', () => {
|
||||
|
||||
it('should abort and skip further operations if it fails to extract the archive', done => {
|
||||
bcExtractArchiveSpy.and.throwError('');
|
||||
bc.create(pr, sha, archive).catch(() => {
|
||||
bc.create(pr, sha, archive, isPublic).catch(() => {
|
||||
expect(shellMkdirSpy).toHaveBeenCalled();
|
||||
expect(bcExtractArchiveSpy).toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
@ -130,7 +202,7 @@ describe('BuildCreator', () => {
|
||||
|
||||
it('should delete the PR directory (for new PR)', done => {
|
||||
bcExtractArchiveSpy.and.throwError('');
|
||||
bc.create(pr, sha, archive).catch(() => {
|
||||
bc.create(pr, sha, archive, isPublic).catch(() => {
|
||||
expect(shellRmSpy).toHaveBeenCalledWith('-rf', prDir);
|
||||
done();
|
||||
});
|
||||
@ -138,10 +210,10 @@ describe('BuildCreator', () => {
|
||||
|
||||
|
||||
it('should delete the SHA directory (for existing PR)', done => {
|
||||
bcExistsSpy.and.callFake((path: string) => path !== shaDir);
|
||||
existsValues[prDir] = true;
|
||||
bcExtractArchiveSpy.and.throwError('');
|
||||
|
||||
bc.create(pr, sha, archive).catch(() => {
|
||||
bc.create(pr, sha, archive, isPublic).catch(() => {
|
||||
expect(shellRmSpy).toHaveBeenCalledWith('-rf', shaDir);
|
||||
done();
|
||||
});
|
||||
@ -149,8 +221,8 @@ describe('BuildCreator', () => {
|
||||
|
||||
|
||||
it('should reject with an UploadError', done => {
|
||||
shellMkdirSpy.and.callFake(() => {throw 'Test'; });
|
||||
bc.create(pr, sha, archive).catch(err => {
|
||||
shellMkdirSpy.and.callFake(() => { throw 'Test'; });
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
expectToBeUploadError(err, 500, `Error while uploading to directory: ${shaDir}\nTest`);
|
||||
done();
|
||||
});
|
||||
@ -159,7 +231,7 @@ describe('BuildCreator', () => {
|
||||
|
||||
it('should pass UploadError instances unmodified', done => {
|
||||
shellMkdirSpy.and.callFake(() => { throw new UploadError(543, 'Test'); });
|
||||
bc.create(pr, sha, archive).catch(err => {
|
||||
bc.create(pr, sha, archive, isPublic).catch(err => {
|
||||
expectToBeUploadError(err, 543, 'Test');
|
||||
done();
|
||||
});
|
||||
@ -169,6 +241,192 @@ describe('BuildCreator', () => {
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('updatePrVisibility()', () => {
|
||||
let bcEmitSpy: jasmine.Spy;
|
||||
let bcExistsSpy: jasmine.Spy;
|
||||
let bcListShasByDate: jasmine.Spy;
|
||||
let shellMvSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
bcEmitSpy = spyOn(bc, 'emit');
|
||||
bcExistsSpy = spyOn(bc as any, 'exists');
|
||||
bcListShasByDate = spyOn(bc as any, 'listShasByDate');
|
||||
shellMvSpy = spyOn(shell, 'mv');
|
||||
|
||||
bcExistsSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false));
|
||||
bcListShasByDate.and.returnValue([]);
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', done => {
|
||||
const promise = bc.updatePrVisibility(pr, true);
|
||||
promise.then(done); // Do not complete the test (and release the spies) synchronously
|
||||
// to avoid running the actual `extractArchive()`.
|
||||
|
||||
expect(promise).toEqual(jasmine.any(Promise));
|
||||
});
|
||||
|
||||
|
||||
[true, false].forEach(makePublic => {
|
||||
const oldPrDir = makePublic ? hiddenPrDir : publicPrDir;
|
||||
const newPrDir = makePublic ? publicPrDir : hiddenPrDir;
|
||||
|
||||
|
||||
it('should rename the directory', done => {
|
||||
bc.updatePrVisibility(pr, makePublic).
|
||||
then(() => expect(shellMvSpy).toHaveBeenCalledWith(oldPrDir, newPrDir)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
describe('when the visibility is updated', () => {
|
||||
|
||||
it('should resolve to true', done => {
|
||||
bc.updatePrVisibility(pr, makePublic).
|
||||
then(result => expect(result).toBe(true)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should rename the directory', done => {
|
||||
bc.updatePrVisibility(pr, makePublic).
|
||||
then(() => expect(shellMvSpy).toHaveBeenCalledWith(oldPrDir, newPrDir)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should emit a ChangedPrVisibilityEvent on success', done => {
|
||||
let emitted = false;
|
||||
|
||||
bcEmitSpy.and.callFake((type: string, evt: ChangedPrVisibilityEvent) => {
|
||||
expect(type).toBe(ChangedPrVisibilityEvent.type);
|
||||
expect(evt).toEqual(jasmine.any(ChangedPrVisibilityEvent));
|
||||
expect(evt.pr).toBe(+pr);
|
||||
expect(evt.shas).toEqual(jasmine.any(Array));
|
||||
expect(evt.isPublic).toBe(makePublic);
|
||||
|
||||
emitted = true;
|
||||
});
|
||||
|
||||
bc.updatePrVisibility(pr, makePublic).
|
||||
then(() => expect(emitted).toBe(true)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should include all shas in the emitted event', done => {
|
||||
const shas = ['foo', 'bar', 'baz'];
|
||||
let emitted = false;
|
||||
|
||||
bcListShasByDate.and.returnValue(Promise.resolve(shas));
|
||||
bcEmitSpy.and.callFake((type: string, evt: ChangedPrVisibilityEvent) => {
|
||||
expect(bcListShasByDate).toHaveBeenCalledWith(newPrDir);
|
||||
|
||||
expect(type).toBe(ChangedPrVisibilityEvent.type);
|
||||
expect(evt).toEqual(jasmine.any(ChangedPrVisibilityEvent));
|
||||
expect(evt.pr).toBe(+pr);
|
||||
expect(evt.shas).toBe(shas);
|
||||
expect(evt.isPublic).toBe(makePublic);
|
||||
|
||||
emitted = true;
|
||||
});
|
||||
|
||||
bc.updatePrVisibility(pr, makePublic).
|
||||
then(() => expect(emitted).toBe(true)).
|
||||
then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
it('should do nothing if the visibility is already up-to-date', done => {
|
||||
bcExistsSpy.and.callFake((dir: string) => dir === newPrDir);
|
||||
bc.updatePrVisibility(pr, makePublic).
|
||||
then(result => {
|
||||
expect(result).toBe(false);
|
||||
expect(shellMvSpy).not.toHaveBeenCalled();
|
||||
expect(bcListShasByDate).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should do nothing if the PR directory does not exist', done => {
|
||||
bcExistsSpy.and.returnValue(false);
|
||||
bc.updatePrVisibility(pr, makePublic).
|
||||
then(result => {
|
||||
expect(result).toBe(false);
|
||||
expect(shellMvSpy).not.toHaveBeenCalled();
|
||||
expect(bcListShasByDate).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
describe('on error', () => {
|
||||
|
||||
it('should abort and skip further operations if both directories exist', done => {
|
||||
bcExistsSpy.and.returnValue(true);
|
||||
bc.updatePrVisibility(pr, makePublic).catch(err => {
|
||||
expectToBeUploadError(err, 409, `Request to move '${oldPrDir}' to existing directory '${newPrDir}'.`);
|
||||
expect(shellMvSpy).not.toHaveBeenCalled();
|
||||
expect(bcListShasByDate).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should abort and skip further operations if it fails to rename the directory', done => {
|
||||
shellMvSpy.and.throwError('');
|
||||
bc.updatePrVisibility(pr, makePublic).catch(() => {
|
||||
expect(shellMvSpy).toHaveBeenCalled();
|
||||
expect(bcListShasByDate).not.toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should abort and skip further operations if it fails to list the SHAs', done => {
|
||||
bcListShasByDate.and.throwError('');
|
||||
bc.updatePrVisibility(pr, makePublic).catch(() => {
|
||||
expect(shellMvSpy).toHaveBeenCalled();
|
||||
expect(bcListShasByDate).toHaveBeenCalled();
|
||||
expect(bcEmitSpy).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should reject with an UploadError', done => {
|
||||
shellMvSpy.and.callFake(() => { throw 'Test'; });
|
||||
bc.updatePrVisibility(pr, makePublic).catch(err => {
|
||||
expectToBeUploadError(err, 500, `Error while making PR ${pr} ${makePublic ? 'public' : 'hidden'}.\nTest`);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should pass UploadError instances unmodified', done => {
|
||||
shellMvSpy.and.callFake(() => { throw new UploadError(543, 'Test'); });
|
||||
bc.updatePrVisibility(pr, makePublic).catch(err => {
|
||||
expectToBeUploadError(err, 543, 'Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
// Protected methods
|
||||
|
||||
@ -317,4 +575,101 @@ describe('BuildCreator', () => {
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('listShasByDate()', () => {
|
||||
let shellLsSpy: jasmine.Spy;
|
||||
const lsResult = (name: string, mtimeMs: number, isDirectory = true) => ({
|
||||
isDirectory: () => isDirectory,
|
||||
mtime: new Date(mtimeMs),
|
||||
name,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
shellLsSpy = spyOn(shell, 'ls').and.returnValue([]);
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', done => {
|
||||
const promise = (bc as any).listShasByDate('input/dir');
|
||||
promise.then(done); // Do not complete the test (and release the spies) synchronously
|
||||
// to avoid running the actual `ls()`.
|
||||
|
||||
expect(promise).toEqual(jasmine.any(Promise));
|
||||
});
|
||||
|
||||
|
||||
it('should `ls()` files with their metadata', done => {
|
||||
(bc as any).listShasByDate('input/dir').
|
||||
then(() => expect(shellLsSpy).toHaveBeenCalledWith('-l', 'input/dir')).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should reject if listing files fails', done => {
|
||||
shellLsSpy.and.returnValue(Promise.reject('Test'));
|
||||
(bc as any).listShasByDate('input/dir').catch((err: string) => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should return the filenames', done => {
|
||||
shellLsSpy.and.returnValue(Promise.resolve([
|
||||
lsResult('foo', 100),
|
||||
lsResult('bar', 200),
|
||||
lsResult('baz', 300),
|
||||
]));
|
||||
|
||||
(bc as any).listShasByDate('input/dir').
|
||||
then((shas: string[]) => expect(shas).toEqual(['foo', 'bar', 'baz'])).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should sort by date', done => {
|
||||
shellLsSpy.and.returnValue(Promise.resolve([
|
||||
lsResult('foo', 300),
|
||||
lsResult('bar', 100),
|
||||
lsResult('baz', 200),
|
||||
]));
|
||||
|
||||
(bc as any).listShasByDate('input/dir').
|
||||
then((shas: string[]) => expect(shas).toEqual(['bar', 'baz', 'foo'])).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should not break with ShellJS\' custom `sort()` method', done => {
|
||||
const mockArray = [
|
||||
lsResult('foo', 300),
|
||||
lsResult('bar', 100),
|
||||
lsResult('baz', 200),
|
||||
];
|
||||
mockArray.sort = jasmine.createSpy('sort');
|
||||
|
||||
shellLsSpy.and.returnValue(Promise.resolve(mockArray));
|
||||
(bc as any).listShasByDate('input/dir').
|
||||
then((shas: string[]) => {
|
||||
expect(shas).toEqual(['bar', 'baz', 'foo']);
|
||||
expect(mockArray.sort).not.toHaveBeenCalled();
|
||||
}).
|
||||
then(done);
|
||||
});
|
||||
|
||||
|
||||
it('should only include directories', done => {
|
||||
shellLsSpy.and.returnValue(Promise.resolve([
|
||||
lsResult('foo', 100),
|
||||
lsResult('bar', 200, false),
|
||||
lsResult('baz', 300),
|
||||
]));
|
||||
|
||||
(bc as any).listShasByDate('input/dir').
|
||||
then((shas: string[]) => expect(shas).toEqual(['foo', 'baz'])).
|
||||
then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,15 +1,15 @@
|
||||
// Imports
|
||||
import {BuildEvent, CreatedBuildEvent} from '../../lib/upload-server/build-events';
|
||||
import {ChangedPrVisibilityEvent, CreatedBuildEvent} from '../../lib/upload-server/build-events';
|
||||
|
||||
// Tests
|
||||
describe('BuildEvent', () => {
|
||||
let evt: BuildEvent;
|
||||
describe('ChangedPrVisibilityEvent', () => {
|
||||
let evt: ChangedPrVisibilityEvent;
|
||||
|
||||
beforeEach(() => evt = new BuildEvent('foo', 42, 'bar'));
|
||||
beforeEach(() => evt = new ChangedPrVisibilityEvent(42, ['foo', 'bar'], true));
|
||||
|
||||
|
||||
it('should have a \'type\' property', () => {
|
||||
expect(evt.type).toBe('foo');
|
||||
it('should have a static \'type\' property', () => {
|
||||
expect(ChangedPrVisibilityEvent.type).toBe('pr.changedVisibility');
|
||||
});
|
||||
|
||||
|
||||
@ -18,8 +18,13 @@ describe('BuildEvent', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should have a \'sha\' property', () => {
|
||||
expect(evt.sha).toBe('bar');
|
||||
it('should have a \'shas\' property', () => {
|
||||
expect(evt.shas).toEqual(['foo', 'bar']);
|
||||
});
|
||||
|
||||
|
||||
it('should have an \'isPublic\' property', () => {
|
||||
expect(evt.isPublic).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
@ -28,7 +33,7 @@ describe('BuildEvent', () => {
|
||||
describe('CreatedBuildEvent', () => {
|
||||
let evt: CreatedBuildEvent;
|
||||
|
||||
beforeEach(() => evt = new CreatedBuildEvent(42, 'bar'));
|
||||
beforeEach(() => evt = new CreatedBuildEvent(42, 'bar', true));
|
||||
|
||||
|
||||
it('should have a static \'type\' property', () => {
|
||||
@ -36,19 +41,6 @@ describe('CreatedBuildEvent', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should extend BuildEvent', () => {
|
||||
expect(evt).toEqual(jasmine.any(CreatedBuildEvent));
|
||||
expect(evt).toEqual(jasmine.any(BuildEvent));
|
||||
|
||||
expect(Object.getPrototypeOf(evt)).toBe(CreatedBuildEvent.prototype);
|
||||
});
|
||||
|
||||
|
||||
it('should automatically set the \'type\'', () => {
|
||||
expect(evt.type).toBe(CreatedBuildEvent.type);
|
||||
});
|
||||
|
||||
|
||||
it('should have a \'pr\' property', () => {
|
||||
expect(evt.pr).toBe(42);
|
||||
});
|
||||
@ -58,4 +50,9 @@ describe('CreatedBuildEvent', () => {
|
||||
expect(evt.sha).toBe('bar');
|
||||
});
|
||||
|
||||
|
||||
it('should have an \'isPublic\' property', () => {
|
||||
expect(evt.isPublic).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -1,8 +1,8 @@
|
||||
// Imports
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import {GithubPullRequests} from '../../lib/common/github-pull-requests';
|
||||
import {GithubPullRequests, PullRequest} from '../../lib/common/github-pull-requests';
|
||||
import {GithubTeams} from '../../lib/common/github-teams';
|
||||
import {BuildVerifier} from '../../lib/upload-server/build-verifier';
|
||||
import {BUILD_VERIFICATION_STATUS, BuildVerifier} from '../../lib/upload-server/build-verifier';
|
||||
import {expectToBeUploadError} from './helpers';
|
||||
|
||||
// Tests
|
||||
@ -13,14 +13,15 @@ describe('BuildVerifier', () => {
|
||||
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};
|
||||
const cfg = {...defaultConfig, ...partialConfig} as typeof defaultConfig;
|
||||
return new BuildVerifier(cfg.secret, cfg.githubToken, cfg.repoSlug, cfg.organization,
|
||||
cfg.allowedTeamSlugs);
|
||||
cfg.allowedTeamSlugs, cfg.trustedPrLabel);
|
||||
};
|
||||
|
||||
beforeEach(() => bv = createBuildVerifier());
|
||||
@ -28,7 +29,8 @@ describe('BuildVerifier', () => {
|
||||
|
||||
describe('constructor()', () => {
|
||||
|
||||
['secret', 'githubToken', 'repoSlug', 'organization', 'allowedTeamSlugs'].forEach(param => {
|
||||
['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}'!`);
|
||||
@ -44,6 +46,122 @@ describe('BuildVerifier', () => {
|
||||
});
|
||||
|
||||
|
||||
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 = {
|
||||
@ -53,20 +171,23 @@ describe('BuildVerifier', () => {
|
||||
'pull-request': pr,
|
||||
'slug': defaultConfig.repoSlug,
|
||||
};
|
||||
let bvGetPrAuthorTeamMembership: jasmine.Spy;
|
||||
let bvGetPrIsTrusted: jasmine.Spy;
|
||||
|
||||
// Heleprs
|
||||
const createAuthHeader = (partialJwt: Partial<typeof defaultJwt> = {}, secret: string = defaultConfig.secret) =>
|
||||
`Token ${jwt.sign({...defaultJwt, ...partialJwt}, secret)}`;
|
||||
|
||||
beforeEach(() => {
|
||||
bvGetPrAuthorTeamMembership = spyOn(bv, 'getPrAuthorTeamMembership').
|
||||
and.returnValue(Promise.resolve({author: 'some-author', isMember: true}));
|
||||
bvGetPrIsTrusted = spyOn(bv, 'getPrIsTrusted').and.returnValue(Promise.resolve(true));
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', () => {
|
||||
expect(bv.verify(pr, createAuthHeader())).toEqual(jasmine.any(Promise));
|
||||
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));
|
||||
});
|
||||
|
||||
|
||||
@ -144,16 +265,16 @@ describe('BuildVerifier', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should call \'getPrAuthorTeamMembership()\' if the token is valid', done => {
|
||||
it('should call \'getPrIsTrusted()\' if the token is valid', done => {
|
||||
bv.verify(pr, createAuthHeader()).then(() => {
|
||||
expect(bvGetPrAuthorTeamMembership).toHaveBeenCalledWith(pr);
|
||||
expect(bvGetPrIsTrusted).toHaveBeenCalledWith(pr);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if \'getPrAuthorTeamMembership()\' rejects', done => {
|
||||
bvGetPrAuthorTeamMembership.and.callFake(() => Promise.reject('Test'));
|
||||
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();
|
||||
@ -161,93 +282,22 @@ describe('BuildVerifier', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should fail if \'getPrAuthorTeamMembership()\' reports no membership', done => {
|
||||
const errorMessage = `Error while verifying upload for PR ${pr}: User 'test' is not an active member of any of ` +
|
||||
'the following teams: team1, team2';
|
||||
|
||||
bvGetPrAuthorTeamMembership.and.returnValue(Promise.resolve({author: 'test', isMember: false}));
|
||||
bv.verify(pr, createAuthHeader()).catch(err => {
|
||||
expectToBeUploadError(err, 403, errorMessage);
|
||||
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 succeed if everything checks outs', done => {
|
||||
bv.verify(pr, createAuthHeader()).then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('getPrAuthorTeamMembership()', () => {
|
||||
const pr = 9;
|
||||
let prsFetchSpy: jasmine.Spy;
|
||||
let teamsIsMemberBySlugSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
prsFetchSpy = spyOn(GithubPullRequests.prototype, 'fetch').
|
||||
and.returnValue(Promise.resolve({user: {login: 'username'}}));
|
||||
|
||||
teamsIsMemberBySlugSpy = spyOn(GithubTeams.prototype, 'isMemberBySlug').
|
||||
and.returnValue(Promise.resolve(true));
|
||||
});
|
||||
|
||||
|
||||
it('should return a promise', () => {
|
||||
expect(bv.getPrAuthorTeamMembership(pr)).toEqual(jasmine.any(Promise));
|
||||
});
|
||||
|
||||
|
||||
it('should fetch the corresponding PR', done => {
|
||||
bv.getPrAuthorTeamMembership(pr).then(() => {
|
||||
expect(prsFetchSpy).toHaveBeenCalledWith(pr);
|
||||
it('should resolve to `verifiedAndTrusted` if \'getPrIsTrusted()\' returns true', done => {
|
||||
bv.verify(pr, createAuthHeader()).then(value => {
|
||||
expect(value).toBe(BUILD_VERIFICATION_STATUS.verifiedAndTrusted);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if fetching the PR errors', done => {
|
||||
prsFetchSpy.and.callFake(() => Promise.reject('Test'));
|
||||
bv.getPrAuthorTeamMembership(pr).catch(err => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should verify the PR author\'s membership in the specified teams', done => {
|
||||
bv.getPrAuthorTeamMembership(pr).then(() => {
|
||||
expect(teamsIsMemberBySlugSpy).toHaveBeenCalledWith('username', ['team1', 'team2']);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should fail if verifying membership errors', done => {
|
||||
teamsIsMemberBySlugSpy.and.callFake(() => Promise.reject('Test'));
|
||||
bv.getPrAuthorTeamMembership(pr).catch(err => {
|
||||
expect(err).toBe('Test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should return the PR\'s author and whether they are members', done => {
|
||||
teamsIsMemberBySlugSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false));
|
||||
|
||||
Promise.all([
|
||||
bv.getPrAuthorTeamMembership(pr).then(({author, isMember}) => {
|
||||
expect(author).toBe('username');
|
||||
expect(isMember).toBe(true);
|
||||
}),
|
||||
bv.getPrAuthorTeamMembership(pr).then(({author, isMember}) => {
|
||||
expect(author).toBe('username');
|
||||
expect(isMember).toBe(false);
|
||||
}),
|
||||
]).then(done);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -4,8 +4,8 @@ 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 {CreatedBuildEvent} from '../../lib/upload-server/build-events';
|
||||
import {BuildVerifier} from '../../lib/upload-server/build-verifier';
|
||||
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
|
||||
@ -18,11 +18,12 @@ describe('uploadServerFactory', () => {
|
||||
githubToken: '12345',
|
||||
repoSlug: 'repo/slug',
|
||||
secret: 'secret',
|
||||
trustedPrLabel: 'trusted: pr-label',
|
||||
};
|
||||
|
||||
// Helpers
|
||||
const createUploadServer = (partialConfig: Partial<typeof defaultConfig> = {}) =>
|
||||
usf.create({...defaultConfig, ...partialConfig});
|
||||
usf.create({...defaultConfig, ...partialConfig} as typeof defaultConfig);
|
||||
|
||||
|
||||
describe('create()', () => {
|
||||
@ -75,6 +76,12 @@ describe('uploadServerFactory', () => {
|
||||
});
|
||||
|
||||
|
||||
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();
|
||||
@ -141,26 +148,71 @@ describe('uploadServerFactory', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should post a comment on GitHub on \'build.created\'', () => {
|
||||
const prsAddCommentSpy = spyOn(GithubPullRequests.prototype, 'addComment');
|
||||
const commentBody = 'The angular.io preview for 1234567 is available [here][1].\n\n' +
|
||||
'[1]: https://pr42-1234567890.domain.name/';
|
||||
describe('on \'build.created\'', () => {
|
||||
let prsAddCommentSpy: jasmine.Spy;
|
||||
|
||||
buildCreator.emit(CreatedBuildEvent.type, {pr: 42, sha: '1234567890'});
|
||||
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'});
|
||||
const prs = prsAddCommentSpy.calls.mostRecent().object;
|
||||
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 as any).repoSlug).toBe('repo/slug');
|
||||
expect((prs as any).requestHeaders.Authorization).toContain('12345');
|
||||
expect(prs.repoSlug).toBe('repo/slug');
|
||||
expect(prs.requestHeaders.Authorization).toContain('12345');
|
||||
});
|
||||
|
||||
});
|
||||
@ -184,6 +236,7 @@ describe('uploadServerFactory', () => {
|
||||
defaultConfig.repoSlug,
|
||||
defaultConfig.githubOrganization,
|
||||
defaultConfig.githubTeamSlugs,
|
||||
defaultConfig.trustedPrLabel,
|
||||
);
|
||||
buildCreator = new BuildCreator(defaultConfig.buildsDir);
|
||||
agent = supertest.agent((usf as any).createMiddleware(buildVerifier, buildCreator));
|
||||
@ -199,17 +252,18 @@ describe('uploadServerFactory', () => {
|
||||
let buildCreatorCreateSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
buildVerifierVerifySpy = spyOn(buildVerifier, 'verify').and.returnValue(Promise.resolve());
|
||||
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 405 for non-GET requests', done => {
|
||||
it('should respond with 404 for non-GET requests', done => {
|
||||
verifyRequests([
|
||||
agent.put(`/create-build/${pr}/${sha}`).expect(405),
|
||||
agent.post(`/create-build/${pr}/${sha}`).expect(405),
|
||||
agent.patch(`/create-build/${pr}/${sha}`).expect(405),
|
||||
agent.delete(`/create-build/${pr}/${sha}`).expect(405),
|
||||
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);
|
||||
});
|
||||
|
||||
@ -284,14 +338,17 @@ describe('uploadServerFactory', () => {
|
||||
|
||||
|
||||
it('should call \'BuildCreator#create()\' with the correct arguments', done => {
|
||||
const req = agent.
|
||||
get(`/create-build/${pr}/${sha}`).
|
||||
set('AUTHORIZATION', 'foo').
|
||||
set('X-FILE', 'bar');
|
||||
buildVerifierVerifySpy.and.returnValues(
|
||||
Promise.resolve(BUILD_VERIFICATION_STATUS.verifiedAndTrusted),
|
||||
Promise.resolve(BUILD_VERIFICATION_STATUS.verifiedNotTrusted));
|
||||
|
||||
promisifyRequest(req).
|
||||
then(() => expect(buildCreatorCreateSpy).toHaveBeenCalledWith(pr, sha, 'bar')).
|
||||
then(done, done.fail);
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@ -307,7 +364,7 @@ describe('uploadServerFactory', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 201 on successful upload', done => {
|
||||
it('should respond with 201 on successful upload (for public builds)', done => {
|
||||
const req = agent.
|
||||
get(`/create-build/${pr}/${sha}`).
|
||||
set('AUTHORIZATION', 'foo').
|
||||
@ -318,23 +375,33 @@ describe('uploadServerFactory', () => {
|
||||
});
|
||||
|
||||
|
||||
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 ignore them)', done => {
|
||||
const sha41 = '0'.repeat(41);
|
||||
it('should accept SHAs with leading zeros (but not trim the zeros)', done => {
|
||||
const sha40 = '0'.repeat(40);
|
||||
const sha41 = `0${sha40}`;
|
||||
|
||||
const request41 = agent.get(`/create-build/${pr}/${sha41}`);
|
||||
const request40 = agent.get(`/create-build/${pr}/${sha40}`).
|
||||
set('AUTHORIZATION', 'foo').
|
||||
set('X-FILE', 'bar');
|
||||
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(request41.expect(404)),
|
||||
promisifyRequest(request40.expect(201)),
|
||||
promisifyRequest(request41.expect(404)),
|
||||
]).then(done, done.fail);
|
||||
});
|
||||
|
||||
@ -351,12 +418,12 @@ describe('uploadServerFactory', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should respond with 405 for non-GET requests', done => {
|
||||
it('should respond with 404 for non-GET requests', done => {
|
||||
verifyRequests([
|
||||
agent.put('/health-check').expect(405),
|
||||
agent.post('/health-check').expect(405),
|
||||
agent.patch('/health-check').expect(405),
|
||||
agent.delete('/health-check').expect(405),
|
||||
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);
|
||||
});
|
||||
|
||||
@ -375,11 +442,141 @@ describe('uploadServerFactory', () => {
|
||||
});
|
||||
|
||||
|
||||
describe('GET *', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('should respond with 404', done => {
|
||||
const responseBody = 'Unknown resource in request: GET /some/url';
|
||||
verifyRequests([agent.get('/some/url').expect(404, responseBody)], done);
|
||||
});
|
||||
|
||||
});
|
||||
@ -387,14 +584,15 @@ describe('uploadServerFactory', () => {
|
||||
|
||||
describe('ALL *', () => {
|
||||
|
||||
it('should respond with 405', done => {
|
||||
const responseFor = (method: string) => `Unsupported method in request: ${method.toUpperCase()} /some/url`;
|
||||
it('should respond with 404', done => {
|
||||
const responseFor = (method: string) => `Unknown resource in request: ${method.toUpperCase()} /some/url`;
|
||||
|
||||
verifyRequests([
|
||||
agent.put('/some/url').expect(405, responseFor('put')),
|
||||
agent.post('/some/url').expect(405, responseFor('post')),
|
||||
agent.patch('/some/url').expect(405, responseFor('patch')),
|
||||
agent.delete('/some/url').expect(405, responseFor('delete')),
|
||||
agent.get('/some/url').expect(404, responseFor('get')),
|
||||
agent.put('/some/url').expect(404, responseFor('put')),
|
||||
agent.post('/some/url').expect(404, responseFor('post')),
|
||||
agent.patch('/some/url').expect(404, responseFor('patch')),
|
||||
agent.delete('/some/url').expect(404, responseFor('delete')),
|
||||
], done);
|
||||
});
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -e -o pipefail
|
||||
set -eu -o pipefail
|
||||
|
||||
# Set up env variables
|
||||
export AIO_GITHUB_TOKEN=$(head -c -1 /aio-secrets/GITHUB_TOKEN 2>/dev/null)
|
||||
|
||||
# Run the clean-up
|
||||
node $AIO_SCRIPTS_JS_DIR/dist/lib/clean-up >> /var/log/aio/clean-up.log 2>&1
|
||||
|
@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
set +e -o pipefail
|
||||
# Using `+e` so that all checks are run and we get a complete report (even if some checks failed).
|
||||
set +e -u -o pipefail
|
||||
|
||||
|
||||
# Variables
|
||||
|
@ -1,11 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -e -o pipefail
|
||||
set -eu -o pipefail
|
||||
|
||||
exec >> /var/log/aio/init.log
|
||||
exec 2>&1
|
||||
|
||||
# Start the services
|
||||
echo [`date`] - Starting services...
|
||||
mkdir -p $AIO_NGINX_LOGS_DIR
|
||||
mkdir -p $TEST_AIO_NGINX_LOGS_DIR
|
||||
|
||||
service rsyslog start
|
||||
service cron start
|
||||
service dnsmasq start
|
||||
|
@ -1,9 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e -o pipefail
|
||||
set -eu -o pipefail
|
||||
|
||||
# Set up env variables for production
|
||||
export AIO_GITHUB_TOKEN=$(head -c -1 /aio-secrets/GITHUB_TOKEN 2>/dev/null)
|
||||
export AIO_PREVIEW_DEPLOYMENT_TOKEN=$(head -c -1 /aio-secrets/PREVIEW_DEPLOYMENT_TOKEN 2>/dev/null)
|
||||
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
|
||||
# TODO(gkalpak): Ideally, the upload server should be run as a non-privileged user.
|
||||
|
@ -1,13 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e -o pipefail
|
||||
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_PREVIEW_DEPLOYMENT_TOKEN=$TEST_AIO_PREVIEW_DEPLOYMENT_TOKEN
|
||||
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
|
||||
|
||||
@ -21,7 +21,7 @@ appName=aio-upload-server-test
|
||||
if [[ "$1" == "stop" ]]; then
|
||||
pm2 delete $appName
|
||||
else
|
||||
pm2 start $AIO_SCRIPTS_JS_DIR/dist/lib/upload-server/index-test.js \
|
||||
pm2 start $AIO_SCRIPTS_JS_DIR/dist/lib/verify-setup/start-test-upload-server.js \
|
||||
--log /var/log/aio/upload-server-test.log \
|
||||
--name $appName \
|
||||
--no-autorestart \
|
||||
|
@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e -o pipefail
|
||||
set -eu -o pipefail
|
||||
|
||||
logFile=/var/log/aio/verify-setup.log
|
||||
uploadServerLogFile=/var/log/aio/upload-server-verify-setup.log
|
||||
|
@ -1,31 +0,0 @@
|
||||
# VM Setup Instructions
|
||||
|
||||
- Set up secrets (access tokens, passwords, etc)
|
||||
- Set up docker
|
||||
- Attach persistent disk
|
||||
- Build docker image (+ checkout repo)
|
||||
- Run image (+ setup for run on boot)
|
||||
|
||||
|
||||
## Build image
|
||||
- `<aio-builds-setup-dir>/build.sh [<name>[:<tag>] [--build-arg <NAME>=<value> ...]]`
|
||||
|
||||
|
||||
## Run image
|
||||
- `sudo docker run \
|
||||
-d \
|
||||
--dns 127.0.0.1 \
|
||||
--name <instance-name> \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
--restart unless-stopped \
|
||||
[-v <host-cert-dir>:/etc/ssl/localcerts:ro] \
|
||||
-v <host-secrets-dir>:/aio-secrets:ro \
|
||||
-v <host-builds-dir>:/var/www/aio-builds \
|
||||
<name>[:<tag>]
|
||||
`
|
||||
|
||||
|
||||
## Questions
|
||||
- Do we care to keep logs (e.g. cron, nginx, aio-upload-server, aio-clean-up, pm2) outside of the container?
|
||||
- Instead of creating new comments for each commit, update the original comment?
|
33
aio/aio-builds-setup/docs/_TOC.md
Normal file
33
aio/aio-builds-setup/docs/_TOC.md
Normal file
@ -0,0 +1,33 @@
|
||||
# VM Setup Instructions
|
||||
|
||||
|
||||
## Overview
|
||||
- [General overview](overview--general.md)
|
||||
- [Security model](overview--security-model.md)
|
||||
- [Available scripts and commands](overview--scripts-and-commands.md)
|
||||
- [HTTP status codes](overview--http-status-codes.md)
|
||||
|
||||
|
||||
## Setting up the VM
|
||||
- [Set up secrets](vm-setup--set-up-secrets.md)
|
||||
- [Set up docker](vm-setup--set-up-docker.md)
|
||||
- [Attach persistent disk](vm-setup--attach-persistent-disk.md)
|
||||
- [Create host directories and files](vm-setup--create-host-dirs-and-files.md)
|
||||
- [Create docker image](vm-setup--create-docker-image.md)
|
||||
|
||||
|
||||
## Configuring the docker image
|
||||
- [Available environment variables](image-config--environment-variables.md)
|
||||
|
||||
|
||||
## Starting the docker container
|
||||
- [Start docker container](vm-setup--start-docker-container.md)
|
||||
|
||||
|
||||
## Updating the docker container
|
||||
- [Update docker container](vm-setup--update-docker-container.md)
|
||||
|
||||
|
||||
## Miscellaneous
|
||||
- [Debug docker container](misc--debug-docker-container.md)
|
||||
- [Integrate with CI](misc--integrate-with-ci.md)
|
@ -0,0 +1,57 @@
|
||||
# Image config - Environment variables
|
||||
|
||||
|
||||
Below is a list of environment variables that can be configured when creating the docker image (as
|
||||
described [here](vm-setup--create-docker-image.md)). An up-to-date list of the configurable
|
||||
environment variables and their default values can be found in the
|
||||
[Dockerfile](../dockerbuild/Dockerfile).
|
||||
|
||||
**Note:**
|
||||
Each variable has a `TEST_` prefixed counterpart, which is used for testing purposes. In most cases
|
||||
you don't need to specify values for those.
|
||||
|
||||
- `AIO_BUILDS_DIR`:
|
||||
The directory (inside the container) where the uploaded build artifacts are kept.
|
||||
|
||||
- `AIO_DOMAIN_NAME`:
|
||||
The domain name of the server.
|
||||
|
||||
- `AIO_GITHUB_ORGANIZATION`:
|
||||
The GitHub organization whose teams are whitelisted for accepting uploads.
|
||||
See also `AIO_GITHUB_TEAM_SLUGS`.
|
||||
|
||||
- `AIO_GITHUB_TEAM_SLUGS`:
|
||||
A comma-separated list of teams, whose authors are allowed to upload PRs.
|
||||
See also `AIO_GITHUB_ORGANIZATION`.
|
||||
|
||||
- `AIO_NGINX_HOSTNAME`:
|
||||
The internal hostname for accessing the nginx server. This is mostly used for performing a
|
||||
periodic health-check.
|
||||
|
||||
- `AIO_NGINX_PORT_HTTP`:
|
||||
The port number on which nginx listens for HTTP connections. This should be mapped to the
|
||||
corresponding port on the host VM (as described [here](vm-setup--start-docker-container.md)).
|
||||
|
||||
- `AIO_NGINX_PORT_HTTPS`:
|
||||
The port number on which nginx listens for HTTPS connections. This should be mapped to the
|
||||
corresponding port on the host VM (as described [here](vm-setup--start-docker-container.md)).
|
||||
|
||||
- `AIO_REPO_SLUG`:
|
||||
The repository slug (in the form `<user>/<repo>`) for which PRs will be uploaded.
|
||||
|
||||
- `AIO_TRUSTED_PR_LABEL`:
|
||||
The PR whose presence indicates the PR has been manually verified and is allowed to have its
|
||||
build artifacts publicly served. This is useful for enabling previews for any PR (not only those
|
||||
from trusted authors).
|
||||
|
||||
- `AIO_UPLOAD_HOSTNAME`:
|
||||
The internal hostname for accessing the Node.js upload-server. This is used by nginx for
|
||||
delegating upload requests and also for performing a periodic health-check.
|
||||
|
||||
- `AIO_UPLOAD_MAX_SIZE`:
|
||||
The maximum allowed size for the uploaded gzip archive containing the build artifacts. Files
|
||||
larger than this will be rejected.
|
||||
|
||||
- `AIO_UPLOAD_PORT`:
|
||||
The port number on which the Node.js upload-server listens for HTTP connections. This is used by
|
||||
nginx for delegating upload requests and also for performing a periodic health-check.
|
12
aio/aio-builds-setup/docs/misc--debug-docker-container.md
Normal file
12
aio/aio-builds-setup/docs/misc--debug-docker-container.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Miscellaneous - Debug docker container
|
||||
|
||||
|
||||
TODO (gkalpak): Add docs. Mention:
|
||||
- `aio-health-check`
|
||||
- `aio-verify-setup`
|
||||
- Test nginx accessible at:
|
||||
- `http://$TEST_AIO_NGINX_HOTNAME:$TEST_AIO_NGINX_PORT_HTTP`
|
||||
- `https://$TEST_AIO_NGINX_HOTNAME:$TEST_AIO_NGINX_PORT_HTTPS`
|
||||
- Test upload-server accessible at:
|
||||
- `http://$TEST_AIO_UPLOAD_HOTNAME:$TEST_AIO_UPLOAD_PORT`
|
||||
- Local DNS (via dnsmasq) maps the above hostnames to 127.0.0.1
|
11
aio/aio-builds-setup/docs/misc--integrate-with-ci.md
Normal file
11
aio/aio-builds-setup/docs/misc--integrate-with-ci.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Miscellaneous - Integrate with CI
|
||||
|
||||
|
||||
TODO (gkalpak): Add docs. Mention:
|
||||
- Travis' JWT addon (+ limitations).
|
||||
Relevant files: `.travis.yml`, `scripts/ci/env.sh`
|
||||
- Testing on CI.
|
||||
Relevant files: `scripts/ci/test-aio.sh`, `aio/aio-builds-setup/scripts/test.sh`
|
||||
- Deploying from CI.
|
||||
Relevant files: `scripts/ci/deploy.sh`, `aio/scripts/deploy-preview.sh`,
|
||||
`aio/scripts/deploy-to-firebase.sh`
|
120
aio/aio-builds-setup/docs/overview--general.md
Normal file
120
aio/aio-builds-setup/docs/overview--general.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Overview - General
|
||||
|
||||
|
||||
## Objective
|
||||
Whenever a PR job is run on Travis, we want to build `angular.io` and upload the build artifacts to
|
||||
a publicly accessible server so that collaborators (developers, designers, authors, etc) can preview
|
||||
the changes without having to checkout and build the app locally.
|
||||
|
||||
|
||||
## Source code
|
||||
In order to make it easier to administer the server and version-control the setup, we are using
|
||||
[docker](https://www.docker.com) to run a container on a VM. The Dockerfile and all other files
|
||||
necessary for creating the docker container are stored (and versioned) along with the angular.io
|
||||
project's source code (currently part of the angular/angular repo) in the `aio-builds-setup/`
|
||||
directory.
|
||||
|
||||
|
||||
## Setup
|
||||
The VM is hosted on [Google Compute Engine](https://cloud.google.com/compute/). The host OS is
|
||||
debian:jessie. For more info how to set up the host VM take a look at the "Setting up the VM"
|
||||
section in [TOC](_TOC.md).
|
||||
|
||||
|
||||
## Security model
|
||||
Since we are managing a public server, it is important to take appropriate measures in order to
|
||||
prevent abuse. For more details on the challenges and the chosen approach take a look at the
|
||||
[security model](overview--security-model.md).
|
||||
|
||||
|
||||
## The 10000 feet view
|
||||
This section gives a brief summary of the several operations performed on CI and by the docker
|
||||
container:
|
||||
|
||||
|
||||
### On CI (Travis)
|
||||
- Build job completes successfully.
|
||||
- The CI script checks whether the build job was initiated by a PR against the angular/angular
|
||||
master branch.
|
||||
- The CI script checks whether the PR has touched any files that might affect the angular.io app
|
||||
(currently the `aio/` or `packages/` directories, ignoring spec files).
|
||||
- Optionally, the CI script can check whether the PR can be automatically verified (i.e. if the
|
||||
author of the PR is a member of one of the whitelisted GitHub teams or the PR has the specified
|
||||
"trusted PR" label).
|
||||
**Note:**
|
||||
For security reasons, the same checks will be performed on the server as well. This is an optional
|
||||
step that can be used in case one wants to apply special logic depending on the outcome of the
|
||||
pre-verification. For example:
|
||||
1. One might want to deploy automatically verified PRs only. In that case, the pre-verification
|
||||
helps avoid the wasted overhead associated with uploads that are going to be rejected (e.g.
|
||||
building the artifacts, sending them to the server, running checks on the server, detecting the
|
||||
reasons of deployment failure and whether to fail the build, etc).
|
||||
2. One might want to apply additional logic (e.g. different tests) depending on whether the PR is
|
||||
automatically verified or not).
|
||||
- The CI script gzips and uploads the build artifacts to the server.
|
||||
|
||||
More info on how to set things up on CI can be found [here](misc--integrate-with-ci.md).
|
||||
|
||||
|
||||
### Uploading build artifacts
|
||||
- nginx receives the upload request.
|
||||
- nginx checks that the uploaded gzip archive does not exceed the specified max file size, stores it
|
||||
in a temporary location and passes the filepath to the Node.js upload-server.
|
||||
- The upload-server runs several checks to determine whether the request should be accepted and
|
||||
whether it should be publicly accessible or stored for later verification (more details can be
|
||||
found [here](overview--security-model.md)).
|
||||
- The upload-server changes the "visibility" of the associated PR, if necessary. For example, if
|
||||
builds for the same PR had been previously deployed as non-public and the current build has been
|
||||
automatically verified, all previous builds are made public as well.
|
||||
If the PR transitions from "non-public" to "public", the upload-server posts a comment on the
|
||||
corresponding PR on GitHub mentioning the SHAs and the links where the previews can be found.
|
||||
- The upload-server verifies that the uploaded file is not trying to overwrite an existing build.
|
||||
- The upload-server deploys the artifacts to a sub-directory named after the PR number and the first
|
||||
few characters of the SHA: `<PR>/<SHA>/`
|
||||
(Non-publicly accessible PRs will be stored in a different location, but again derived from the PR
|
||||
number and SHA.)
|
||||
- If the PR is publicly accessible, the upload-server posts a comment on the corresponding PR on
|
||||
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
|
||||
[here](overview--http-status-codes.md).
|
||||
|
||||
|
||||
### Updating PR visibility
|
||||
- nginx receives a natification that a PR has been updated and passes it through to the
|
||||
upload-server. This could, for example, be sent by a GitHub webhook every time a PR's labels
|
||||
change.
|
||||
E.g.: `ngbuilds.io/pr-updated` (payload: `{"number":<PR>,"action":"labeled"}`)
|
||||
- The request contains the PR number (as `number`) and optionally the action that triggered the
|
||||
request (as `action`) in the payload.
|
||||
- The upload-server verifies the payload and determines whether the `action` (if specified) could
|
||||
have led to PR visibility changes. Only requests that omit the `action` field altogether or
|
||||
specify an action that can affect visibility are further processed.
|
||||
(Currently, the only actions that are considered capable of affecting visibility are `labeled` and
|
||||
`unlabeled`.)
|
||||
- The upload-server re-checks and if necessary updates the PR's visibility.
|
||||
|
||||
More info on the possible HTTP status codes and their meaning can be found
|
||||
[here](overview--http-status-codes.md).
|
||||
|
||||
|
||||
### Serving build artifacts
|
||||
- nginx receives a request for an uploaded resource on a subdomain corresponding to the PR and SHA.
|
||||
E.g.: `pr<PR>-<SHA>.ngbuilds.io/path/to/resource`
|
||||
- nginx maps the subdomain to the correct sub-directory and serves the resource.
|
||||
E.g.: `/<PR>/<SHA>/path/to/resource`
|
||||
|
||||
More info on the possible HTTP status codes and their meaning can be found
|
||||
[here](overview--http-status-codes.md).
|
||||
|
||||
|
||||
### Removing obsolete artifacts
|
||||
In order to avoid flooding the disk with unnecessary build artifacts, there is a cronjob that runs a
|
||||
clean-up tasks once a day. The task retrieves all open PRs from GitHub and removes all directories
|
||||
that do not correspond with an open PR.
|
||||
|
||||
|
||||
### Health-check
|
||||
The docker service runs a periodic health-check that verifies the running conditions of the
|
||||
container. This includes verifying the status of specific system services, the responsiveness of
|
||||
nginx and the upload-server and internet connectivity.
|
84
aio/aio-builds-setup/docs/overview--http-status-codes.md
Normal file
84
aio/aio-builds-setup/docs/overview--http-status-codes.md
Normal file
@ -0,0 +1,84 @@
|
||||
# Overview - HTTP Status Codes
|
||||
|
||||
|
||||
This is a list of all the possible HTTP status codes returned by the nginx anf upload servers, along
|
||||
with a bried explanation of what they mean:
|
||||
|
||||
|
||||
## `http://*.ngbuilds.io/*`
|
||||
|
||||
- **307 (Temporary Redirect)**:
|
||||
All non-HTTPS requests. 308 (Permanent Redirect) would be more appropriate, but is not supported
|
||||
by all agents (e.g. cURL).
|
||||
|
||||
|
||||
## `https://pr<pr>-<sha>.ngbuilds.io/*`
|
||||
|
||||
- **200 (OK)**:
|
||||
File was found or URL was rewritten to `/index.html` (i.e. all paths that have no `.` in final
|
||||
segment).
|
||||
|
||||
- **403 (Forbidden)**:
|
||||
Trying to access a sub-directory.
|
||||
|
||||
- **404 (Not Found)**:
|
||||
File not found.
|
||||
|
||||
|
||||
## `https://ngbuilds.io/create-build/<pr>/<sha>`
|
||||
|
||||
- **201 (Created)**:
|
||||
Build deployed successfully and is publicly available.
|
||||
|
||||
- **202 (Accepted)**:
|
||||
Build not automatically verifiable. Stored for later deployment (after re-verification).
|
||||
|
||||
- **400 (Bad Request)**:
|
||||
No payload.
|
||||
|
||||
- **401 (Unauthorized)**:
|
||||
No `AUTHORIZATION` header.
|
||||
|
||||
- **403 (Forbidden)**:
|
||||
Unable to verify build (e.g. invalid JWT token, or unable to talk to 3rd-party APIs, etc).
|
||||
|
||||
- **405 (Method Not Allowed)**:
|
||||
Request method other than POST.
|
||||
|
||||
- **409 (Conflict)**:
|
||||
Request to overwrite existing directory (e.g. deploy existing build or change PR visibility when
|
||||
the destination directory does already exist).
|
||||
|
||||
- **413 (Payload Too Large)**:
|
||||
Payload larger than size specified in `AIO_UPLOAD_MAX_SIZE`.
|
||||
|
||||
|
||||
## `https://ngbuilds.io/health-check`
|
||||
|
||||
- **200 (OK)**:
|
||||
The server is healthy (i.e. up and running and processing requests).
|
||||
|
||||
|
||||
## `https://ngbuilds.io/pr-updated`
|
||||
|
||||
- **200 (OK)**:
|
||||
Request processed successfully. Processing may or may not have resulted in further actions.
|
||||
|
||||
- **400 (Bad Request)**:
|
||||
No payload or no `number` field in payload.
|
||||
|
||||
- **405 (Method Not Allowed)**:
|
||||
Request method other than POST.
|
||||
|
||||
- **409 (Conflict)**:
|
||||
Request to overwrite existing directory (i.e. directories for both visibilities exist).
|
||||
(Normally, this should not happen.)
|
||||
|
||||
|
||||
## `https://*.ngbuilds.io/*`
|
||||
|
||||
- **404 (Not Found)**:
|
||||
Request not matched by the above rules.
|
||||
|
||||
- **500 (Internal Server Error)**:
|
||||
Error while processing a request matched by the above rules.
|
53
aio/aio-builds-setup/docs/overview--scripts-and-commands.md
Normal file
53
aio/aio-builds-setup/docs/overview--scripts-and-commands.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Overview - Scripts and Commands
|
||||
|
||||
|
||||
This is an overview of the available scripts and commands.
|
||||
|
||||
|
||||
## Scripts
|
||||
The scripts are located inside `<aio-builds-setup-dir>/scripts/`. The following scripts are
|
||||
available:
|
||||
|
||||
- `create-image.sh`:
|
||||
Can be used for creating a preconfigured docker image.
|
||||
See [here](vm-setup--create-docker-image.md) for more info.
|
||||
|
||||
- `test.sh`:
|
||||
Can be used for running the tests for `<aio-builds-setup-dir>/dockerbuild/scripts-js/`. This is
|
||||
useful for CI integration. See [here](misc--integrate-with-ci.md) for more info.
|
||||
|
||||
- `update-preview-server.sh`:
|
||||
Can be used for updating the docker container (and image) based on the latest changes checked out
|
||||
from a git repository. See [here](vm-setup--update-docker-container.md) for more info.
|
||||
|
||||
|
||||
## Commands
|
||||
The following commands are available globally from inside the docker container. They are either used
|
||||
by the container to perform its various operations or can be used ad-hoc, mainly for testing
|
||||
purposes. Each command is backed by a corresponding script inside
|
||||
`<aio-builds-setup-dir>/dockerbuild/scripts-sh/`.
|
||||
|
||||
- `aio-clean-up`:
|
||||
Cleans up the builds directory by removing the artifacts that do not correspond to an open PR.
|
||||
_It is run as a daily cronjob._
|
||||
|
||||
- `aio-health-check`:
|
||||
Runs a basic health-check, verifying that the necessary services are running, the servers are
|
||||
responding and there is a working internet connection.
|
||||
_It is used periodically by docker for determining the container's health status._
|
||||
|
||||
- `aio-init`:
|
||||
Initializes the container (mainly by starting the necessary services).
|
||||
_It is run (by default) when starting the container._
|
||||
|
||||
- `aio-upload-server-prod`:
|
||||
Spins up a Node.js upload-server instance.
|
||||
_It is used in `aio-init` (see above) during initialization._
|
||||
|
||||
- `aio-upload-server-test`:
|
||||
Spins up a Node.js upload-server instance for tests.
|
||||
_It is used in `aio-verify-setup` (see below) for running tests._
|
||||
|
||||
- `aio-verify-setup`:
|
||||
Runs a suite of e2e-like tests, mainly verifying the correct (inter)operation of nginx and the
|
||||
Node.js upload-server.
|
138
aio/aio-builds-setup/docs/overview--security-model.md
Normal file
138
aio/aio-builds-setup/docs/overview--security-model.md
Normal file
@ -0,0 +1,138 @@
|
||||
# Overview - Security model
|
||||
|
||||
|
||||
Whenever a PR job is run on Travis, we want to build `angular.io` and upload the build artifacts to
|
||||
a publicly accessible server so that collaborators (developers, designers, authors, etc) can preview
|
||||
the changes without having to checkout and build the app locally.
|
||||
|
||||
This document discusses the security considerations associated with uploading build artifacts as
|
||||
part of the CI setup and serving them publicly.
|
||||
|
||||
|
||||
## Security objectives
|
||||
|
||||
- **Prevent uploading arbitrary content to our servers.**
|
||||
Since there is no restriction on who can submit a PR, we cannot allow any PR's build artifacts to
|
||||
be uploaded.
|
||||
|
||||
- **Prevent overwriting other peoples uploaded content.**
|
||||
There needs to be a mechanism in place to ensure that the uploaded content does indeed correspond
|
||||
to the PR indicated by its URL.
|
||||
|
||||
- **Prevent arbitrary access on the server.**
|
||||
Since the PR author has full access over the build artifacts that would be uploaded, we must
|
||||
ensure that the uploaded files will not enable arbitrary access to the server or expose sensitive
|
||||
info.
|
||||
|
||||
|
||||
## Issues / Caveats
|
||||
|
||||
- Because the PR author can change the scripts run on CI, any security mechanisms must be immune to
|
||||
such changes.
|
||||
|
||||
- For security reasons, encrypted Travis variables are not available to PRs, so we can't rely on
|
||||
them to implement security.
|
||||
|
||||
|
||||
## Implemented approach
|
||||
|
||||
|
||||
### In a nutshell
|
||||
The implemented approach can be broken up to the following sub-tasks:
|
||||
|
||||
1. Verify which PR the uploaded artifacts correspond to.
|
||||
2. Fetch the PR's metadata, including author and labels.
|
||||
3. Check whether the PR can be automatically verified as "trusted" (based on its author or labels).
|
||||
4. If necessary, update the corresponding PR's verification status.
|
||||
5. Deploy the artifacts to the corresponding PR's directory.
|
||||
6. Prevent overwriting previously deployed artifacts (which ensures that the guarantees established
|
||||
during deployment will remain valid until the artifacts are removed).
|
||||
7. Prevent uploaded files from accessing anything outside their directory.
|
||||
|
||||
|
||||
### Implementation details
|
||||
This section describes how each of the aforementioned sub-tasks is accomplished:
|
||||
|
||||
1. **Verify which PR the uploaded artifacts correspond to.**
|
||||
|
||||
We are taking advantage of Travis' [JWT addon](https://docs.travis-ci.com/user/jwt). By sharing
|
||||
a secret between Travis (which keeps it private but uses it to sign a JWT) and the server (which
|
||||
uses it to verify the authenticity of the JWT), we can accomplish the following:
|
||||
a. Verify that the upload request comes from Travis.
|
||||
b. Determine the PR that these artifacts correspond to (since Travis puts that information into
|
||||
the JWT, without the PR author being able to modify it).
|
||||
|
||||
_Note:_
|
||||
_There are currently certain limitation in the implementation of the JWT addon._
|
||||
_See the next section for more details._
|
||||
|
||||
2. **Fetch the PR's metadata, including author and labels**.
|
||||
|
||||
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
|
||||
[GitHub API](https://developer.github.com/v3/).
|
||||
To avoid rate-limit restrictions, we use a Personal Access Token (issued by
|
||||
[@mary-poppins](https://github.com/mary-poppins)).
|
||||
|
||||
3. **Check whether the PR can be automatically verified as "trusted"**.
|
||||
|
||||
"Trusted" means that we are confident that the build artifacts are suitable for being deployed
|
||||
and publicly accessible on the preview server. There are two ways to check that:
|
||||
1. We can verify that the PR has a pre-determined label, which marks it as "safe for preview".
|
||||
Such a label can only have been added by a maintainer (with the necessary rights) and
|
||||
designates that they have manually verified the PR contents.
|
||||
2. We can verify (again using the GitHub API) the author's membership in one of the
|
||||
whitelisted/trusted GitHub teams. For this operation, we need a Personal Access Token with the
|
||||
`read:org` scope issued by a user that can "see" the specified GitHub organization.
|
||||
Here too, we use the token by @mary-poppins.
|
||||
|
||||
4. **If necessary update the corresponding PR's verification status**.
|
||||
|
||||
Once we have determined whether the PR is considered "trusted", we update its "visibility" (i.e.
|
||||
whether it is publicly accessible or not), based on the new verification status. For example, if
|
||||
a PR was initially considered "not trusted" but the check triggered by a new build determined
|
||||
otherwise, the PR (and all the previously uploaded previews) are made public. It works the same
|
||||
way if a PR has gone from "trusted" to "not trusted".
|
||||
|
||||
5. **Deploy the artifacts to the corresponding PR's directory.**
|
||||
|
||||
With the preceding steps, we have verified that the uploaded artifacts have been uploaded by
|
||||
Travis. Additionally, we have determined whether the PR can be trusted to have its previews
|
||||
publicly accessible or whether further verification is necessary. The artifacts will be stored to
|
||||
the PR's directory, but will not be publicly accessible unless the PR has been verified.
|
||||
Essentially, as long as sub-tasks 1, 2 and 3 can be securely accomplished, it is possible to
|
||||
"project" the trust we have in a team's members through the PR and Travis to the build artifacts.
|
||||
|
||||
6. **Prevent overwriting previously deployed artifacts**.
|
||||
|
||||
In order to enforce this restriction (and ensure that the deployed artifacts' validity is
|
||||
preserved throughout their "lifetime"), the server that handles the upload (currently a Node.js
|
||||
Express server) rejects uploads that target an existing directory.
|
||||
_Note: A PR can contain multiple uploads; one for each SHA that was built on Travis._
|
||||
|
||||
7. **Prevent uploaded files from accessing anything outside their directory.**
|
||||
|
||||
Nginx (which is used to serve the uploaded artifacts) has been configured to not follow symlinks
|
||||
outside of the directory where the build artifacts are stored.
|
||||
|
||||
|
||||
## Assumptions / Things to keep in mind
|
||||
|
||||
- Each trusted PR author has full control over the content that is uploaded for their PRs. Part of
|
||||
the security model relies on the trustworthiness of these authors.
|
||||
|
||||
- Adding the specified label on a PR and marking it as trusted, gives the author full control over
|
||||
the content that is uploaded for the specific PR (e.g. by pushing more commits to it). The user
|
||||
adding the label is responsible for ensuring that this control is not abused and that the PR is
|
||||
either closed (one way of another) or the access is revoked.
|
||||
|
||||
- If anyone gets access to the `PREVIEW_DEPLOYMENT_TOKEN` (a.k.a. `NGBUILDS_IO_KEY` on
|
||||
angular/angular) variable generated for each Travis job, they will be able to impersonate the
|
||||
corresponding PR's author on the preview server for as long as the token is valid (currently 90
|
||||
mins). Because of this, the value of the `PREVIEW_DEPLOYMENT_TOKEN` should not be made publicly
|
||||
accessible (e.g. by printing it on the Travis job log).
|
||||
|
||||
- Travis does only allow specific whitelisted property names to be used with the JWT addon. The only
|
||||
known such property at the time is `SAUCE_ACCESS_KEY` (used for integration with SauceLabs). In
|
||||
order to be able to actually use the JWT addon we had to name the encrypted variable
|
||||
`SAUCE_ACCESS_KEY` (which we later re-assign to `NGBUILDS_IO_KEY`).
|
@ -13,5 +13,8 @@
|
||||
|
||||
|
||||
## Mount disk on boot
|
||||
- ``echo UUID=`sudo blkid -s UUID -o value /dev/disk/by-id/google-aio-builds` \
|
||||
/mnt/disks/aio-builds ext4 discard,defaults,nofail 0 2 | sudo tee -a /etc/fstab``
|
||||
- Run:
|
||||
```
|
||||
echo UUID=`sudo blkid -s UUID -o value /dev/disk/by-id/google-aio-builds` \
|
||||
/mnt/disks/aio-builds ext4 discard,defaults,nofail 0 2 | sudo tee -a /etc/fstab
|
||||
```
|
32
aio/aio-builds-setup/docs/vm-setup--create-docker-image.md
Normal file
32
aio/aio-builds-setup/docs/vm-setup--create-docker-image.md
Normal file
@ -0,0 +1,32 @@
|
||||
# VM setup - Create docker image
|
||||
|
||||
|
||||
## Checkout repository
|
||||
- `git clone <repo-url>`
|
||||
|
||||
|
||||
## Build docker image
|
||||
- `<aio-builds-setup-dir>/scripts/create-image.sh [<name>[:<tag>] [--build-arg <NAME>=<value> ...]]`
|
||||
- You can overwrite the default environment variables inside the image, by passing new values using
|
||||
`--build-arg`.
|
||||
|
||||
**Note:** The script has to execute docker commands with `sudo`.
|
||||
|
||||
|
||||
## Example
|
||||
The following commands would create a docker image from GitHub repo `foo/bar` to be deployed on the
|
||||
`foobar-builds.io` domain and accepting PR deployments from authors that are members of the
|
||||
`bar-core` and `bar-docs-authors` teams of organization `foo`:
|
||||
|
||||
- `git clone https://github.com/foo/bar.git foobar`
|
||||
- Run:
|
||||
```
|
||||
./foobar/aio-builds-setup/scripts/build.sh foobar-builds \
|
||||
--build-arg AIO_REPO_SLUG=foo/bar \
|
||||
--build-arg AIO_DOMAIN_NAME=foobar-builds.io \
|
||||
--build-arg AIO_GITHUB_ORGANIZATION=foo \
|
||||
--build-arg AIO_GITHUB_TEAM_SLUGS=bar-core,bar-docs-authors
|
||||
```
|
||||
|
||||
A full list of the available environment variables can be found
|
||||
[here](image-config--environment-variables.md).
|
@ -0,0 +1,75 @@
|
||||
# VM setup - Create host directories and files
|
||||
|
||||
|
||||
## Create directory with secrets
|
||||
For security reasons, sensitive info (such as tokens and passwords) are not hardcoded into the
|
||||
docker image, nor passed as environment variables at runtime. They are passed to the docker
|
||||
container from the host VM as files inside a directory. Each file's name is the name of the variable
|
||||
and the file content is the value. These are read from inside the running container when necessary.
|
||||
|
||||
More info on how to create `secrets` directory and files can be found
|
||||
[here](vm-setup--set-up-secrets.md).
|
||||
|
||||
|
||||
## Create directory for build artifacts
|
||||
The uploaded build artifacts should be kept on a directory outside the docker container, so it is
|
||||
easier to replace the container without losing the uploaded builds. For portability across VMs a
|
||||
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`.
|
||||
|
||||
|
||||
## Create SSL certificates (Optional for dev)
|
||||
The host VM can attach a directory containing the SSL certificate and key to be used by the nginx
|
||||
server for serving the uploaded build artifacts. More info on how to attach the directory when
|
||||
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
|
||||
`<DOMAIN_NAME>.crt` and `<DOMAIN_NAME>.key` respectively. For example, for a domain name
|
||||
`ngbuild.io`, nginx will look for files `ngbuilds.io.crt` and `ngbuilds.io.key`. More info on how to
|
||||
specify the domain name see [here](vm-setup--create-docker-image.md).
|
||||
|
||||
If no directory is attached, nginx will use an internal self-signed certificate. This is convenient
|
||||
during development, but is not suitable for production.
|
||||
|
||||
**Note:**
|
||||
Since nginx needs to be able to serve requests for both the main domain as well as any subdomain
|
||||
(e.g. `ngbuilds.io/` and `foo-bar.ngbuilds.io/`), the provided certificate needs to be a wildcard
|
||||
certificate covering both the domain and subdomains.
|
||||
|
||||
|
||||
## Create directory for logs (Optional)
|
||||
Optionally, a logs directory can pe passed to the docker container for storing non-system-related
|
||||
logs. If not provided, the logs are kept locally on the container and will be lost whenever the
|
||||
container is replaced (e.g. when updating to use a newer version of the docker image). Log files are
|
||||
rotated and retained for 6 months.
|
||||
|
||||
The following log files are kept in this directory:
|
||||
|
||||
- `clean-up.log`:
|
||||
Output of the `aio-clean-up` command, run as a cronjob for cleaning up the build artifacts of
|
||||
closed PRs.
|
||||
|
||||
- `init.log`:
|
||||
Output of the `aio-init` command, run (by default) when starting the container.
|
||||
|
||||
- `nginx/{access,error}.log`:
|
||||
The access and error logs produced by the nginx server while serving "production" files.
|
||||
|
||||
- `nginx-test/{access,error}.log`:
|
||||
The access and error logs produced by the nginx server while serving "test" files. This is only
|
||||
used when running tests locally from inside the container, e.g. with the `aio-verify-setup`
|
||||
command. (See [here](overview--scripts-and-commands.md) for more info.)
|
||||
|
||||
- `upload-server-{prod,test,verify-setup}-*.log`:
|
||||
The logs produced by the Node.js upload-server while serving either:
|
||||
- `-prod`: "Production" files (g.g during normal operation).
|
||||
- `-test`: "Test" files (e.g. when a test instance is started with the `aio-upload-server-test`
|
||||
command).
|
||||
- `-verify-setup`: "Test" files, but while running `aio-verify-setup`.
|
||||
|
||||
(See [here](overview--scripts-and-commands.md) for more info the commands mentioned above.)
|
||||
|
||||
- `verify-setup.log`:
|
||||
The output of the `aio-verify-setup` command (e.g. Jasmine output), except for upload-server
|
||||
output which is logged to `upload-server-verify-setup-*.log` (see above).
|
@ -0,0 +1,92 @@
|
||||
# VM setup - Start docker container
|
||||
|
||||
|
||||
## The `docker run` command
|
||||
Once everything has been setup and configured, a docker container can be started with the following
|
||||
command:
|
||||
|
||||
```
|
||||
sudo docker run \
|
||||
--detach \
|
||||
--dns 127.0.0.1 \
|
||||
--name <instance-name> \
|
||||
--publish 80:80 \
|
||||
--publish 443:443 \
|
||||
--restart unless-stopped \
|
||||
[--volume <host-cert-dir>:/etc/ssl/localcerts:ro] \
|
||||
--volume <host-secrets-dir>:/aio-secrets:ro \
|
||||
--volume <host-builds-dir>:/var/www/aio-builds \
|
||||
[--volume <host-logs-dir>:/var/log/aio] \
|
||||
<name>[:<tag>]
|
||||
```
|
||||
|
||||
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/).
|
||||
|
||||
```
|
||||
sudo docker run \
|
||||
|
||||
# Start as a daemon.
|
||||
--detach \
|
||||
|
||||
# Use the local DNS server.
|
||||
# (This is necessary for mapping internal URLs, e.g. for the Node.js upload-server.)
|
||||
--dns 127.0.0.1 \
|
||||
|
||||
# USe `<instance-name>` as an alias for the container.
|
||||
# Useful for running `docker` commands, e.g.: `docker stop <instance-name>`
|
||||
--name <instance-name> \
|
||||
|
||||
# Map ports of the host VM (left) to ports of the docker container (right)
|
||||
--publish 80:80 \
|
||||
--publish 443:443 \
|
||||
|
||||
# Automatically restart the container (unless it was explicitly stopped by the user).
|
||||
# (This ensures that the container will be automatically started on boot.)
|
||||
--restart unless-stopped \
|
||||
|
||||
# The directory the contains the SSL certificates.
|
||||
# (See [here](vm-setup--create-host-dirs-and-files.md) for more info.)
|
||||
# If not provided, the container will use self-signed certificates.
|
||||
[--volume <host-cert-dir>:/etc/ssl/localcerts:ro] \
|
||||
|
||||
# The directory the contains the secrets (e.g. GitHub token, JWT secret, etc).
|
||||
# (See [here](vm-setup--set-up-secrets.md) for more info.)
|
||||
--volume <host-secrets-dir>:/aio-secrets:ro \
|
||||
|
||||
# The uploaded build artifacts will stored to and served from this directory.
|
||||
# (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.
|
||||
# (See [here](vm-setup--create-host-dirs-and-files.md) for more info.)
|
||||
# If not provided, the logs will be kept inside the container, which means they will be lost
|
||||
# whenever a new container is created.
|
||||
[--volume <host-logs-dir>:/var/log/aio] \
|
||||
|
||||
# The name of the docker image to use (and an optional tag; defaults to `latest`).
|
||||
# (See [here](vm-setup--create-docker-image.md) for instructions on how to create the iamge.)
|
||||
<name>[:<tag>]
|
||||
```
|
||||
|
||||
|
||||
## Example
|
||||
The following command would start a docker container based on the previously created `foobar-builds`
|
||||
docker image, alias it as 'foobar-builds-1' and map predefined directories on the host VM to be used
|
||||
by the container for accesing secrets and SSL certificates and keeping the build artifacts and logs.
|
||||
|
||||
```
|
||||
sudo docker run \
|
||||
--detach \
|
||||
--dns 127.0.0.1 \
|
||||
--name foobar-builds-1 \
|
||||
--publish 80:80 \
|
||||
--publish 443:443 \
|
||||
--restart unless-stopped \
|
||||
--volume /etc/ssl/localcerts:/etc/ssl/localcerts:ro \
|
||||
--volume /foobar-secrets:/aio-secrets:ro \
|
||||
--volume /mnt/disks/foobar-builds:/var/www/aio-builds \
|
||||
--volume /foobar-logs:/var/log/aio \
|
||||
foobar-builds
|
||||
```
|
@ -0,0 +1,52 @@
|
||||
# VM setup - Update docker container
|
||||
|
||||
|
||||
## Overview
|
||||
Assuming you have cloned the repository containing the preview server code (as described
|
||||
[here](vm-setup--create-docker-image.md)), you can use the `update-preview-server.sh` script on the
|
||||
VM host to update the preview server based on changes in the source code.
|
||||
|
||||
The script will pull the latest changes from the origin's master branch and examine if there have
|
||||
been any changes in files inside the preview server source code directory (see below). If there are,
|
||||
it will create a new image and verify that is works as expected. Finally, it will stop and remove
|
||||
the old docker container and image, create and new container based on the new image and start it.
|
||||
|
||||
The script assumes that the preview server source code is in the repository's
|
||||
`aio/aio-builds-setup/` directory and expects the following inputs:
|
||||
|
||||
- **$1**: `HOST_REPO_DIR`
|
||||
- **$2**: `HOST_LOCALCERTS_DIR`
|
||||
- **$3**: `HOST_SECRETS_DIR`
|
||||
- **$4**: `HOST_BUILDS_DIR`
|
||||
- **$5**: `HOST_LOGS_DIR`
|
||||
|
||||
See [here](vm-setup--create-host-dirs-and-files.md) for more info on what each input directory is
|
||||
used for.
|
||||
|
||||
**Note 1:** The script has to execute docker commands with `sudo`.
|
||||
|
||||
**Note 2:** Make sure the user that executes the script has access to update the repository
|
||||
|
||||
|
||||
## Run the script manually
|
||||
You may choose to manually run the script, when necessary. Example:
|
||||
|
||||
```
|
||||
update-preview-server.sh \
|
||||
/path/to/repo \
|
||||
/path/to/localcerts \
|
||||
/path/to/secrets \
|
||||
/path/to/builds \
|
||||
/path/to/logs
|
||||
```
|
||||
|
||||
|
||||
## Run the script automatically
|
||||
You may choose to automatically trigger the script, e.g. using a cronjob. For example, the following
|
||||
cronjob entry would run the script every hour and update the preview server (assuming the user has
|
||||
the necessary permissions):
|
||||
|
||||
```
|
||||
# Periodically check for changes and update the preview server (if necessary)
|
||||
*/30 * * * * /path/to/update-preview-server.sh /path/to/repo /path/to/localcerts /path/to/secrets /path/to/builds /path/to/logs
|
||||
```
|
@ -2,14 +2,16 @@
|
||||
set -eux -o pipefail
|
||||
|
||||
# Set up env
|
||||
source "`dirname $0`/env.sh"
|
||||
source "`dirname $0`/_env.sh"
|
||||
readonly defaultImageNameAndTag="aio-builds:latest"
|
||||
|
||||
# Build `scripts-js/`
|
||||
cd "$SCRIPTS_JS_DIR"
|
||||
yarn install
|
||||
yarn run build
|
||||
cd -
|
||||
# (Necessary, because only `scripts-js/dist/` is copied to the docker image.)
|
||||
(
|
||||
cd "$SCRIPTS_JS_DIR"
|
||||
yarn install
|
||||
yarn build
|
||||
)
|
||||
|
||||
# Create docker image
|
||||
readonly nameAndOptionalTag=${1:-$defaultImageNameAndTag}
|
@ -2,10 +2,11 @@
|
||||
set -eux -o pipefail
|
||||
|
||||
# Set up env
|
||||
source "`dirname $0`/env.sh"
|
||||
source "`dirname $0`/_env.sh"
|
||||
|
||||
# Test `scripts-js/`
|
||||
cd "$SCRIPTS_JS_DIR"
|
||||
yarn install
|
||||
yarn test
|
||||
cd -
|
||||
(
|
||||
cd "$SCRIPTS_JS_DIR"
|
||||
yarn install
|
||||
yarn test
|
||||
)
|
||||
|
@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -eux -o pipefail
|
||||
|
||||
# Set up env
|
||||
source "`dirname $0`/env.sh"
|
||||
|
||||
# Preverify PR
|
||||
AIO_GITHUB_ORGANIZATION="angular" \
|
||||
AIO_GITHUB_TEAM_SLUGS="angular-core" \
|
||||
AIO_GITHUB_TOKEN=$(echo ${GITHUB_TEAM_MEMBERSHIP_CHECK_KEY} | rev) \
|
||||
AIO_REPO_SLUG=$TRAVIS_REPO_SLUG \
|
||||
AIO_PREVERIFY_PR=$TRAVIS_PULL_REQUEST \
|
||||
node "$SCRIPTS_JS_DIR/dist/lib/upload-server/index-preverify-pr"
|
70
aio/aio-builds-setup/scripts/update-preview-server.sh
Executable file
70
aio/aio-builds-setup/scripts/update-preview-server.sh
Executable file
@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eux -o pipefail
|
||||
exec 3>&1
|
||||
|
||||
echo "[`date`] - Updating the preview server..."
|
||||
|
||||
# Input
|
||||
readonly HOST_REPO_DIR=$1
|
||||
readonly HOST_LOCALCERTS_DIR=$2
|
||||
readonly HOST_SECRETS_DIR=$3
|
||||
readonly HOST_BUILDS_DIR=$4
|
||||
readonly HOST_LOGS_DIR=$5
|
||||
|
||||
# Constants
|
||||
readonly PROVISIONAL_IMAGE_NAME=aio-builds:provisional
|
||||
readonly LATEST_IMAGE_NAME=aio-builds:latest
|
||||
readonly CONTAINER_NAME=aio
|
||||
|
||||
# Run
|
||||
(
|
||||
cd "$HOST_REPO_DIR"
|
||||
|
||||
readonly lastDeployedCommit=$(git rev-parse HEAD)
|
||||
echo "Currently at commit $lastDeployedCommit."
|
||||
|
||||
# Pull latest master from origin.
|
||||
git pull origin master
|
||||
|
||||
# Do not update the server unless files inside `aio-builds-setup/` have changed
|
||||
# or the last attempt failed (identified by the provisional image still being around).
|
||||
readonly relevantChangedFilesCount=$(git diff --name-only $lastDeployedCommit...HEAD | grep -P "^aio/aio-builds-setup/" | wc -l)
|
||||
readonly lastAttemptFailed=$(sudo docker rmi "$PROVISIONAL_IMAGE_NAME" >> /dev/fd/3 && echo "true" || echo "false")
|
||||
if [[ $relevantChangedFilesCount -eq 0 ]] && [[ "$lastAttemptFailed" != "true" ]]; then
|
||||
echo "Skipping update because no relevant files have been touched."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create and verify a new docker image.
|
||||
aio/aio-builds-setup/scripts/create-image.sh "$PROVISIONAL_IMAGE_NAME"
|
||||
readonly imageVerified=$(sudo docker run --dns 127.0.0.1 --rm --volume $HOST_SECRETS_DIR:/aio-secrets:ro "$PROVISIONAL_IMAGE_NAME" /bin/bash -c "aio-init && aio-health-check && aio-verify-setup" >> /dev/fd/3 && echo "true" || echo "false")
|
||||
|
||||
if [[ "$imageVerified" != "true" ]]; then
|
||||
echo "Failed to verify new docker image. Aborting update!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove the old container and replace the docker image.
|
||||
sudo docker stop "$CONTAINER_NAME" || true
|
||||
sudo docker rm "$CONTAINER_NAME" || true
|
||||
sudo docker rmi "$LATEST_IMAGE_NAME" || true
|
||||
sudo docker tag "$PROVISIONAL_IMAGE_NAME" "$LATEST_IMAGE_NAME"
|
||||
sudo docker rmi "$PROVISIONAL_IMAGE_NAME"
|
||||
|
||||
# Create and start a docker container based on the new image.
|
||||
sudo docker run \
|
||||
--detach \
|
||||
--dns 127.0.0.1 \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--publish 80:80 \
|
||||
--publish 443:443 \
|
||||
--restart unless-stopped \
|
||||
--volume $HOST_LOCALCERTS_DIR:/etc/ssl/localcerts:ro \
|
||||
--volume $HOST_SECRETS_DIR:/aio-secrets:ro \
|
||||
--volume $HOST_BUILDS_DIR:/var/www/aio-builds \
|
||||
--volume $HOST_LOGS_DIR:/var/log/aio \
|
||||
"$LATEST_IMAGE_NAME"
|
||||
|
||||
echo "The new docker image has been successfully deployed."
|
||||
)
|
@ -1,18 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Bootstrapping
|
||||
@cheatsheetIndex 0
|
||||
@description
|
||||
{@target ts}`import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';`{@endtarget}
|
||||
{@target js}Available from the `ng.platformBrowserDynamic` namespace{@endtarget}
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`platformBrowserDynamic().bootstrapModule(AppModule);`|`platformBrowserDynamic().bootstrapModule`
|
||||
syntax(js):
|
||||
`document.addEventListener('DOMContentLoaded', function() {
|
||||
ng.platformBrowserDynamic
|
||||
.platformBrowserDynamic()
|
||||
.bootstrapModule(app.AppModule);
|
||||
});`|`platformBrowserDynamic().bootstrapModule`
|
||||
description:
|
||||
Bootstraps the app, using the root component from the specified `NgModule`. {@target js}Must be wrapped in the event listener to fire when the page loads.{@endtarget}
|
@ -1,34 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Built-in directives
|
||||
@cheatsheetIndex 3
|
||||
@description
|
||||
{@target ts}`import { CommonModule } from '@angular/common';`{@endtarget}
|
||||
{@target js}Available using the `ng.common.CommonModule` module{@endtarget}
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<section *ngIf="showSection">`|`*ngIf`
|
||||
description:
|
||||
Removes or recreates a portion of the DOM tree based on the `showSection` expression.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<li *ngFor="let item of list">`|`*ngFor`
|
||||
description:
|
||||
Turns the li element and its contents into a template, and uses that to instantiate a view for each item in list.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<div [ngSwitch]="conditionExpression">
|
||||
<ng-template [ngSwitchCase]="case1Exp">...</ng-template>
|
||||
<ng-template ngSwitchCase="case2LiteralString">...</ng-template>
|
||||
<ng-template ngSwitchDefault>...</ng-template>
|
||||
</div>`|`[ngSwitch]`|`[ngSwitchCase]`|`ngSwitchCase`|`ngSwitchDefault`
|
||||
description:
|
||||
Conditionally swaps the contents of the div by selecting one of the embedded templates based on the current value of `conditionExpression`.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<div [ngClass]="{active: isActive, disabled: isDisabled}">`|`[ngClass]`
|
||||
description:
|
||||
Binds the presence of CSS classes on the element to the truthiness of the associated map values. The right-hand expression should return {class-name: true/false} map.
|
@ -1,49 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Class decorators
|
||||
@cheatsheetIndex 5
|
||||
@description
|
||||
{@target ts}`import { Directive, ... } from '@angular/core';`{@endtarget}
|
||||
{@target js}Available from the `ng.core` namespace{@endtarget}
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@Component({...})
|
||||
class MyComponent() {}`|`@Component({...})`
|
||||
syntax(js):
|
||||
`var MyComponent = ng.core.Component({...}).Class({...})`|`ng.core.Component({...})`
|
||||
description:
|
||||
Declares that a class is a component and provides metadata about the component.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@Directive({...})
|
||||
class MyDirective() {}`|`@Directive({...})`
|
||||
syntax(js):
|
||||
`var MyDirective = ng.core.Directive({...}).Class({...})`|`ng.core.Directive({...})`
|
||||
description:
|
||||
Declares that a class is a directive and provides metadata about the directive.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@Pipe({...})
|
||||
class MyPipe() {}`|`@Pipe({...})`
|
||||
syntax(js):
|
||||
`var MyPipe = ng.core.Pipe({...}).Class({...})`|`ng.core.Pipe({...})`
|
||||
description:
|
||||
Declares that a class is a pipe and provides metadata about the pipe.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@Injectable()
|
||||
class MyService() {}`|`@Injectable()`
|
||||
syntax(js):
|
||||
`var OtherService = ng.core.Class(
|
||||
{constructor: function() { }});
|
||||
var MyService = ng.core.Class(
|
||||
{constructor: [OtherService, function(otherService) { }]});`|`var MyService = ng.core.Class({constructor: [OtherService, function(otherService) { }]});`
|
||||
description:
|
||||
{@target ts}Declares that a class has dependencies that should be injected into the constructor when the dependency injector is creating an instance of this class.
|
||||
{@endtarget}
|
||||
{@target js}
|
||||
Declares a service to inject into a class by providing an array with the services, with the final item being the function to receive the injected services.
|
||||
{@endtarget}
|
@ -1,38 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Component configuration
|
||||
@cheatsheetIndex 7
|
||||
@description
|
||||
{@target js}`ng.core.Component` extends `ng.core.Directive`,
|
||||
so the `ng.core.Directive` configuration applies to components as well{@endtarget}
|
||||
{@target ts}`@Component` extends `@Directive`,
|
||||
so the `@Directive` configuration applies to components as well{@endtarget}
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`moduleId: module.id`|`moduleId:`
|
||||
description:
|
||||
If set, the `templateUrl` and `styleUrl` are resolved relative to the component.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`viewProviders: [MyService, { provide: ... }]`|`viewProviders:`
|
||||
syntax(js):
|
||||
`viewProviders: [MyService, { provide: ... }]`|`viewProviders:`
|
||||
description:
|
||||
List of dependency injection providers scoped to this component's view.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`template: 'Hello {{name}}'
|
||||
templateUrl: 'my-component.html'`|`template:`|`templateUrl:`
|
||||
description:
|
||||
Inline template or external template URL of the component's view.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`styles: ['.primary {color: red}']
|
||||
styleUrls: ['my-component.css']`|`styles:`|`styleUrls:`
|
||||
description:
|
||||
List of inline CSS styles or external stylesheet URLs for styling the component’s view.
|
@ -1,30 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Dependency injection configuration
|
||||
@cheatsheetIndex 10
|
||||
@description
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`{ provide: MyService, useClass: MyMockService }`|`provide`|`useClass`
|
||||
syntax(js):
|
||||
`{ provide: MyService, useClass: MyMockService }`|`provide`|`useClass`
|
||||
description:
|
||||
Sets or overrides the provider for `MyService` to the `MyMockService` class.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`{ provide: MyService, useFactory: myFactory }`|`provide`|`useFactory`
|
||||
syntax(js):
|
||||
`{ provide: MyService, useFactory: myFactory }`|`provide`|`useFactory`
|
||||
description:
|
||||
Sets or overrides the provider for `MyService` to the `myFactory` factory function.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`{ provide: MyValue, useValue: 41 }`|`provide`|`useValue`
|
||||
syntax(js):
|
||||
`{ provide: MyValue, useValue: 41 }`|`provide`|`useValue`
|
||||
description:
|
||||
Sets or overrides the provider for `MyValue` to the value `41`.
|
@ -1,86 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Class field decorators for directives and components
|
||||
@cheatsheetIndex 8
|
||||
@description
|
||||
{@target ts}`import { Input, ... } from '@angular/core';`{@endtarget}
|
||||
{@target js}Available from the `ng.core` namespace{@endtarget}
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@Input() myProperty;`|`@Input()`
|
||||
syntax(js):
|
||||
`ng.core.Input(myProperty, myComponent);`|`ng.core.Input(`|`);`
|
||||
description:
|
||||
Declares an input property that you can update via property binding (example:
|
||||
`<my-cmp [myProperty]="someExpression">`).
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@Output() myEvent = new EventEmitter();`|`@Output()`
|
||||
syntax(js):
|
||||
`myEvent = new ng.core.EventEmitter();
|
||||
ng.core.Output(myEvent, myComponent);`|`ng.core.Output(`|`);`
|
||||
description:
|
||||
Declares an output property that fires events that you can subscribe to with an event binding (example: `<my-cmp (myEvent)="doSomething()">`).
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@HostBinding('class.valid') isValid;`|`@HostBinding('class.valid')`
|
||||
syntax(js):
|
||||
`ng.core.HostBinding('class.valid',
|
||||
'isValid', myComponent);`|`ng.core.HostBinding('class.valid', 'isValid'`|`);`
|
||||
description:
|
||||
Binds a host element property (here, the CSS class `valid`) to a directive/component property (`isValid`).
|
||||
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@HostListener('click', ['$event']) onClick(e) {...}`|`@HostListener('click', ['$event'])`
|
||||
syntax(js):
|
||||
`ng.core.HostListener('click',
|
||||
['$event'], onClick(e) {...}, myComponent);`|`ng.core.HostListener('click', ['$event'], onClick(e)`|`);`
|
||||
description:
|
||||
Subscribes to a host element event (`click`) with a directive/component method (`onClick`), optionally passing an argument (`$event`).
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@ContentChild(myPredicate) myChildComponent;`|`@ContentChild(myPredicate)`
|
||||
syntax(js):
|
||||
`ng.core.ContentChild(myPredicate,
|
||||
'myChildComponent', myComponent);`|`ng.core.ContentChild(myPredicate,`|`);`
|
||||
description:
|
||||
Binds the first result of the component content query (`myPredicate`) to a property (`myChildComponent`) of the class.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@ContentChildren(myPredicate) myChildComponents;`|`@ContentChildren(myPredicate)`
|
||||
syntax(js):
|
||||
`ng.core.ContentChildren(myPredicate,
|
||||
'myChildComponents', myComponent);`|`ng.core.ContentChildren(myPredicate,`|`);`
|
||||
description:
|
||||
Binds the results of the component content query (`myPredicate`) to a property (`myChildComponents`) of the class.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@ViewChild(myPredicate) myChildComponent;`|`@ViewChild(myPredicate)`
|
||||
syntax(js):
|
||||
`ng.core.ViewChild(myPredicate,
|
||||
'myChildComponent', myComponent);`|`ng.core.ViewChild(myPredicate,`|`);`
|
||||
description:
|
||||
Binds the first result of the component view query (`myPredicate`) to a property (`myChildComponent`) of the class. Not available for directives.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@ViewChildren(myPredicate) myChildComponents;`|`@ViewChildren(myPredicate)`
|
||||
syntax(js):
|
||||
`ng.core.ViewChildren(myPredicate,
|
||||
'myChildComponents', myComponent);`|`ng.core.ViewChildren(myPredicate,`|`);`
|
||||
description:
|
||||
Binds the results of the component view query (`myPredicate`) to a property (`myChildComponents`) of the class. Not available for directives.
|
@ -1,23 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Directive configuration
|
||||
@cheatsheetIndex 6
|
||||
@description
|
||||
{@target ts}`@Directive({ property1: value1, ... })`{@endtarget}
|
||||
{@target js}`ng.core.Directive({ property1: value1, ... }).Class({...})`{@endtarget}
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`selector: '.cool-button:not(a)'`|`selector:`
|
||||
description:
|
||||
Specifies a CSS selector that identifies this directive within a template. Supported selectors include `element`,
|
||||
`[attribute]`, `.class`, and `:not()`.
|
||||
|
||||
Does not support parent-child relationship selectors.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`providers: [MyService, { provide: ... }]`|`providers:`
|
||||
syntax(js):
|
||||
`providers: [MyService, { provide: ... }]`|`providers:`
|
||||
description:
|
||||
List of dependency injection providers for this directive and its children.
|
@ -1,12 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Forms
|
||||
@cheatsheetIndex 4
|
||||
@description
|
||||
{@target ts}`import { FormsModule } from '@angular/forms';`{@endtarget}
|
||||
{@target js}Available using the `ng.forms.FormsModule` module{@endtarget}
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<input [(ngModel)]="userName">`|`[(ngModel)]`
|
||||
description:
|
||||
Provides two-way data-binding, parsing, and validation for form controls.
|
@ -1,86 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Directive and component change detection and lifecycle hooks
|
||||
@cheatsheetIndex 9
|
||||
@description
|
||||
{@target ts}(implemented as class methods){@endtarget}
|
||||
{@target js}(implemented as component properties){@endtarget}
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`constructor(myService: MyService, ...) { ... }`|`constructor(myService: MyService, ...)`
|
||||
syntax(js):
|
||||
`constructor: function(MyService, ...) { ... }`|`constructor: function(MyService, ...)`
|
||||
description:
|
||||
Called before any other lifecycle hook. Use it to inject dependencies, but avoid any serious work here.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`ngOnChanges(changeRecord) { ... }`|`ngOnChanges(changeRecord)`
|
||||
syntax(js):
|
||||
`ngOnChanges: function(changeRecord) { ... }`|`ngOnChanges: function(changeRecord)`
|
||||
description:
|
||||
Called after every change to input properties and before processing content or child views.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`ngOnInit() { ... }`|`ngOnInit()`
|
||||
syntax(js):
|
||||
`ngOnInit: function() { ... }`|`ngOnInit: function()`
|
||||
description:
|
||||
Called after the constructor, initializing input properties, and the first call to `ngOnChanges`.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`ngDoCheck() { ... }`|`ngDoCheck()`
|
||||
syntax(js):
|
||||
`ngDoCheck: function() { ... }`|`ngDoCheck: function()`
|
||||
description:
|
||||
Called every time that the input properties of a component or a directive are checked. Use it to extend change detection by performing a custom check.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`ngAfterContentInit() { ... }`|`ngAfterContentInit()`
|
||||
syntax(js):
|
||||
`ngAfterContentInit: function() { ... }`|`ngAfterContentInit: function()`
|
||||
description:
|
||||
Called after `ngOnInit` when the component's or directive's content has been initialized.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`ngAfterContentChecked() { ... }`|`ngAfterContentChecked()`
|
||||
syntax(js):
|
||||
`ngAfterContentChecked: function() { ... }`|`ngAfterContentChecked: function()`
|
||||
description:
|
||||
Called after every check of the component's or directive's content.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`ngAfterViewInit() { ... }`|`ngAfterViewInit()`
|
||||
syntax(js):
|
||||
`ngAfterViewInit: function() { ... }`|`ngAfterViewInit: function()`
|
||||
description:
|
||||
Called after `ngAfterContentInit` when the component's view has been initialized. Applies to components only.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`ngAfterViewChecked() { ... }`|`ngAfterViewChecked()`
|
||||
syntax(js):
|
||||
`ngAfterViewChecked: function() { ... }`|`ngAfterViewChecked: function()`
|
||||
description:
|
||||
Called after every check of the component's view. Applies to components only.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`ngOnDestroy() { ... }`|`ngOnDestroy()`
|
||||
syntax(js):
|
||||
`ngOnDestroy: function() { ... }`|`ngOnDestroy: function()`
|
||||
description:
|
||||
Called once, before the instance is destroyed.
|
@ -1,58 +0,0 @@
|
||||
@cheatsheetSection
|
||||
NgModules
|
||||
@cheatsheetIndex 1
|
||||
@description
|
||||
{@target ts}`import { NgModule } from '@angular/core';`{@endtarget}
|
||||
{@target js}Available from the `ng.core` namespace{@endtarget}
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`@NgModule({ declarations: ..., imports: ...,
|
||||
exports: ..., providers: ..., bootstrap: ...})
|
||||
class MyModule {}`|`NgModule`
|
||||
description:
|
||||
Defines a module that contains components, directives, pipes, and providers.
|
||||
|
||||
syntax(js):
|
||||
`ng.core.NgModule({declarations: ..., imports: ...,
|
||||
exports: ..., providers: ..., bootstrap: ...}).
|
||||
Class({ constructor: function() {}})`
|
||||
description:
|
||||
Defines a module that contains components, directives, pipes, and providers.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`declarations: [MyRedComponent, MyBlueComponent, MyDatePipe]`|`declarations:`
|
||||
description:
|
||||
List of components, directives, and pipes that belong to this module.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`imports: [BrowserModule, SomeOtherModule]`|`imports:`
|
||||
description:
|
||||
List of modules to import into this module. Everything from the imported modules
|
||||
is available to `declarations` of this module.
|
||||
|
||||
syntax(js):
|
||||
`imports: [ng.platformBrowser.BrowserModule, SomeOtherModule]`|`imports:`
|
||||
description:
|
||||
List of modules to import into this module. Everything from the imported modules
|
||||
is available to `declarations` of this module.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`exports: [MyRedComponent, MyDatePipe]`|`exports:`
|
||||
description:
|
||||
List of components, directives, and pipes visible to modules that import this module.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`providers: [MyService, { provide: ... }]`|`providers:`
|
||||
description:
|
||||
List of dependency injection providers visible both to the contents of this module and to importers of this module.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`bootstrap: [MyAppComponent]`|`bootstrap:`
|
||||
description:
|
||||
List of components to bootstrap when this module is bootstrapped.
|
@ -1,170 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Routing and navigation
|
||||
@cheatsheetIndex 11
|
||||
@description
|
||||
{@target ts}`import { Routes, RouterModule, ... } from '@angular/router';`{@endtarget}
|
||||
{@target js}Available from the `ng.router` namespace{@endtarget}
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`const routes: Routes = [
|
||||
{ path: '', component: HomeComponent },
|
||||
{ path: 'path/:routeParam', component: MyComponent },
|
||||
{ path: 'staticPath', component: ... },
|
||||
{ path: '**', component: ... },
|
||||
{ path: 'oldPath', redirectTo: '/staticPath' },
|
||||
{ path: ..., component: ..., data: { message: 'Custom' } }
|
||||
]);
|
||||
|
||||
const routing = RouterModule.forRoot(routes);`|`Routes`
|
||||
syntax(js):
|
||||
`var routes = [
|
||||
{ path: '', component: HomeComponent },
|
||||
{ path: ':routeParam', component: MyComponent },
|
||||
{ path: 'staticPath', component: ... },
|
||||
{ path: '**', component: ... },
|
||||
{ path: 'oldPath', redirectTo: '/staticPath' },
|
||||
{ path: ..., component: ..., data: { message: 'Custom' } }
|
||||
]);
|
||||
|
||||
var routing = ng.router.RouterModule.forRoot(routes);`|`ng.router.Routes`
|
||||
description:
|
||||
Configures routes for the application. Supports static, parameterized, redirect, and wildcard routes. Also supports custom route data and resolve.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet name="aux"></router-outlet>
|
||||
`|`router-outlet`
|
||||
description:
|
||||
Marks the location to load the component of the active route.
|
||||
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`
|
||||
<a routerLink="/path">
|
||||
<a [routerLink]="[ '/path', routeParam ]">
|
||||
<a [routerLink]="[ '/path', { matrixParam: 'value' } ]">
|
||||
<a [routerLink]="[ '/path' ]" [queryParams]="{ page: 1 }">
|
||||
<a [routerLink]="[ '/path' ]" fragment="anchor">
|
||||
`|`[routerLink]`
|
||||
description:
|
||||
Creates a link to a different view based on a route instruction consisting of a route path, required and optional parameters, query parameters, and a fragment. To navigate to a root route, use the `/` prefix; for a child route, use the `./`prefix; for a sibling or parent, use the `../` prefix.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<a [routerLink]="[ '/path' ]" routerLinkActive="active">`
|
||||
description:
|
||||
The provided classes are added to the element when the `routerLink` becomes the current active route.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`class CanActivateGuard implements CanActivate {
|
||||
canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<boolean>|Promise<boolean>|boolean { ... }
|
||||
}
|
||||
|
||||
{ path: ..., canActivate: [CanActivateGuard] }`|`CanActivate`
|
||||
syntax(js):
|
||||
`var CanActivateGuard = ng.core.Class({
|
||||
canActivate: function(route, state) {
|
||||
// return Observable/Promise boolean or boolean
|
||||
}
|
||||
});
|
||||
|
||||
{ path: ..., canActivate: [CanActivateGuard] }`|`CanActivate`
|
||||
description:
|
||||
An interface for defining a class that the router should call first to determine if it should activate this component. Should return a boolean or an Observable/Promise that resolves to a boolean.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`class CanDeactivateGuard implements CanDeactivate<T> {
|
||||
canDeactivate(
|
||||
component: T,
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<boolean>|Promise<boolean>|boolean { ... }
|
||||
}
|
||||
|
||||
{ path: ..., canDeactivate: [CanDeactivateGuard] }`|`CanDeactivate`
|
||||
syntax(js):
|
||||
`var CanDeactivateGuard = ng.core.Class({
|
||||
canDeactivate: function(component, route, state) {
|
||||
// return Observable/Promise boolean or boolean
|
||||
}
|
||||
});
|
||||
|
||||
{ path: ..., canDeactivate: [CanDeactivateGuard] }`|`CanDeactivate`
|
||||
description:
|
||||
An interface for defining a class that the router should call first to determine if it should deactivate this component after a navigation. Should return a boolean or an Observable/Promise that resolves to a boolean.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`class CanActivateChildGuard implements CanActivateChild {
|
||||
canActivateChild(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<boolean>|Promise<boolean>|boolean { ... }
|
||||
}
|
||||
|
||||
{ path: ..., canActivateChild: [CanActivateGuard],
|
||||
children: ... }`|`CanActivateChild`
|
||||
syntax(js):
|
||||
`var CanActivateChildGuard = ng.core.Class({
|
||||
canActivateChild: function(route, state) {
|
||||
// return Observable/Promise boolean or boolean
|
||||
}
|
||||
});
|
||||
|
||||
{ path: ..., canActivateChild: [CanActivateChildGuard],
|
||||
children: ... }`|`CanActivateChild`
|
||||
description:
|
||||
An interface for defining a class that the router should call first to determine if it should activate the child route. Should return a boolean or an Observable/Promise that resolves to a boolean.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`class ResolveGuard implements Resolve<T> {
|
||||
resolve(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<any>|Promise<any>|any { ... }
|
||||
}
|
||||
|
||||
{ path: ..., resolve: [ResolveGuard] }`|`Resolve`
|
||||
syntax(js):
|
||||
`var ResolveGuard = ng.core.Class({
|
||||
resolve: function(route, state) {
|
||||
// return Observable/Promise value or value
|
||||
}
|
||||
});
|
||||
|
||||
{ path: ..., resolve: [ResolveGuard] }`|`Resolve`
|
||||
description:
|
||||
An interface for defining a class that the router should call first to resolve route data before rendering the route. Should return a value or an Observable/Promise that resolves to a value.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax(ts):
|
||||
`class CanLoadGuard implements CanLoad {
|
||||
canLoad(
|
||||
route: Route
|
||||
): Observable<boolean>|Promise<boolean>|boolean { ... }
|
||||
}
|
||||
|
||||
{ path: ..., canLoad: [CanLoadGuard], loadChildren: ... }`|`CanLoad`
|
||||
syntax(js):
|
||||
`var CanLoadGuard = ng.core.Class({
|
||||
canLoad: function(route) {
|
||||
// return Observable/Promise boolean or boolean
|
||||
}
|
||||
});
|
||||
|
||||
{ path: ..., canLoad: [CanLoadGuard], loadChildren: ... }`|`CanLoad`
|
||||
description:
|
||||
An interface for defining a class that the router should call first to check if the lazy loaded module should be loaded. Should return a boolean or an Observable/Promise that resolves to a boolean.
|
||||
|
@ -1,94 +0,0 @@
|
||||
@cheatsheetSection
|
||||
Template syntax
|
||||
@cheatsheetIndex 2
|
||||
@description
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<input [value]="firstName">`|`[value]`
|
||||
description:
|
||||
Binds property `value` to the result of expression `firstName`.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<div [attr.role]="myAriaRole">`|`[attr.role]`
|
||||
description:
|
||||
Binds attribute `role` to the result of expression `myAriaRole`.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<div [class.extra-sparkle]="isDelightful">`|`[class.extra-sparkle]`
|
||||
description:
|
||||
Binds the presence of the CSS class `extra-sparkle` on the element to the truthiness of the expression `isDelightful`.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<div [style.width.px]="mySize">`|`[style.width.px]`
|
||||
description:
|
||||
Binds style property `width` to the result of expression `mySize` in pixels. Units are optional.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<button (click)="readRainbow($event)">`|`(click)`
|
||||
description:
|
||||
Calls method `readRainbow` when a click event is triggered on this button element (or its children) and passes in the event object.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<div title="Hello {{ponyName}}">`|`{{ponyName}}`
|
||||
description:
|
||||
Binds a property to an interpolated string, for example, "Hello Seabiscuit". Equivalent to:
|
||||
`<div [title]="'Hello ' + ponyName">`
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<p>Hello {{ponyName}}</p>`|`{{ponyName}}`
|
||||
description:
|
||||
Binds text content to an interpolated string, for example, "Hello Seabiscuit".
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<my-cmp [(title)]="name">`|`[(title)]`
|
||||
description:
|
||||
Sets up two-way data binding. Equivalent to: `<my-cmp [title]="name" (titleChange)="name=$event">`
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<video #movieplayer ...>
|
||||
<button (click)="movieplayer.play()">
|
||||
</video>`|`#movieplayer`|`(click)`
|
||||
description:
|
||||
Creates a local variable `movieplayer` that provides access to the `video` element instance in data-binding and event-binding expressions in the current template.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<p *myUnless="myExpression">...</p>`|`*myUnless`
|
||||
description:
|
||||
The `*` symbol turns the current element into an embedded template. Equivalent to:
|
||||
`<ng-template [myUnless]="myExpression"><p>...</p></ng-template>`
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<p>Card No.: {{cardNumber | myCardNumberFormatter}}</p>`|`{{cardNumber | myCardNumberFormatter}}`
|
||||
description:
|
||||
Transforms the current value of expression `cardNumber` via the pipe called `myCardNumberFormatter`.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<p>Employer: {{employer?.companyName}}</p>`|`{{employer?.companyName}}`
|
||||
description:
|
||||
The safe navigation operator (`?`) means that the `employer` field is optional and if `undefined`, the rest of the expression should be ignored.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<svg:rect x="0" y="0" width="100" height="100"/>`|`svg:`
|
||||
description:
|
||||
An SVG snippet template needs an `svg:` prefix on its root element to disambiguate the SVG element from an HTML component.
|
||||
|
||||
@cheatsheetItem
|
||||
syntax:
|
||||
`<svg>
|
||||
<rect x="0" y="0" width="100" height="100"/>
|
||||
</svg>`|`svg`
|
||||
description:
|
||||
An `<svg>` root element is detected as an SVG element automatically, without the prefix.
|
File diff suppressed because it is too large
Load Diff
@ -1,301 +0,0 @@
|
||||
@title
|
||||
Component Interaction
|
||||
|
||||
@intro
|
||||
Share information between different directives and components
|
||||
|
||||
@description
|
||||
<a id="top"></a>This cookbook contains recipes for common component communication scenarios
|
||||
in which two or more components share information.
|
||||
<a id="toc"></a>## Table of contents
|
||||
|
||||
[Pass data from parent to child with input binding](#parent-to-child)
|
||||
|
||||
[Intercept input property changes with a setter](#parent-to-child-setter)
|
||||
|
||||
[Intercept input property changes with *ngOnChanges*](#parent-to-child-on-changes)
|
||||
|
||||
[Parent listens for child event](#child-to-parent)
|
||||
|
||||
[Parent interacts with child via a *local variable*](#parent-to-child-local-var)
|
||||
|
||||
[Parent calls a *ViewChild*](#parent-to-view-child)
|
||||
|
||||
[Parent and children communicate via a service](#bidirectional-service)
|
||||
**See the <live-example name="cb-component-communication"></live-example>**.
|
||||
|
||||
<a id="parent-to-child"></a>## Pass data from parent to child with input binding
|
||||
|
||||
`HeroChildComponent` has two ***input properties***,
|
||||
typically adorned with [@Input decorations](../guide/template-syntax.html#inputs-outputs).
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/hero-child.component.ts'}
|
||||
|
||||
The second `@Input` aliases the child component property name `masterName` as `'master'`.
|
||||
|
||||
The `HeroParentComponent` nests the child `HeroChildComponent` inside an `*ngFor` repeater,
|
||||
binding its `master` string property to the child's `master` alias
|
||||
and each iteration's `hero` instance to the child's `hero` property.
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/hero-parent.component.ts'}
|
||||
|
||||
The running application displays three heroes:
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/component-communication/parent-to-child.png" alt="Parent-to-child"> </img>
|
||||
</figure>
|
||||
|
||||
### Test it
|
||||
|
||||
E2E test that all children were instantiated and displayed as expected:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/e2e-spec.ts' region='parent-to-child'}
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
<a id="parent-to-child-setter"></a>## Intercept input property changes with a setter
|
||||
|
||||
Use an input property setter to intercept and act upon a value from the parent.
|
||||
|
||||
The setter of the `name` input property in the child `NameChildComponent`
|
||||
trims the whitespace from a name and replaces an empty value with default text.
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/name-child.component.ts'}
|
||||
|
||||
Here's the `NameParentComponent` demonstrating name variations including a name with all spaces:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/name-parent.component.ts'}
|
||||
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/component-communication/setter.png" alt="Parent-to-child-setter"> </img>
|
||||
</figure>
|
||||
|
||||
### Test it
|
||||
|
||||
E2E tests of input property setter with empty and non-empty names:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/e2e-spec.ts' region='parent-to-child-setter'}
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
<a id="parent-to-child-on-changes"></a>## Intercept input property changes with *ngOnChanges*
|
||||
|
||||
Detect and act upon changes to input property values with the `ngOnChanges` method of the `OnChanges` lifecycle hook interface.
|
||||
May prefer this approach to the property setter when watching multiple, interacting input properties.
|
||||
|
||||
Learn about `ngOnChanges` in the [LifeCycle Hooks](../guide/lifecycle-hooks.html) chapter.This `VersionChildComponent` detects changes to the `major` and `minor` input properties and composes a log message reporting these changes:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/version-child.component.ts'}
|
||||
|
||||
The `VersionParentComponent` supplies the `minor` and `major` values and binds buttons to methods that change them.
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/version-parent.component.ts'}
|
||||
|
||||
Here's the output of a button-pushing sequence:
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/component-communication/parent-to-child-on-changes.gif" alt="Parent-to-child-onchanges"> </img>
|
||||
</figure>
|
||||
|
||||
### Test it
|
||||
|
||||
Test that ***both*** input properties are set initially and that button clicks trigger
|
||||
the expected `ngOnChanges` calls and values:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/e2e-spec.ts' region='parent-to-child-onchanges'}
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
<a id="child-to-parent"></a>## Parent listens for child event
|
||||
|
||||
The child component exposes an `EventEmitter` property with which it `emits`events when something happens.
|
||||
The parent binds to that event property and reacts to those events.
|
||||
|
||||
The child's `EventEmitter` property is an ***output property***,
|
||||
typically adorned with an [@Output decoration](../guide/template-syntax.html#inputs-outputs)
|
||||
as seen in this `VoterComponent`:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/voter.component.ts'}
|
||||
|
||||
Clicking a button triggers emission of a `true` or `false` (the boolean *payload*).
|
||||
|
||||
The parent `VoteTakerComponent` binds an event handler (`onVoted`) that responds to the child event
|
||||
payload (`$event`) and updates a counter.
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/votetaker.component.ts'}
|
||||
|
||||
The framework passes the event argument — represented by `$event` — to the handler method,
|
||||
and the method processes it:
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/component-communication/child-to-parent.gif" alt="Child-to-parent"> </img>
|
||||
</figure>
|
||||
|
||||
### Test it
|
||||
|
||||
Test that clicking the *Agree* and *Disagree* buttons update the appropriate counters:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/e2e-spec.ts' region='child-to-parent'}
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
## Parent interacts with child via *local variable*
|
||||
|
||||
A parent component cannot use data binding to read child properties
|
||||
or invoke child methods. We can do both
|
||||
by creating a template reference variable for the child element
|
||||
and then reference that variable *within the parent template*
|
||||
as seen in the following example.
|
||||
|
||||
<a id="countdown-timer-example"></a>
|
||||
We have a child `CountdownTimerComponent` that repeatedly counts down to zero and launches a rocket.
|
||||
It has `start` and `stop` methods that control the clock and it displays a
|
||||
countdown status message in its own template.
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/countdown-timer.component.ts'}
|
||||
|
||||
Let's see the `CountdownLocalVarParentComponent` that hosts the timer component.
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/countdown-parent.component.ts' region='lv'}
|
||||
|
||||
The parent component cannot data bind to the child's
|
||||
`start` and `stop` methods nor to its `seconds` property.
|
||||
|
||||
We can place a local variable (`#timer`) on the tag (`<countdown-timer>`) representing the child component.
|
||||
That gives us a reference to the child component itself and the ability to access
|
||||
*any of its properties or methods* from within the parent template.
|
||||
|
||||
In this example, we wire parent buttons to the child's `start` and `stop` and
|
||||
use interpolation to display the child's `seconds` property.
|
||||
|
||||
Here we see the parent and child working together.
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/component-communication/countdown-timer-anim.gif" alt="countdown timer"> </img>
|
||||
</figure>
|
||||
|
||||
|
||||
|
||||
{@a countdown-tests}
|
||||
### Test it
|
||||
|
||||
Test that the seconds displayed in the parent template
|
||||
match the seconds displayed in the child's status message.
|
||||
Test also that clicking the *Stop* button pauses the countdown timer:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/e2e-spec.ts' region='countdown-timer-tests'}
|
||||
|
||||
[Back to top](#top)
|
||||
|
||||
<a id="parent-to-view-child"></a>## Parent calls a *ViewChild*
|
||||
|
||||
The *local variable* approach is simple and easy. But it is limited because
|
||||
the parent-child wiring must be done entirely within the parent template.
|
||||
The parent component *itself* has no access to the child.
|
||||
|
||||
We can't use the *local variable* technique if an instance of the parent component *class*
|
||||
must read or write child component values or must call child component methods.
|
||||
|
||||
When the parent component *class* requires that kind of access,
|
||||
we ***inject*** the child component into the parent as a *ViewChild*.
|
||||
|
||||
We'll illustrate this technique with the same [Countdown Timer](#countdown-timer-example) example.
|
||||
We won't change its appearance or behavior.
|
||||
The child [CountdownTimerComponent](#countdown-timer-example) is the same as well.
|
||||
We are switching from the *local variable* to the *ViewChild* technique
|
||||
solely for the purpose of demonstration.Here is the parent, `CountdownViewChildParentComponent`:
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/countdown-parent.component.ts' region='vc'}
|
||||
|
||||
It takes a bit more work to get the child view into the parent component *class*.
|
||||
|
||||
We import references to the `ViewChild` decorator and the `AfterViewInit` lifecycle hook.
|
||||
|
||||
We inject the child `CountdownTimerComponent` into the private `timerComponent` property
|
||||
via the `@ViewChild` property decoration.
|
||||
|
||||
The `#timer` local variable is gone from the component metadata.
|
||||
Instead we bind the buttons to the parent component's own `start` and `stop` methods and
|
||||
present the ticking seconds in an interpolation around the parent component's `seconds` method.
|
||||
|
||||
These methods access the injected timer component directly.
|
||||
|
||||
The `ngAfterViewInit` lifecycle hook is an important wrinkle.
|
||||
The timer component isn't available until *after* Angular displays the parent view.
|
||||
So we display `0` seconds initially.
|
||||
|
||||
Then Angular calls the `ngAfterViewInit` lifecycle hook at which time it is *too late*
|
||||
to update the parent view's display of the countdown seconds.
|
||||
Angular's unidirectional data flow rule prevents us from updating the parent view's
|
||||
in the same cycle. We have to *wait one turn* before we can display the seconds.
|
||||
|
||||
We use `setTimeout` to wait one tick and then revise the `seconds` method so
|
||||
that it takes future values from the timer component.
|
||||
|
||||
### Test it
|
||||
Use [the same countdown timer tests](#countdown-tests) as before.[Back to top](#top)
|
||||
|
||||
<a id="bidirectional-service"></a>## Parent and children communicate via a service
|
||||
|
||||
A parent component and its children share a service whose interface enables bi-directional communication
|
||||
*within the family*.
|
||||
|
||||
The scope of the service instance is the parent component and its children.
|
||||
Components outside this component subtree have no access to the service or their communications.
|
||||
|
||||
This `MissionService` connects the `MissionControlComponent` to multiple `AstronautComponent` children.
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/mission.service.ts'}
|
||||
|
||||
The `MissionControlComponent` both provides the instance of the service that it shares with its children
|
||||
(through the `providers` metadata array) and injects that instance into itself through its constructor:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/missioncontrol.component.ts'}
|
||||
|
||||
The `AstronautComponent` also injects the service in its constructor.
|
||||
Each `AstronautComponent` is a child of the `MissionControlComponent` and therefore receives its parent's service instance:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/ts/src/app/astronaut.component.ts'}
|
||||
|
||||
|
||||
Notice that we capture the `subscription` and unsubscribe when the `AstronautComponent` is destroyed.
|
||||
This is a memory-leak guard step. There is no actual risk in this app because the
|
||||
lifetime of a `AstronautComponent` is the same as the lifetime of the app itself.
|
||||
That *would not* always be true in a more complex application.
|
||||
|
||||
We do not add this guard to the `MissionControlComponent` because, as the parent,
|
||||
it controls the lifetime of the `MissionService`.The *History* log demonstrates that messages travel in both directions between
|
||||
the parent `MissionControlComponent` and the `AstronautComponent` children,
|
||||
facilitated by the service:
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/component-communication/bidirectional-service.gif" alt="bidirectional-service"> </img>
|
||||
</figure>
|
||||
|
||||
### Test it
|
||||
|
||||
Tests click buttons of both the parent `MissionControlComponent` and the `AstronautComponent` children
|
||||
and verify that the *History* meets expectations:
|
||||
|
||||
|
||||
{@example 'cb-component-communication/e2e-spec.ts' region='bidirectional-service'}
|
||||
|
||||
[Back to top](#top)
|
@ -1,194 +0,0 @@
|
||||
@title
|
||||
Component-relative Paths
|
||||
|
||||
@intro
|
||||
Use relative URLs for component templates and styles.
|
||||
|
||||
@description
|
||||
## Write *Component-Relative* URLs to component templates and style files
|
||||
|
||||
Our components often refer to external template and style files.
|
||||
We identify those files with a URL in the `templateUrl` and `styleUrls` properties of the `@Component` metadata
|
||||
as seen here:
|
||||
|
||||
|
||||
{@example 'cb-component-relative-paths/ts/src/app/some.component.ts' region='absolute-config'}
|
||||
|
||||
By default, we *must* specify the full path back to the application root.
|
||||
We call this an ***absolute path*** because it is *absolute* with respect to the application root.
|
||||
|
||||
There are two problems with an *absolute path*:
|
||||
|
||||
1. We have to remember the full path back to the application root.
|
||||
|
||||
1. We have to update the URL when we move the component around in the application files structure.
|
||||
|
||||
It would be much easier to write and maintain our application components if we could specify template and style locations
|
||||
*relative* to their component class file.
|
||||
|
||||
*We can!*
|
||||
|
||||
|
||||
~~~ {.alert.is-important}
|
||||
|
||||
We can if we build our application as `commonjs` modules and load those modules
|
||||
with a suitable package loader such as `systemjs` or `webpack`.
|
||||
Learn why [below](#why-default).
|
||||
|
||||
The Angular CLI uses these technologies and defaults to the
|
||||
*component-relative path* approach described here.
|
||||
CLI users can skip this chapter or read on to understand
|
||||
how it works.
|
||||
|
||||
|
||||
~~~
|
||||
|
||||
|
||||
## _Component-Relative_ Paths
|
||||
|
||||
Our goal is to specify template and style URLs *relative* to their component class files,
|
||||
hence the term ***component-relative path***.
|
||||
|
||||
The key to success is following a convention that puts related component files in well-known locations.
|
||||
|
||||
We recommend keeping component template and component-specific style files as *siblings* of their
|
||||
companion component class files.
|
||||
Here we see the three files for `SomeComponent` sitting next to each other in the `app` folder.
|
||||
|
||||
<aio-filetree>
|
||||
|
||||
<aio-folder>
|
||||
app
|
||||
<aio-file>
|
||||
some.component.css
|
||||
</aio-file>
|
||||
|
||||
|
||||
<aio-file>
|
||||
some.component.html
|
||||
</aio-file>
|
||||
|
||||
|
||||
<aio-file>
|
||||
some.component.ts
|
||||
</aio-file>
|
||||
|
||||
|
||||
</aio-folder>
|
||||
|
||||
|
||||
</aio-filetree>
|
||||
|
||||
We'll have more files and folders — and greater folder depth — as our application grows.
|
||||
We'll be fine as long as the component files travel together as the inseparable siblings they are.
|
||||
|
||||
### Set the *moduleId*
|
||||
|
||||
Having adopted this file structure convention, we can specify locations of the template and style files
|
||||
relative to the component class file simply by setting the `moduleId` property of the `@Component` metadata like this
|
||||
|
||||
{@example 'cb-component-relative-paths/ts/src/app/some.component.ts' region='module-id'}
|
||||
|
||||
We strip the `src/app/` base path from the `templateUrl` and `styleUrls` and replace it with `./`.
|
||||
The result looks like this:
|
||||
|
||||
{@example 'cb-component-relative-paths/ts/src/app/some.component.ts' region='relative-config'}
|
||||
|
||||
|
||||
|
||||
~~~ {.alert.is-helpful}
|
||||
|
||||
Webpack users may prefer [an alternative approach](#webpack).
|
||||
|
||||
|
||||
~~~
|
||||
|
||||
|
||||
## Source
|
||||
|
||||
**We can see the <live-example name="cb-component-relative-paths"></live-example>**
|
||||
and download the source code from there
|
||||
or simply read the pertinent source here.
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="src/app/some.component.ts">
|
||||
{@example 'cb-component-relative-paths/ts/src/app/some.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="src/app/some.component.html">
|
||||
{@example 'cb-component-relative-paths/ts/src/app/some.component.html'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="src/app/some.component.css">
|
||||
{@example 'cb-component-relative-paths/ts/src/app/some.component.css'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="src/app/app.component.ts">
|
||||
{@example 'cb-component-relative-paths/ts/src/app/app.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
|
||||
|
||||
{@a why-default}
|
||||
|
||||
## Appendix: why *component-relative* is not the default
|
||||
|
||||
A *component-relative* path is obviously superior to an *absolute* path.
|
||||
Why did Angular default to the *absolute* path?
|
||||
Why do *we* have to set the `moduleId`? Why can't Angular set it?
|
||||
|
||||
First, let's look at what happens if we use a relative path and omit the `moduleId`.
|
||||
|
||||
`EXCEPTION: Failed to load some.component.html`
|
||||
|
||||
Angular can't find the file so it throws an error.
|
||||
|
||||
Why can't Angular calculate the template and style URLs from the component file's location?
|
||||
|
||||
Because the location of the component can't be determined without the developer's help.
|
||||
Angular apps can be loaded in many ways: from individual files, from SystemJS packages, or
|
||||
from CommonJS packages, to name a few.
|
||||
We might generate modules in any of several formats.
|
||||
We might not be writing modular code at all!
|
||||
|
||||
With this diversity of packaging and module load strategies,
|
||||
it's not possible for Angular to know with certainty where these files reside at runtime.
|
||||
|
||||
The only location Angular can be sure of is the URL of the `index.html` home page, the application root.
|
||||
So by default it resolves template and style paths relative to the URL of `index.html`.
|
||||
That's why we previously wrote our file URLs with an `app/` base path prefix.
|
||||
|
||||
But *if* we follow the recommended guidelines and we write modules in `commonjs` format
|
||||
and we use a module loader that *plays nice*,
|
||||
*then* we — the developers of the application —
|
||||
know that the semi-global `module.id` variable is available and contains
|
||||
the absolute URL of the component class module file.
|
||||
|
||||
That knowledge enables us to tell Angular where the *component* file is
|
||||
by setting the `moduleId`:
|
||||
|
||||
{@example 'cb-component-relative-paths/ts/src/app/some.component.ts' region='module-id'}
|
||||
|
||||
|
||||
|
||||
{@a webpack}
|
||||
|
||||
## Webpack: load templates and styles
|
||||
Webpack developers have an alternative to `moduleId`.
|
||||
|
||||
They can load templates and styles at runtime by adding `./` at the beginning of the `template` and `styles` / `styleUrls`
|
||||
properties that reference *component-relative URLS.
|
||||
|
||||
|
||||
{@example 'webpack/ts/src/app/app.component.ts'}
|
||||
|
||||
|
||||
Webpack will do a `require` behind the scenes to load the templates and styles. Read more [here](../guide/webpack.html#highlights).
|
||||
|
||||
See the [Introduction to Webpack](../guide/webpack.html).
|
@ -1,921 +0,0 @@
|
||||
@title
|
||||
Dependency Injection
|
||||
|
||||
@intro
|
||||
Techniques for Dependency Injection
|
||||
|
||||
@description
|
||||
Dependency Injection is a powerful pattern for managing code dependencies.
|
||||
In this cookbook we will explore many of the features of Dependency Injection (DI) in Angular.
|
||||
<a id="toc"></a>## Table of contents
|
||||
|
||||
[Application-wide dependencies](#app-wide-dependencies)
|
||||
|
||||
[External module configuration](#external-module-configuration)
|
||||
|
||||
[*@Injectable* and nested service dependencies](#nested-dependencies)
|
||||
|
||||
[Limit service scope to a component subtree](#service-scope)
|
||||
|
||||
[Multiple service instances (sandboxing)](#multiple-service-instances)
|
||||
|
||||
[Qualify dependency lookup with *@Optional* and *@Host*](#qualify-dependency-lookup)
|
||||
|
||||
[Inject the component's DOM element](#component-element)
|
||||
|
||||
[Define dependencies with providers](#providers)
|
||||
* [The *provide* object literal](#provide)
|
||||
* [useValue - the *value provider*](#usevalue)
|
||||
* [useClass - the *class provider*](#useclass)
|
||||
* [useExisting - the *alias provider*](#useexisting)
|
||||
* [useFactory - the *factory provider*](#usefactory)
|
||||
|
||||
[Provider token alternatives](#tokens)
|
||||
* [class-interface](#class-interface)
|
||||
* [OpaqueToken](#opaque-token)
|
||||
|
||||
[Inject into a derived class](#di-inheritance)
|
||||
|
||||
[Find a parent component by injection](#find-parent)
|
||||
* [Find parent with a known component type](#known-parent)
|
||||
* [Cannot find a parent by its base class](#base-parent)
|
||||
* [Find a parent by its class-interface](#class-interface-parent)
|
||||
* [Find a parent in a tree of parents (*@SkipSelf*)](#parent-tree)
|
||||
* [A *provideParent* helper function](#provideparent)
|
||||
|
||||
[Break circularities with a forward class reference (*forwardRef*)](#forwardref)
|
||||
**See the <live-example name="cb-dependency-injection"></live-example>**
|
||||
of the code supporting this cookbook.
|
||||
|
||||
<a id="app-wide-dependencies"></a>## Application-wide dependencies
|
||||
Register providers for dependencies used throughout the application in the root application component, `AppComponent`.
|
||||
|
||||
In the following example, we import and register several services
|
||||
(the `LoggerService`, `UserContext`, and the `UserService`)
|
||||
in the `@Component` metadata `providers` array.
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/app.component.ts' region='import-services'}
|
||||
|
||||
All of these services are implemented as classes.
|
||||
Service classes can act as their own providers which is why listing them in the `providers` array
|
||||
is all the registration we need.
|
||||
A *provider* is something that can create or deliver a service.
|
||||
Angular creates a service instance from a class provider by "new-ing" it.
|
||||
Learn more about providers [below](#providers).Now that we've registered these services,
|
||||
Angular can inject them into the constructor of *any* component or service, *anywhere* in the application.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-bios.component.ts' region='ctor'}
|
||||
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/user-context.service.ts' region='ctor'}
|
||||
|
||||
<a id="external-module-configuration"></a>
|
||||
## External module configuration
|
||||
We often register providers in the `NgModule` rather than in the root application component.
|
||||
|
||||
We do this when (a) we expect the service to be injectable everywhere
|
||||
or (b) we must configure another application global service _before it starts_.
|
||||
|
||||
We see an example of the second case here, where we configure the Component Router with a non-default
|
||||
[location strategy](../guide/router.html#location-strategy) by listing its provider
|
||||
in the `providers` list of the `AppModule`.
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/app.module.ts' region='providers'}
|
||||
|
||||
|
||||
|
||||
{@a injectable}
|
||||
|
||||
|
||||
{@a nested-dependencies}
|
||||
|
||||
## *@Injectable* and nested service dependencies
|
||||
The consumer of an injected service does not know how to create that service.
|
||||
It shouldn't care.
|
||||
It's the dependency injection's job to create and cache that service.
|
||||
|
||||
Sometimes a service depends on other services ... which may depend on yet other services.
|
||||
Resolving these nested dependencies in the correct order is also the framework's job.
|
||||
At each step, the consumer of dependencies simply declares what it requires in its constructor and the framework takes over.
|
||||
|
||||
For example, we inject both the `LoggerService` and the `UserContext` in the `AppComponent`.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/app.component.ts' region='ctor'}
|
||||
|
||||
The `UserContext` in turn has dependencies on both the `LoggerService` (again) and
|
||||
a `UserService` that gathers information about a particular user.
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/user-context.service.ts' region='injectables'}
|
||||
|
||||
When Angular creates an`AppComponent`, the dependency injection framework creates an instance of the `LoggerService` and
|
||||
starts to create the `UserContextService`.
|
||||
The `UserContextService` needs the `LoggerService`, which the framework already has, and the `UserService`, which it has yet to create.
|
||||
The `UserService` has no dependencies so the dependency injection framework can just `new` one into existence.
|
||||
|
||||
The beauty of dependency injection is that the author of `AppComponent` didn't care about any of this.
|
||||
The author simply declared what was needed in the constructor (`LoggerService` and `UserContextService`) and the framework did the rest.
|
||||
|
||||
Once all the dependencies are in place, the `AppComponent` displays the user information:
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/logged-in-user.png" alt="Logged In User"> </img>
|
||||
</figure>
|
||||
|
||||
### *@Injectable()*
|
||||
Notice the `@Injectable()`decorator on the `UserContextService` class.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/user-context.service.ts' region='injectable'}
|
||||
|
||||
That decorator makes it possible for Angular to identify the types of its two dependencies, `LoggerService` and `UserService`.
|
||||
|
||||
Technically, the `@Injectable()`decorator is only _required_ for a service class that has _its own dependencies_.
|
||||
The `LoggerService` doesn't depend on anything. The logger would work if we omitted `@Injectable()`
|
||||
and the generated code would be slightly smaller.
|
||||
|
||||
But the service would break the moment we gave it a dependency and we'd have to go back and
|
||||
and add `@Injectable()` to fix it. We add `@Injectable()` from the start for the sake of consistency and to avoid future pain.
|
||||
|
||||
|
||||
~~~ {.alert.is-helpful}
|
||||
|
||||
Although we recommend applying `@Injectable` to all service classes, do not feel bound by it.
|
||||
Some developers prefer to add it only where needed and that's a reasonable policy too.
|
||||
|
||||
|
||||
~~~
|
||||
|
||||
|
||||
The `AppComponent` class had two dependencies as well but no `@Injectable()`.
|
||||
It didn't need `@Injectable()` because that component class has the `@Component` decorator.
|
||||
In Angular with TypeScript, a *single* decorator — *any* decorator — is sufficient to identify dependency types.
|
||||
|
||||
<a id="service-scope"></a>
|
||||
## Limit service scope to a component subtree
|
||||
|
||||
All injected service dependencies are singletons meaning that,
|
||||
for a given dependency injector ("injector"), there is only one instance of service.
|
||||
|
||||
But an Angular application has multiple dependency injectors, arranged in a tree hierarchy that parallels the component tree.
|
||||
So a particular service can be *provided* (and created) at any component level and multiple times
|
||||
if provided in multiple components.
|
||||
|
||||
By default, a service dependency provided in one component is visible to all of its child components and
|
||||
Angular injects the same service instance into all child components that ask for that service.
|
||||
|
||||
Accordingly, dependencies provided in the root `AppComponent` can be injected into *any* component *anywhere* in the application.
|
||||
|
||||
That isn't always desirable.
|
||||
Sometimes we want to restrict service availability to a particular region of the application.
|
||||
|
||||
We can limit the scope of an injected service to a *branch* of the application hierarchy
|
||||
by providing that service *at the sub-root component for that branch*.
|
||||
Here we provide the `HeroService` to the `HeroesBaseComponent` by listing it in the `providers` array:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/sorted-heroes.component.ts' region='injection'}
|
||||
|
||||
When Angular creates the `HeroesBaseComponent`, it also creates a new instance of `HeroService`
|
||||
that is visible only to the component and its children (if any).
|
||||
|
||||
We could also provide the `HeroService` to a *different* component elsewhere in the application.
|
||||
That would result in a *different* instance of the service, living in a *different* injector.
|
||||
We examples of such scoped `HeroService` singletons appear throughout the accompanying sample code,
|
||||
including the `HeroBiosComponent`, `HeroOfTheMonthComponent`, and `HeroesBaseComponent`.
|
||||
Each of these components has its own `HeroService` instance managing its own independent collection of heroes.
|
||||
|
||||
|
||||
|
||||
~~~ {.alert.is-helpful}
|
||||
|
||||
### Take a break!
|
||||
This much Dependency Injection knowledge may be all that many Angular developers
|
||||
ever need to build their applications. It doesn't always have to be more complicated.
|
||||
|
||||
|
||||
~~~
|
||||
|
||||
<a id="multiple-service-instances"></a>
|
||||
## Multiple service instances (sandboxing)
|
||||
|
||||
Sometimes we want multiple instances of a service at *the same level of the component hierarchy*.
|
||||
|
||||
A good example is a service that holds state for its companion component instance.
|
||||
We need a separate instance of the service for each component.
|
||||
Each service has its own work-state, isolated from the service-and-state of a different component.
|
||||
We call this *sandboxing* because each service and component instance has its own sandbox to play in.
|
||||
|
||||
<a id="hero-bios-component"></a>
|
||||
Imagine a `HeroBiosComponent` that presents three instances of the `HeroBioComponent`.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-bios.component.ts' region='simple'}
|
||||
|
||||
Each `HeroBioComponent` can edit a single hero's biography.
|
||||
A `HeroBioComponent` relies on a `HeroCacheService` to fetch, cache, and perform other persistence operations on that hero.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-cache.service.ts' region='service'}
|
||||
|
||||
Clearly the three instances of the `HeroBioComponent` can't share the same `HeroCacheService`.
|
||||
They'd be competing with each other to determine which hero to cache.
|
||||
|
||||
Each `HeroBioComponent` gets its *own* `HeroCacheService` instance
|
||||
by listing the `HeroCacheService` in its metadata `providers` array.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-bio.component.ts' region='component'}
|
||||
|
||||
The parent `HeroBiosComponent` binds a value to the `heroId`.
|
||||
The `ngOnInit` pass that `id` to the service which fetches and caches the hero.
|
||||
The getter for the `hero` property pulls the cached hero from the service.
|
||||
And the template displays this data-bound property.
|
||||
|
||||
Find this example in <live-example name="cb-dependency-injection">live code</live-example>
|
||||
and confirm that the three `HeroBioComponent` instances have their own cached hero data.
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/hero-bios.png" alt="Bios"> </img>
|
||||
</figure>
|
||||
|
||||
|
||||
|
||||
{@a optional}
|
||||
|
||||
|
||||
{@a qualify-dependency-lookup}
|
||||
|
||||
## Qualify dependency lookup with *@Optional* and *@Host*
|
||||
We learned that dependencies can be registered at any level in the component hierarchy.
|
||||
|
||||
When a component requests a dependency, Angular starts with that component's injector and walks up the injector tree
|
||||
until it finds the first suitable provider. Angular throws an error if it can't find the dependency during that walk.
|
||||
|
||||
We *want* this behavior most of the time.
|
||||
But sometimes we need to limit the search and/or accommodate a missing dependency.
|
||||
We can modify Angular's search behavior with the `@Host` and `@Optional` qualifying decorators,
|
||||
used individually or together.
|
||||
|
||||
The `@Optional` decorator tells Angular to continue when it can't find the dependency.
|
||||
Angular sets the injection parameter to `null` instead.
|
||||
|
||||
The `@Host` decorator stops the upward search at the *host component*.
|
||||
|
||||
The host component is typically the component requesting the dependency.
|
||||
But when this component is projected into a *parent* component, that parent component becomes the host.
|
||||
We look at this second, more interesting case in our next example.
|
||||
|
||||
### Demonstration
|
||||
The `HeroBiosAndContactsComponent` is a revision of the `HeroBiosComponent` that we looked at [above](#hero-bios-component).
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-bios.component.ts' region='hero-bios-and-contacts'}
|
||||
|
||||
Focus on the template:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-bios.component.ts' region='template'}
|
||||
|
||||
We've inserted a `<hero-contact>` element between the `<hero-bio>` tags.
|
||||
Angular *projects* (*transcludes*) the corresponding `HeroContactComponent` into the `HeroBioComponent` view,
|
||||
placing it in the `<ng-content>` slot of the `HeroBioComponent` template:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-bio.component.ts' region='template'}
|
||||
|
||||
It looks like this, with the hero's telephone number from `HeroContactComponent` projected above the hero description:
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/hero-bio-and-content.png" alt="bio and contact"> </img>
|
||||
</figure>
|
||||
|
||||
Here's the `HeroContactComponent` which demonstrates the qualifying decorators that we're talking about in this section:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-contact.component.ts' region='component'}
|
||||
|
||||
Focus on the constructor parameters
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-contact.component.ts' region='ctor-params'}
|
||||
|
||||
The `@Host()` function decorating the `heroCache` property ensures that
|
||||
we get a reference to the cache service from the parent `HeroBioComponent`.
|
||||
Angular throws if the parent lacks that service, even if a component higher in the component tree happens to have that service.
|
||||
|
||||
A second `@Host()` function decorates the `loggerService` property.
|
||||
We know the only `LoggerService` instance in the app is provided at the `AppComponent` level.
|
||||
The host `HeroBioComponent` doesn't have its own `LoggerService` provider.
|
||||
|
||||
Angular would throw an error if we hadn't also decorated the property with the `@Optional()` function.
|
||||
Thanks to `@Optional()`, Angular sets the `loggerService` to null and the rest of the component adapts.
|
||||
|
||||
We'll come back to the `elementRef` property shortly.Here's the `HeroBiosAndContactsComponent` in action.
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/hero-bios-and-contacts.png" alt="Bios with contact into"> </img>
|
||||
</figure>
|
||||
|
||||
If we comment out the `@Host()` decorator, Angular now walks up the injector ancestor tree
|
||||
until it finds the logger at the `AppComponent` level. The logger logic kicks in and the hero display updates
|
||||
with the gratuitous "!!!", indicating that the logger was found.
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/hero-bio-contact-no-host.png" alt="Without @Host"> </img>
|
||||
</figure>
|
||||
|
||||
On the other hand, if we restore the `@Host()` decorator and comment out `@Optional`,
|
||||
the application fails for lack of the required logger at the host component level.
|
||||
<br>
|
||||
`EXCEPTION: No provider for LoggerService! (HeroContactComponent -> LoggerService)`
|
||||
<a id="component-element"></a>## Inject the component's element
|
||||
|
||||
On occasion we might need to access a component's corresponding DOM element.
|
||||
Although we strive to avoid it, many visual effects and 3rd party tools (such as jQuery)
|
||||
require DOM access.
|
||||
|
||||
To illustrate, we've written a simplified version of the `HighlightDirective` from
|
||||
the [Attribute Directives](../guide/attribute-directives.html) chapter.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/highlight.directive.ts'}
|
||||
|
||||
The directive sets the background to a highlight color when the user mouses over the
|
||||
DOM element to which it is applied.
|
||||
|
||||
Angular set the constructor's `el` parameter to the injected `ElementRef` which is
|
||||
a wrapper around that DOM element.
|
||||
Its `nativeElement` property exposes the DOM element for the directive to manipulate.
|
||||
|
||||
The sample code applies the directive's `myHighlight` attribute to two `<div>` tags,
|
||||
first without a value (yielding the default color) and then with an assigned color value.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/app.component.html' region='highlight'}
|
||||
|
||||
The following image shows the effect of mousing over the `<hero-bios-and-contacts>` tag.
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/highlight.png" alt="Highlighted bios"> </img>
|
||||
</figure>
|
||||
|
||||
<a id="providers"></a>
|
||||
## Define dependencies with providers
|
||||
|
||||
In this section we learn to write providers that deliver dependent services.
|
||||
|
||||
### Background
|
||||
We get a service from a dependency injector by giving it a ***token***.
|
||||
|
||||
We usually let Angular handle this transaction for us by specifying a constructor parameter and its type.
|
||||
The parameter type serves as the injector lookup *token*.
|
||||
Angular passes this token to the injector and assigns the result to the parameter.
|
||||
Here's a typical example:
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-bios.component.ts' region='ctor'}
|
||||
|
||||
Angular asks the injector for the service associated with the `LoggerService` and
|
||||
and assigns the returned value to the `logger` parameter.
|
||||
|
||||
Where did the injector get that value?
|
||||
It may already have that value in its internal container.
|
||||
If it doesn't, it may be able to make one with the help of a ***provider***.
|
||||
A *provider* is a recipe for delivering a service associated with a *token*.
|
||||
If the injector doesn't have a provider for the requested *token*, it delegates the request
|
||||
to its parent injector, where the process repeats until there are no more injectors.
|
||||
If the search is futile, the injector throws an error ... unless the request was [optional](#optional).
|
||||
|
||||
Let's return our attention to providers themselves.A new injector has no providers.
|
||||
Angular initializes the injectors it creates with some providers it cares about.
|
||||
We have to register our _own_ application providers manually,
|
||||
usually in the `providers` array of the `Component` or `Directive` metadata:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/app.component.ts' region='providers'}
|
||||
|
||||
### Defining providers
|
||||
|
||||
The simple class provider is the most typical by far.
|
||||
We mention the class in the `providers` array and we're done.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-bios.component.ts' region='class-provider'}
|
||||
|
||||
It's that simple because the most common injected service is an instance of a class.
|
||||
But not every dependency can be satisfied by creating a new instance of a class.
|
||||
We need other ways to deliver dependency values and that means we need other ways to specify a provider.
|
||||
|
||||
The `HeroOfTheMonthComponent` example demonstrates many of the alternatives and why we need them.
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/hero-of-month.png" alt="Hero of the month" width="300px"> </img>
|
||||
</figure>
|
||||
|
||||
It's visually simple: a few properties and the output of a logger. The code behind it gives us plenty to talk about.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-of-the-month.component.ts' region='hero-of-the-month'}
|
||||
|
||||
|
||||
|
||||
|
||||
{@a provide}
|
||||
#### The *provide* object literal
|
||||
|
||||
The `provide` object literal takes a *token* and a *definition object*.
|
||||
The *token* is usually a class but [it doesn't have to be](#tokens).
|
||||
|
||||
The *definition* object has one main property, (e.g. `useValue`) that indicates how the provider
|
||||
should create or return the provided value.
|
||||
|
||||
|
||||
|
||||
{@a usevalue}
|
||||
#### useValue - the *value provider*
|
||||
|
||||
Set the `useValue` property to a ***fixed value*** that the provider can return as the dependency object.
|
||||
|
||||
Use this technique to provide *runtime configuration constants* such as web-site base addresses and feature flags.
|
||||
We often use a *value provider* in a unit test to replace a production service with a fake or mock.
|
||||
|
||||
The `HeroOfTheMonthComponent` example has two *value providers*.
|
||||
The first provides an instance of the `Hero` class;
|
||||
the second specifies a literal string resource:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-of-the-month.component.ts' region='use-value'}
|
||||
|
||||
The `Hero` provider token is a class which makes sense because the value is a `Hero`
|
||||
and the consumer of the injected hero would want the type information.
|
||||
|
||||
The `TITLE` provider token is *not a class*.
|
||||
It's a special kind of provider lookup key called an [OpaqueToken](#opaquetoken).
|
||||
We often use an `OpaqueToken` when the dependency is a simple value like a string, a number, or a function.
|
||||
|
||||
The value of a *value provider* must be defined *now*. We can't create the value later.
|
||||
Obviously the title string literal is immediately available.
|
||||
The `someHero` variable in this example was set earlier in the file:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-of-the-month.component.ts' region='some-hero'}
|
||||
|
||||
The other providers create their values *lazily* when they're needed for injection.
|
||||
|
||||
|
||||
|
||||
{@a useclass}
|
||||
#### useClass - the *class provider*
|
||||
|
||||
The `useClass` provider creates and returns new instance of the specified class.
|
||||
|
||||
Use this technique to ***substitute an alternative implementation*** for a common or default class.
|
||||
The alternative could implement a different strategy, extend the default class,
|
||||
or fake the behavior of the real class in a test case.
|
||||
|
||||
We see two examples in the `HeroOfTheMonthComponent`:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-of-the-month.component.ts' region='use-class'}
|
||||
|
||||
The first provider is the *de-sugared*, expanded form of the most typical case in which the
|
||||
class to be created (`HeroService`) is also the provider's injection token.
|
||||
We wrote it in this long form to de-mystify the preferred short form.
|
||||
|
||||
The second provider substitutes the `DateLoggerService` for the `LoggerService`.
|
||||
The `LoggerService` is already registered at the `AppComponent` level.
|
||||
When _this component_ requests the `LoggerService`, it receives the `DateLoggerService` instead.
|
||||
This component and its tree of child components receive the `DateLoggerService` instance.
|
||||
Components outside the tree continue to receive the original `LoggerService` instance.The `DateLoggerService` inherits from `LoggerService`; it appends the current date/time to each message:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/date-logger.service.ts' region='date-logger-service'}
|
||||
|
||||
|
||||
|
||||
|
||||
{@a useexisting}
|
||||
#### useExisting - the *alias provider*
|
||||
|
||||
The `useExisting` provider maps one token to another.
|
||||
In effect, the first token is an ***alias*** for the service associated with second token,
|
||||
creating ***two ways to access the same service object***.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-of-the-month.component.ts' region='use-existing'}
|
||||
|
||||
Narrowing an API through an aliasing interface is _one_ important use case for this technique.
|
||||
We're aliasing for that very purpose here.
|
||||
Imagine that the `LoggerService` had a large API (it's actually only three methods and a property).
|
||||
We want to shrink that API surface to just the two members exposed by the `MinimalLogger` [*class-interface*](#class-interface):
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/date-logger.service.ts' region='minimal-logger'}
|
||||
|
||||
The constructor's `logger` parameter is typed as `MinimalLogger` so only its two members are visible in TypeScript:
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/minimal-logger-intellisense.png" alt="MinimalLogger restricted API"> </img>
|
||||
</figure>
|
||||
|
||||
Angular actually sets the `logger` parameter to the injector's full version of the `LoggerService`
|
||||
which happens to be the `DateLoggerService` thanks to the override provider registered previously via `useClass`.
|
||||
The following image, which displays the logging date, confirms the point:
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/date-logger-entry.png" alt="DateLoggerService entry" width="300px"> </img>
|
||||
</figure>
|
||||
|
||||
|
||||
|
||||
|
||||
{@a usefactory}
|
||||
#### useFactory - the *factory provider*
|
||||
|
||||
The `useFactory` provider creates a dependency object by calling a factory function
|
||||
as seen in this example.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-of-the-month.component.ts' region='use-factory'}
|
||||
|
||||
Use this technique to ***create a dependency object***
|
||||
with a factory function whose inputs are some ***combination of injected services and local state***.
|
||||
|
||||
The *dependency object* doesn't have to be a class instance. It could be anything.
|
||||
In this example, the *dependency object* is a string of the names of the runners-up
|
||||
to the "Hero of the Month" contest.
|
||||
|
||||
The local state is the number `2`, the number of runners-up this component should show.
|
||||
We execute `runnersUpFactory` immediately with `2`.
|
||||
|
||||
The `runnersUpFactory` itself isn't the provider factory function.
|
||||
The true provider factory function is the function that `runnersUpFactory` returns.
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/runners-up.ts' region='factory-synopsis'}
|
||||
|
||||
That returned function takes a winning `Hero` and a `HeroService` as arguments.
|
||||
|
||||
Angular supplies these arguments from injected values identified by
|
||||
the two *tokens* in the `deps` array.
|
||||
The two `deps` values are *tokens* that the injector uses
|
||||
to provide these factory function dependencies.
|
||||
|
||||
After some undisclosed work, the function returns the string of names
|
||||
and Angular injects it into the `runnersUp` parameter of the `HeroOfTheMonthComponent`.
|
||||
|
||||
The function retrieves candidate heroes from the `HeroService`,
|
||||
takes `2` of them to be the runners-up, and returns their concatenated names.
|
||||
Look at the <live-example name="cb-dependency-injection"></live-example>
|
||||
for the full source code.
|
||||
|
||||
|
||||
{@a tokens}
|
||||
|
||||
## Provider token alternatives: the *class-interface* and *OpaqueToken*
|
||||
|
||||
Angular dependency injection is easiest when the provider *token* is a class
|
||||
that is also the type of the returned dependency object (what we usually call the *service*).
|
||||
|
||||
But the token doesn't have to be a class and even when it is a class,
|
||||
it doesn't have to be the same type as the returned object.
|
||||
That's the subject of our next section.
|
||||
|
||||
<a id="class-interface"></a>
|
||||
### class-interface
|
||||
In the previous *Hero of the Month* example, we used the `MinimalLogger` class
|
||||
as the token for a provider of a `LoggerService`.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-of-the-month.component.ts' region='use-existing'}
|
||||
|
||||
The `MinimalLogger` is an abstract class.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/date-logger.service.ts' region='minimal-logger'}
|
||||
|
||||
We usually inherit from an abstract class.
|
||||
But `LoggerService` doesn't inherit from `MinimalLogger`. *No class* inherits from it.
|
||||
Instead, we use it like an interface.
|
||||
|
||||
Look again at the declaration for `DateLoggerService`
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/date-logger.service.ts' region='date-logger-service-signature'}
|
||||
|
||||
`DateLoggerService` inherits (extends) from `LoggerService`, not `MinimalLogger`.
|
||||
The `DateLoggerService` *implements* `MinimalLogger` as if `MinimalLogger` were an *interface*.
|
||||
|
||||
We call a class used in this way a ***class-interface***.
|
||||
The key benefit of a *class-interface* is that we can get the strong-typing of an interface
|
||||
and we can ***use it as a provider token*** in the same manner as a normal class.
|
||||
|
||||
A ***class-interface*** should define *only* the members that its consumers are allowed to call.
|
||||
Such a narrowing interface helps decouple the concrete class from its consumers.
|
||||
The `MinimalLogger` defines just two of the `LoggerClass` members.
|
||||
|
||||
#### Why *MinimalLogger* is a class and not an interface
|
||||
We can't use an interface as a provider token because
|
||||
interfaces are not JavaScript objects.
|
||||
They exist only in the TypeScript design space.
|
||||
They disappear after the code is transpiled to JavaScript.
|
||||
|
||||
A provider token must be a real JavaScript object of some kind:
|
||||
a function, an object, a string ... a class.
|
||||
|
||||
Using a class as an interface gives us the characteristics of an interface in a JavaScript object.
|
||||
|
||||
The minimize memory cost, the class should have *no implementation*.
|
||||
The `MinimalLogger` transpiles to this unoptimized, pre-minified JavaScript:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/date-logger.service.ts' region='minimal-logger-transpiled'}
|
||||
|
||||
It never grows larger no matter how many members we add *as long as they are typed but not implemented*.
|
||||
|
||||
|
||||
{@a opaque-token}
|
||||
### OpaqueToken
|
||||
|
||||
Dependency objects can be simple values like dates, numbers and strings or
|
||||
shapeless objects like arrays and functions.
|
||||
|
||||
Such objects don't have application interfaces and therefore aren't well represented by a class.
|
||||
They're better represented by a token that is both unique and symbolic,
|
||||
a JavaScript object that has a friendly name but won't conflict with
|
||||
another token that happens to have the same name.
|
||||
|
||||
The `OpaqueToken` has these characteristics.
|
||||
We encountered them twice in the *Hero of the Month* example,
|
||||
in the *title* value provider and in the *runnersUp* factory provider.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-of-the-month.component.ts' region='provide-opaque-token'}
|
||||
|
||||
We created the `TITLE` token like this:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/hero-of-the-month.component.ts' region='opaque-token'}
|
||||
|
||||
|
||||
|
||||
{@a di-inheritance}
|
||||
|
||||
## Inject into a derived class
|
||||
We must take care when writing a component that inherits from another component.
|
||||
If the base component has injected dependencies,
|
||||
we must re-provide and re-inject them in the derived class
|
||||
and then pass them down to the base class through the constructor.
|
||||
|
||||
In this contrived example, `SortedHeroesComponent` inherits from `HeroesBaseComponent`
|
||||
to display a *sorted* list of heroes.
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/sorted-heroes.png" alt="Sorted Heroes"> </img>
|
||||
</figure>
|
||||
|
||||
The `HeroesBaseComponent` could stand on its own.
|
||||
It demands its own instance of the `HeroService` to get heroes
|
||||
and displays them in the order they arrive from the database.
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/sorted-heroes.component.ts' region='heroes-base'}
|
||||
|
||||
|
||||
We strongly prefer simple constructors. They should do little more than initialize variables.
|
||||
This rule makes the component safe to construct under test without fear that it will do something dramatic like talk to the server.
|
||||
That's why we call the `HeroService` from within the `ngOnInit` rather than the constructor.
|
||||
|
||||
We explain the mysterious `afterGetHeroes` below.Users want to see the heroes in alphabetical order.
|
||||
Rather than modify the original component, we sub-class it and create a
|
||||
`SortedHeroesComponent` that sorts the heroes before presenting them.
|
||||
The `SortedHeroesComponent` lets the base class fetch the heroes.
|
||||
(we said it was contrived).
|
||||
|
||||
Unfortunately, Angular cannot inject the `HeroService` directly into the base class.
|
||||
We must provide the `HeroService` again for *this* component,
|
||||
then pass it down to the base class inside the constructor.
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/sorted-heroes.component.ts' region='sorted-heroes'}
|
||||
|
||||
Now take note of the `afterGetHeroes` method.
|
||||
Our first instinct was to create an `ngOnInit` method in `SortedHeroesComponent` and do the sorting there.
|
||||
But Angular calls the *derived* class's `ngOnInit` *before* calling the base class's `ngOnInit`
|
||||
so we'd be sorting the heroes array *before they arrived*. That produces a nasty error.
|
||||
|
||||
Overriding the base class's `afterGetHeroes` method solves the problem
|
||||
|
||||
These complications argue for *avoiding component inheritance*.
|
||||
|
||||
|
||||
{@a find-parent}
|
||||
|
||||
## Find a parent component by injection
|
||||
|
||||
Application components often need to share information.
|
||||
We prefer the more loosely coupled techniques such as data binding and service sharing.
|
||||
But sometimes it makes sense for one component to have a direct reference to another component
|
||||
perhaps to access values or call methods on that component.
|
||||
|
||||
Obtaining a component reference is a bit tricky in Angular.
|
||||
Although an Angular application is a tree of components,
|
||||
there is no public API for inspecting and traversing that tree.
|
||||
|
||||
There is an API for acquiring a child reference
|
||||
(checkout `Query`, `QueryList`, `ViewChildren`, and `ContentChildren`).
|
||||
|
||||
There is no public API for acquiring a parent reference.
|
||||
But because every component instance is added to an injector's container,
|
||||
we can use Angular dependency injection to reach a parent component.
|
||||
|
||||
This section describes some techniques for doing that.
|
||||
|
||||
<a id="known-parent"></a>
|
||||
### Find a parent component of known type
|
||||
|
||||
We use standard class injection to acquire a parent component whose type we know.
|
||||
|
||||
In the following example, the parent `AlexComponent` has several children including a `CathyComponent`:
|
||||
|
||||
{@a alex}
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='alex-1'}
|
||||
|
||||
*Cathy* reports whether or not she has access to *Alex*
|
||||
after injecting an `AlexComponent` into her constructor:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='cathy'}
|
||||
|
||||
We added the [@Optional](#optional) qualifier for safety but
|
||||
the <live-example name="cb-dependency-injection"></live-example>
|
||||
confirms that the `alex` parameter is set.
|
||||
|
||||
<a id="base-parent"></a>
|
||||
### Cannot find a parent by its base class
|
||||
|
||||
What if we do *not* know the concrete parent component class?
|
||||
|
||||
A re-usable component might be a child of multiple components.
|
||||
Imagine a component for rendering breaking news about a financial instrument.
|
||||
For sound (cough) business reasons, this news component makes frequent calls
|
||||
directly into its parent instrument as changing market data stream by.
|
||||
|
||||
The app probably defines more than a dozen financial instrument components.
|
||||
If we're lucky, they all implement the same base class
|
||||
whose API our `NewsComponent` understands.
|
||||
|
||||
Looking for components that implement an interface would be better.
|
||||
That's not possible because TypeScript interfaces disappear from the transpiled JavaScript
|
||||
which doesn't support interfaces. There's no artifact we could look for.We're not claiming this is good design.
|
||||
We are asking *can a component inject its parent via the parent's base class*?
|
||||
|
||||
The sample's `CraigComponent` explores this question. [Looking back](#alex)
|
||||
we see that the `Alex` component *extends* (*inherits*) from a class named `Base`.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='alex-class-signature'}
|
||||
|
||||
The `CraigComponent` tries to inject `Base` into its `alex` constructor parameter and reports if it succeeded.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='craig'}
|
||||
|
||||
Unfortunately, this does not work.
|
||||
The <live-example name="cb-dependency-injection"></live-example>
|
||||
confirms that the `alex` parameter is null.
|
||||
*We cannot inject a parent by its base class.*
|
||||
|
||||
<a id="class-interface-parent"></a>
|
||||
### Find a parent by its class-interface
|
||||
|
||||
We can find a parent component with a [class-interface](#class-interface).
|
||||
|
||||
The parent must cooperate by providing an *alias* to itself in the name of a *class-interface* token.
|
||||
|
||||
Recall that Angular always adds a component instance to its own injector;
|
||||
that's why we could inject *Alex* into *Cathy* [earlier](#known-parent).
|
||||
|
||||
We write an [*alias provider*](#useexisting) — a `provide` object literal with a `useExisting` definition —
|
||||
that creates an *alternative* way to inject the same component instance
|
||||
and add that provider to the `providers` array of the `@Component` metadata for the `AlexComponent`:
|
||||
|
||||
{@a alex-providers}
|
||||
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='alex-providers'}
|
||||
|
||||
[Parent](#parent-token) is the provider's *class-interface* token.
|
||||
The [*forwardRef*](#forwardref) breaks the circular reference we just created by having the `AlexComponent` refer to itself.
|
||||
|
||||
*Carol*, the third of *Alex*'s child components, injects the parent into its `parent` parameter, the same way we've done it before:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='carol-class'}
|
||||
|
||||
Here's *Alex* and family in action:
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/alex.png" alt="Alex in action"> </img>
|
||||
</figure>
|
||||
|
||||
|
||||
|
||||
{@a parent-tree}
|
||||
### Find the parent in a tree of parents
|
||||
|
||||
Imagine one branch of a component hierarchy: *Alice* -> *Barry* -> *Carol*.
|
||||
Both *Alice* and *Barry* implement the `Parent` *class-interface*.
|
||||
|
||||
*Barry* is the problem. He needs to reach his parent, *Alice*, and also be a parent to *Carol*.
|
||||
That means he must both *inject* the `Parent` *class-interface* to get *Alice* and
|
||||
*provide* a `Parent` to satisfy *Carol*.
|
||||
|
||||
Here's *Barry*:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='barry'}
|
||||
|
||||
*Barry*'s `providers` array looks just like [*Alex*'s](#alex-providers).
|
||||
If we're going to keep writing [*alias providers*](#useexisting) like this we should create a [helper function](#provideparent).
|
||||
|
||||
For now, focus on *Barry*'s constructor:
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="Barry's constructor">
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='barry-ctor'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="Carol's constructor">
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='carol-ctor'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
It's identical to *Carol*'s constructor except for the additional `@SkipSelf` decorator.
|
||||
|
||||
`@SkipSelf` is essential for two reasons:
|
||||
|
||||
1. It tells the injector to start its search for a `Parent` dependency in a component *above* itself,
|
||||
which *is* what parent means.
|
||||
|
||||
2. Angular throws a cyclic dependency error if we omit the `@SkipSelf` decorator.
|
||||
|
||||
`Cannot instantiate cyclic dependency! (BethComponent -> Parent -> BethComponent)`
|
||||
|
||||
Here's *Alice*, *Barry* and family in action:
|
||||
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dependency-injection/alice.png" alt="Alice in action"> </img>
|
||||
</figure>
|
||||
|
||||
|
||||
|
||||
{@a parent-token}
|
||||
### The *Parent* class-interface
|
||||
We [learned earlier](#class-interface) that a *class-interface* is an abstract class used as an interface rather than as a base class.
|
||||
|
||||
Our example defines a `Parent` *class-interface* .
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='parent'}
|
||||
|
||||
The `Parent` *class-interface* defines a `name` property with a type declaration but *no implementation*.,
|
||||
The `name` property is the only member of a parent component that a child component can call.
|
||||
Such a narrowing interface helps decouple the child component class from its parent components.
|
||||
|
||||
A component that could serve as a parent *should* implement the *class-interface* as the `AliceComponent` does:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='alice-class-signature'}
|
||||
|
||||
Doing so adds clarity to the code. But it's not technically necessary.
|
||||
Although the `AlexComponent` has a `name` property (as required by its `Base` class)
|
||||
its class signature doesn't mention `Parent`:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='alex-class-signature'}
|
||||
|
||||
|
||||
The `AlexComponent` *should* implement `Parent` as a matter of proper style.
|
||||
It doesn't in this example *only* to demonstrate that the code will compile and run without the interface
|
||||
|
||||
|
||||
{@a provideparent}
|
||||
### A *provideParent* helper function
|
||||
|
||||
Writing variations of the same parent *alias provider* gets old quickly,
|
||||
especially this awful mouthful with a [*forwardRef*](#forwardref):
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='alex-providers'}
|
||||
|
||||
We can extract that logic into a helper function like this:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='provide-the-parent'}
|
||||
|
||||
Now we can add a simpler, more meaningful parent provider to our components:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='alice-providers'}
|
||||
|
||||
We can do better. The current version of the helper function can only alias the `Parent` *class-interface*.
|
||||
Our application might have a variety of parent types, each with its own *class-interface* token.
|
||||
|
||||
Here's a revised version that defaults to `parent` but also accepts an optional second parameter for a different parent *class-interface*.
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='provide-parent'}
|
||||
|
||||
And here's how we could use it with a different parent type:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='beth-providers'}
|
||||
|
||||
|
||||
|
||||
{@a forwardref}
|
||||
|
||||
## Break circularities with a forward class reference (*forwardRef*)
|
||||
|
||||
The order of class declaration matters in TypeScript.
|
||||
We can't refer directly to a class until it's been defined.
|
||||
|
||||
This isn't usually a problem, especially if we adhere to the recommended *one class per file* rule.
|
||||
But sometimes circular references are unavoidable.
|
||||
We're in a bind when class 'A refers to class 'B' and 'B' refers to 'A'.
|
||||
One of them has to be defined first.
|
||||
|
||||
The Angular `forwardRef` function creates an *indirect* reference that Angular can resolve later.
|
||||
|
||||
The *Parent Finder* sample is full of circular class references that are impossible to break.
|
||||
We face this dilemma when a class makes *a reference to itself*
|
||||
as does the `AlexComponent` in its `providers` array.
|
||||
The `providers` array is a property of the `@Component` decorator function which must
|
||||
appear *above* the class definition.
|
||||
|
||||
We break the circularity with `forwardRef`:
|
||||
|
||||
{@example 'cb-dependency-injection/ts/src/app/parent-finder.component.ts' region='alex-providers'}
|
||||
|
@ -1,138 +0,0 @@
|
||||
@title
|
||||
Dynamic Component Loader
|
||||
|
||||
@intro
|
||||
Load components dynamically
|
||||
|
||||
@description
|
||||
Component templates are not always fixed. An application may need to load new components at runtime.
|
||||
|
||||
In this cookbook we show how to use `ComponentFactoryResolver` to add components dynamically.
|
||||
|
||||
<a id="toc"></a>## Table of contents
|
||||
|
||||
[Dynamic Component Loading](#dynamic-loading)
|
||||
|
||||
[Where to load the component](#where-to-load)
|
||||
|
||||
[Loading components](#loading-components)
|
||||
|
||||
<a id="dynamic-loading"></a>## Dynamic Component Loading
|
||||
|
||||
The following example shows how to build a dynamic ad banner.
|
||||
|
||||
The hero agency is planning an ad campaign with several different ads cycling through the banner.
|
||||
|
||||
New ad components are added frequently by several different teams. This makes it impractical to use a template with a static component structure.
|
||||
|
||||
Instead we need a way to load a new component without a fixed reference to the component in the ad banner's template.
|
||||
|
||||
Angular comes with its own API for loading components dynamically. In the following sections you will learn how to use it.
|
||||
|
||||
|
||||
<a id="where-to-load"></a>## Where to load the component
|
||||
|
||||
Before components can be added we have to define an anchor point to mark where components can be inserted dynamically.
|
||||
|
||||
The ad banner uses a helper directive called `AdDirective` to mark valid insertion points in the template.
|
||||
|
||||
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/ad.directive.ts'}
|
||||
|
||||
`AdDirective` injects `ViewContainerRef` to gain access to the view container of the element that will become the host of the dynamically added component.
|
||||
|
||||
<a id="loading-components"></a>## Loading components
|
||||
|
||||
The next step is to implement the ad banner. Most of the implementation is in `AdBannerComponent`.
|
||||
|
||||
We start by adding a `template` element with the `AdDirective` directive applied.
|
||||
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="ad-banner.component.ts">
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/ad-banner.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="ad.service.ts">
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/ad.service.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="ad-item.ts">
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/ad-item.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="app.module.ts">
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/app.module.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="app.component">
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/app.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
The `template` element decorated with the `ad-host` directive marks where dynamically loaded components will be added.
|
||||
|
||||
Using a `template` element is recommended since it doesn't render any additional output.
|
||||
|
||||
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/ad-banner.component.ts' region='ad-host'}
|
||||
|
||||
### Resolving Components
|
||||
|
||||
`AdBanner` takes an array of `AdItem` objects as input. `AdItem` objects specify the type of component to load and any data to bind to the component.
|
||||
|
||||
The ad components making up the ad campaign are returned from `AdService`.
|
||||
|
||||
Passing an array of components to `AdBannerComponent` allows for a dynamic list of ads without static elements in the template.
|
||||
|
||||
`AdBannerComponent` cycles through the array of `AdItems` and loads the corresponding components on an interval. Every 3 seconds a new component is loaded.
|
||||
|
||||
`ComponentFactoryResolver` is used to resolve a `ComponentFactory` for each specific component. The component factory is need to create an instance of the component.
|
||||
|
||||
`ComponentFactories` are generated by the Angular compiler.
|
||||
|
||||
Generally the compiler will generate a component factory for any component referenced in a template.
|
||||
|
||||
With dynamically loaded components there are no selector references in the templates since components are loaded at runtime. In order to ensure that the compiler will still generate a factory, dynamically loaded components have to be added to their `NgModule`'s `entryComponents` array.
|
||||
|
||||
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/app.module.ts' region='entry-components'}
|
||||
|
||||
Components are added to the template by calling `createComponent` on the `ViewContainerRef` reference.
|
||||
|
||||
`createComponent` returns a reference to the loaded component. The component reference can be used to pass input data or call methods to interact with the component.
|
||||
|
||||
In the Ad banner, all components implement a common `AdComponent` interface to standardize the api for passing data to the components.
|
||||
|
||||
Two sample components and the `AdComponent` interface are shown below:
|
||||
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="hero-job-ad.component.ts">
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/hero-job-ad.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="hero-profile.component.ts">
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/hero-profile.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="ad.component.ts">
|
||||
{@example 'cb-dynamic-component-loader/ts/src/app/ad.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
The final ad banner looks like this:
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dynamic-component-loader/ads.gif" alt="Ads"> </img>
|
||||
</figure>
|
||||
|
@ -1,168 +0,0 @@
|
||||
@title
|
||||
Dynamic Forms
|
||||
|
||||
@intro
|
||||
Render dynamic forms with FormGroup
|
||||
|
||||
@description
|
||||
We can't always justify the cost and time to build handcrafted forms,
|
||||
especially if we'll need a great number of them, they're similar to each other, and they change frequently
|
||||
to meet rapidly changing business and regulatory requirements.
|
||||
|
||||
It may be more economical to create the forms dynamically, based on metadata that describe the business object model.
|
||||
|
||||
In this cookbook we show how to use `formGroup` to dynamically render a simple form with different control types and validation.
|
||||
It's a primitive start.
|
||||
It might evolve to support a much richer variety of questions, more graceful rendering, and superior user experience.
|
||||
All such greatness has humble beginnings.
|
||||
|
||||
In our example we use a dynamic form to build an online application experience for heroes seeking employment.
|
||||
The agency is constantly tinkering with the application process.
|
||||
We can create the forms on the fly *without changing our application code*.
|
||||
<a id="toc"></a>## Table of contents
|
||||
|
||||
[Bootstrap](#bootstrap)
|
||||
|
||||
[Question Model](#object-model)
|
||||
|
||||
[Form Component](#form-component)
|
||||
|
||||
[Questionnaire Metadata](#questionnaire-metadata)
|
||||
|
||||
[Dynamic Template](#dynamic-template)
|
||||
**See the <live-example name="cb-dynamic-form"></live-example>**.
|
||||
|
||||
<a id="bootstrap"></a>## Bootstrap
|
||||
|
||||
We start by creating an `NgModule` called `AppModule`.
|
||||
|
||||
In our example we will be using Reactive Forms.
|
||||
|
||||
Reactive Forms belongs to a different `NgModule` called `ReactiveFormsModule`, so in order to access any Reactive Forms directives, we have to import `ReactiveFormsModule` from the `@angular/forms` library.
|
||||
|
||||
We bootstrap our `AppModule` in main.ts.
|
||||
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="app.module.ts">
|
||||
{@example 'cb-dynamic-form/ts/src/app/app.module.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="main.ts">
|
||||
{@example 'cb-dynamic-form/ts/src/main.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
|
||||
<a id="object-model"></a>## Question Model
|
||||
|
||||
The next step is to define an object model that can describe all scenarios needed by the form functionality.
|
||||
The hero application process involves a form with a lot of questions.
|
||||
The "question" is the most fundamental object in the model.
|
||||
|
||||
We have created `QuestionBase` as the most fundamental question class.
|
||||
|
||||
|
||||
{@example 'cb-dynamic-form/ts/src/app/question-base.ts'}
|
||||
|
||||
From this base we derived two new classes in `TextboxQuestion` and `DropdownQuestion` that represent Textbox and Dropdown questions.
|
||||
The idea is that the form will be bound to specific question types and render the appropriate controls dynamically.
|
||||
|
||||
`TextboxQuestion` supports multiple html5 types like text, email, url etc via the `type` property.
|
||||
|
||||
|
||||
{@example 'cb-dynamic-form/ts/src/app/question-textbox.ts'}
|
||||
|
||||
`DropdownQuestion` presents a list of choices in a select box.
|
||||
|
||||
|
||||
{@example 'cb-dynamic-form/ts/src/app/question-dropdown.ts'}
|
||||
|
||||
Next we have defined `QuestionControlService`, a simple service for transforming our questions to a `FormGroup`.
|
||||
In a nutshell, the form group consumes the metadata from the question model and allows us to specify default values and validation rules.
|
||||
|
||||
|
||||
{@example 'cb-dynamic-form/ts/src/app/question-control.service.ts'}
|
||||
|
||||
<a id="form-component"></a>## Question form components
|
||||
Now that we have defined the complete model we are ready to create components to represent the dynamic form.
|
||||
`DynamicFormComponent` is the entry point and the main container for the form.
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="dynamic-form.component.html">
|
||||
{@example 'cb-dynamic-form/ts/src/app/dynamic-form.component.html'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="dynamic-form.component.ts">
|
||||
{@example 'cb-dynamic-form/ts/src/app/dynamic-form.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
It presents a list of questions, each question bound to a `<df-question>` component element.
|
||||
The `<df-question>` tag matches the `DynamicFormQuestionComponent`,
|
||||
the component responsible for rendering the details of each _individual_ question based on values in the data-bound question object.
|
||||
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="dynamic-form-question.component.html">
|
||||
{@example 'cb-dynamic-form/ts/src/app/dynamic-form-question.component.html'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="dynamic-form-question.component.ts">
|
||||
{@example 'cb-dynamic-form/ts/src/app/dynamic-form-question.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
Notice this component can present any type of question in our model.
|
||||
We only have two types of questions at this point but we can imagine many more.
|
||||
The `ngSwitch` determines which type of question to display.
|
||||
|
||||
In both components we're relying on Angular's **formGroup** to connect the template HTML to the
|
||||
underlying control objects, populated from the question model with display and validation rules.
|
||||
|
||||
`formControlName` and `formGroup` are directives defined in `ReactiveFormsModule`. Our templates can can access these directives directly since we imported `ReactiveFormsModule` from `AppModule`.
|
||||
<a id="questionnaire-metadata"></a>## Questionnaire data`DynamicFormComponent` expects the list of questions in the form of an array bound to `@Input() questions`.
|
||||
|
||||
The set of questions we have defined for the job application is returned from the `QuestionService`.
|
||||
In a real app we'd retrieve these questions from storage.
|
||||
|
||||
The key point is that we control the hero job application questions entirely through the objects returned from `QuestionService`.
|
||||
Questionnaire maintenance is a simple matter of adding, updating, and removing objects from the `questions` array.
|
||||
|
||||
|
||||
{@example 'cb-dynamic-form/ts/src/app/question.service.ts'}
|
||||
|
||||
Finally, we display an instance of the form in the `AppComponent` shell.
|
||||
|
||||
|
||||
{@example 'cb-dynamic-form/ts/src/app/app.component.ts'}
|
||||
|
||||
<a id="dynamic-template"></a>## Dynamic Template
|
||||
Although in this example we're modelling a job application for heroes, there are no references to any specific hero question
|
||||
outside the objects returned by `QuestionService`.
|
||||
|
||||
This is very important since it allows us to repurpose the components for any type of survey
|
||||
as long as it's compatible with our *question* object model.
|
||||
The key is the dynamic data binding of metadata used to render the form
|
||||
without making any hardcoded assumptions about specific questions.
|
||||
In addition to control metadata, we are also adding validation dynamically.
|
||||
|
||||
The *Save* button is disabled until the form is in a valid state.
|
||||
When the form is valid, we can click *Save* and the app renders the current form values as JSON.
|
||||
This proves that any user input is bound back to the data model.
|
||||
Saving and retrieving the data is an exercise for another time.
|
||||
The final form looks like this:
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/dynamic-form/dynamic-form.png" alt="Dynamic-Form"> </img>
|
||||
</figure>
|
||||
|
||||
[Back to top](#top)
|
@ -1,486 +0,0 @@
|
||||
@title
|
||||
Form Validation
|
||||
|
||||
@intro
|
||||
Validate user's form entries
|
||||
|
||||
@description
|
||||
|
||||
|
||||
{@a top}
|
||||
We can improve overall data quality by validating user input for accuracy and completeness.
|
||||
|
||||
In this cookbook we show how to validate user input in the UI and display useful validation messages
|
||||
using first the template-driven forms and then the reactive forms approach.
|
||||
Learn more about these choices in the [Forms chapter.](../guide/forms.html)
|
||||
|
||||
|
||||
{@a toc}
|
||||
## Table of Contents
|
||||
|
||||
[Simple Template-Driven Forms](#template1)
|
||||
|
||||
[Template-Driven Forms with validation messages in code](#template2)
|
||||
|
||||
[Reactive Forms with validation in code](#reactive)
|
||||
|
||||
[Custom validation](#custom-validation)
|
||||
|
||||
[Testing](#testing)
|
||||
|
||||
|
||||
{@a live-example}
|
||||
**Try the live example to see and download the full cookbook source code**
|
||||
<live-example name="cb-form-validation" embedded=true img="cookbooks/form-validation/plunker.png">
|
||||
|
||||
</live-example>
|
||||
|
||||
|
||||
|
||||
|
||||
{@a template1}
|
||||
## Simple Template-Driven Forms
|
||||
|
||||
In the template-driven approach, you arrange
|
||||
[form elements](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Forms_in_HTML) in the component's template.
|
||||
|
||||
You add Angular form directives (mostly directives beginning `ng...`) to help
|
||||
Angular construct a corresponding internal control model that implements form functionality.
|
||||
We say that the control model is _implicit_ in the template.
|
||||
|
||||
To validate user input, you add [HTML validation attributes](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation)
|
||||
to the elements. Angular interprets those as well, adding validator functions to the control model.
|
||||
|
||||
Angular exposes information about the state of the controls including
|
||||
whether the user has "touched" the control or made changes and if the control values are valid.
|
||||
|
||||
In the first template validation example,
|
||||
we add more HTML to read that control state and update the display appropriately.
|
||||
Here's an excerpt from the template html for a single input box control bound to the hero name:
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template1.component.html' region='name-with-error-msg'}
|
||||
|
||||
Note the following:
|
||||
- The `<input>` element carries the HTML validation attributes: `required`, `minlength`, and `maxlength`.
|
||||
|
||||
- We set the `name` attribute of the input box to `"name"` so Angular can track this input element and associate it
|
||||
with an Angular form control called `name` in its internal control model.
|
||||
|
||||
- We use the `[(ngModel)]` directive to two-way data bind the input box to the `hero.name` property.
|
||||
|
||||
- We set a template variable (`#name`) to the value `"ngModel"` (always `ngModel`).
|
||||
This gives us a reference to the Angular `NgModel` directive
|
||||
associated with this control that we can use _in the template_
|
||||
to check for control states such as `valid` and `dirty`.
|
||||
|
||||
- The `*ngIf` on `<div>` element reveals a set of nested message `divs` but only if there are "name" errors and
|
||||
the control is either `dirty` or `touched`.
|
||||
|
||||
- Each nested `<div>` can present a custom message for one of the possible validation errors.
|
||||
We've prepared messages for `required`, `minlength`, and `maxlength`.
|
||||
|
||||
The full template repeats this kind of layout for each data entry control on the form.
|
||||
#### Why check _dirty_ and _touched_?
|
||||
|
||||
We shouldn't show errors for a new hero before the user has had a chance to edit the value.
|
||||
The checks for `dirty` and `touched` prevent premature display of errors.
|
||||
|
||||
Learn about `dirty` and `touched` in the [Forms](../guide/forms.html) chapter.The component class manages the hero model used in the data binding
|
||||
as well as other code to support the view.
|
||||
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template1.component.ts' region='class'}
|
||||
|
||||
Use this template-driven validation technique when working with static forms with simple, standard validation rules.
|
||||
|
||||
Here are the complete files for the first version of `HeroFormTemplateCompononent` in the template-driven approach:
|
||||
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="template/hero-form-template1.component.html">
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template1.component.html'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="template/hero-form-template1.component.ts">
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template1.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
|
||||
|
||||
|
||||
{@a template2}
|
||||
## Template-Driven Forms with validation messages in code
|
||||
|
||||
While the layout is straightforward,
|
||||
there are obvious shortcomings with the way we handle validation messages:
|
||||
|
||||
* It takes a lot of HTML to represent all possible error conditions.
|
||||
This gets out of hand when there are many controls and many validation rules.
|
||||
|
||||
* We're not fond of so much JavaScript logic in HTML.
|
||||
|
||||
* The messages are static strings, hard-coded into the template.
|
||||
We often require dynamic messages that we should shape in code.
|
||||
|
||||
We can move the logic and the messages into the component with a few changes to
|
||||
the template and component.
|
||||
|
||||
Here's the hero name again, excerpted from the revised template ("Template 2"), next to the original version:
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="hero-form-template2.component.html (name #2)">
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template2.component.html' region='name-with-error-msg'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="hero-form-template1.component.html (name #1)">
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template1.component.html' region='name-with-error-msg'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
The `<input>` element HTML is almost the same. There are noteworthy differences:
|
||||
- The hard-code error message `<divs>` are gone.
|
||||
|
||||
- There's a new attribute, `forbiddenName`, that is actually a custom validation directive.
|
||||
It invalidates the control if the user enters "bob" anywhere in the name ([try it](#live-example)).
|
||||
We discuss [custom validation directives](#custom-validation) later in this cookbook.
|
||||
|
||||
- The `#name` template variable is gone because we no longer refer to the Angular control for this element.
|
||||
|
||||
- Binding to the new `formErrors.name` property is sufficent to display all name validation error messages.
|
||||
|
||||
#### Component class
|
||||
The original component code stays the same.
|
||||
We _added_ new code to acquire the Angular form control and compose error messages.
|
||||
|
||||
The first step is to acquire the form control that Angular created from the template by querying for it.
|
||||
|
||||
Look back at the top of the component template where we set the
|
||||
`#heroForm` template variable in the `<form>` element:
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template1.component.html' region='form-tag'}
|
||||
|
||||
The `heroForm` variable is a reference to the control model that Angular derived from the template.
|
||||
We tell Angular to inject that model into the component class's `currentForm` property using a `@ViewChild` query:
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template2.component.ts' region='view-child'}
|
||||
|
||||
Some observations:
|
||||
|
||||
- Angular `@ViewChild` queries for a template variable when you pass it
|
||||
the name of that variable as a string (`'heroForm'` in this case).
|
||||
|
||||
- The `heroForm` object changes several times during the life of the component, most notably when we add a new hero.
|
||||
We'll have to re-inspect it periodically.
|
||||
|
||||
- Angular calls the `ngAfterViewChecked` [lifecycle hook method](../guide/lifecycle-hooks.html#afterview)
|
||||
when anything changes in the view.
|
||||
That's the right time to see if there's a new `heroForm` object.
|
||||
|
||||
- When there _is_ a new `heroForm` model, we subscribe to its `valueChanged` _Observable_ property.
|
||||
The `onValueChanged` handler looks for validation errors after every user keystroke.
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template2.component.ts' region='handler'}
|
||||
|
||||
The `onValueChanged` handler interprets user data entry.
|
||||
The `data` object passed into the handler contains the current element values.
|
||||
The handler ignores them. Instead, it iterates over the fields of the component's `formErrors` object.
|
||||
|
||||
The `formErrors` is a dictionary of the hero fields that have validation rules and their current error messages.
|
||||
Only two hero properties have validation rules, `name` and `power`.
|
||||
The messages are empty strings when the hero data are valid.
|
||||
|
||||
For each field, the handler
|
||||
- clears the prior error message if any
|
||||
- acquires the field's corresponding Angular form control
|
||||
- if such a control exists _and_ its been changed ("dirty") _and_ its invalid ...
|
||||
- the handler composes a consolidated error message for all of the control's errors.
|
||||
|
||||
We'll need some error messages of course, a set for each validated property, one message per validation rule:
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template2.component.ts' region='messages'}
|
||||
|
||||
Now every time the user makes a change, the `onValueChanged` handler checks for validation errors and produces messages accordingly.
|
||||
|
||||
### Is this an improvement?
|
||||
|
||||
Clearly the template got substantially smaller while the component code got substantially larger.
|
||||
It's not easy to see the benefit when there are just three fields and only two of them have validation rules.
|
||||
|
||||
Consider what happens as we increase the number of validated fields and rules.
|
||||
In general, HTML is harder to read and maintain than code.
|
||||
The initial template was already large and threatening to get rapidly worse as we add more validation message `<divs>`.
|
||||
|
||||
After moving the validation messaging to the component,
|
||||
the template grows more slowly and proportionally.
|
||||
Each field has approximately the same number of lines no matter its number of validation rules.
|
||||
The component also grows proportionally, at the rate of one line per validated field
|
||||
and one line per validation message.
|
||||
|
||||
Both trends are manageable.
|
||||
|
||||
Now that the messages are in code, we have more flexibility. We can compose messages more intelligently.
|
||||
We can refactor the messages out of the component, perhaps to a service class that retrieves them from the server.
|
||||
In short, there are more opportunities to improve message handling now that text and logic have moved from template to code.
|
||||
|
||||
### _FormModule_ and template-driven forms
|
||||
|
||||
Angular has two different forms modules — `FormsModule` and `ReactiveFormsModule` —
|
||||
that correspond with the two approaches to form development.
|
||||
Both modules come from the same `@angular/forms` library package.
|
||||
|
||||
We've been reviewing the "Template-driven" approach which requires the `FormsModule`
|
||||
Here's how we imported it in the `HeroFormTemplateModule`.
|
||||
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template.module.ts'}
|
||||
|
||||
|
||||
We haven't talked about the `SharedModule` or its `SubmittedComponent` which appears at the bottom of every
|
||||
form template in this cookbook.
|
||||
|
||||
They're not germane to the validation story. Look at the [live example](#live-example) if you're interested.
|
||||
|
||||
|
||||
|
||||
{@a reactive}
|
||||
## Reactive Forms
|
||||
|
||||
In the template-driven approach, you markup the template with form elements, validation attributes,
|
||||
and `ng...` directives from the Angular `FormsModule`.
|
||||
At runtime, Angular interprets the template and derives its _form control model_.
|
||||
|
||||
**Reactive Forms** takes a different approach.
|
||||
You create the form control model in code. You write the template with form elements
|
||||
and`form...` directives from the Angular `ReactiveFormsModule`.
|
||||
At runtime, Angular binds the template elements to your control model based on your instructions.
|
||||
|
||||
This approach requires a bit more effort. *You have to write the control model and manage it*.
|
||||
|
||||
In return, you can
|
||||
* add, change, and remove validation functions on the fly
|
||||
* manipulate the control model dynamically from within the component
|
||||
* [test](#testing) validation and control logic with isolated unit tests.
|
||||
|
||||
The third cookbook sample re-writes the hero form in _reactive forms_ style.
|
||||
|
||||
### Switch to the _ReactiveFormsModule_
|
||||
The reactive forms classes and directives come from the Angular `ReactiveFormsModule`, not the `FormsModule`.
|
||||
The application module for the "Reactive Forms" feature in this sample looks like this:
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/reactive/hero-form-reactive.module.ts'}
|
||||
|
||||
The "Reactive Forms" feature module and component are in the `src/app/reactive` folder.
|
||||
Let's focus on the `HeroFormReactiveComponent` there, starting with its template.
|
||||
|
||||
### Component template
|
||||
|
||||
We begin by changing the `<form>` tag so that it binds the Angular `formGroup` directive in the template
|
||||
to the `heroForm` property in the component class.
|
||||
The `heroForm` is the control model that the component class builds and maintains.
|
||||
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/reactive/hero-form-reactive.component.html' region='form-tag'}
|
||||
|
||||
Then we modify the template HTML elements to match the _reactive forms_ style.
|
||||
Here is the "name" portion of the template again, revised for reactive forms and compared with the template-driven version:
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="hero-form-reactive.component.html (name #3)">
|
||||
{@example 'cb-form-validation/ts/src/app/reactive/hero-form-reactive.component.html' region='name-with-error-msg'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="hero-form-template1.component.html (name #2)">
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template2.component.html' region='name-with-error-msg'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
Key changes:
|
||||
- the validation attributes are gone (except `required`) because we'll be validating in code.
|
||||
|
||||
- `required` remains, not for validation purposes (we'll cover that in the code),
|
||||
but rather for css styling and accessibility.
|
||||
|
||||
A future version of reactive forms will add the `required` HTML validation attribute to the DOM element
|
||||
(and perhaps the `aria-required` attribute) when the control has the `required` validator function.
|
||||
|
||||
Until then, apply the `required` attribute _and_ add the `Validator.required` function
|
||||
to the control model, as we'll do below.
|
||||
- the `formControlName` replaces the `name` attribute; it serves the same
|
||||
purpose of correlating the input box with the Angular form control.
|
||||
|
||||
- the two-way `[(ngModel)]` binding is gone.
|
||||
The reactive approach does not use data binding to move data into and out of the form controls.
|
||||
We do that in code.
|
||||
|
||||
The retreat from data binding is a principle of the reactive paradigm rather than a technical limitation.### Component class
|
||||
|
||||
The component class is now responsible for defining and managing the form control model.
|
||||
|
||||
Angular no longer derives the control model from the template so we can no longer query for it.
|
||||
We create the Angular form control model explicitly with the help of the `FormBuilder`.
|
||||
|
||||
Here's the section of code devoted to that process, paired with the template-driven code it replaces:
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="reactive/hero-form-reactive.component.ts (FormBuilder)">
|
||||
{@example 'cb-form-validation/ts/src/app/reactive/hero-form-reactive.component.ts' region='form-builder'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="template/hero-form-template2.component.ts (ViewChild)">
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template2.component.ts' region='view-child'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
- we inject the `FormBuilder` in a constructor.
|
||||
|
||||
- we call a `buildForm` method in the `ngOnInit` [lifecycle hook method](../guide/lifecycle-hooks.html#hooks-overview)
|
||||
because that's when we'll have the hero data. We'll call it again in the `addHero` method.
|
||||
A real app would retrieve the hero asynchronously from a data service, a task best performed in the `ngOnInit` hook.- the `buildForm` method uses the `FormBuilder` (`fb`) to declare the form control model.
|
||||
Then it attaches the same `onValueChanged` handler (there's a one line difference)
|
||||
to the form's `valueChanged` event and calls it immediately
|
||||
to set error messages for the new control model.
|
||||
#### _FormBuilder_ declaration
|
||||
The `FormBuilder` declaration object specifies the three controls of the sample's hero form.
|
||||
|
||||
Each control spec is a control name with an array value.
|
||||
The first array element is the current value of the corresponding hero field.
|
||||
The (optional) second value is a validator function or an array of validator functions.
|
||||
|
||||
Most of the validator functions are stock validators provided by Angular as static methods of the `Validators` class.
|
||||
Angular has stock validators that correspond to the standard HTML validation attributes.
|
||||
|
||||
The `forbiddenNames` validator on the `"name"` control is a custom validator,
|
||||
discussed in a separate [section below](#custom-validation).
|
||||
|
||||
Learn more about `FormBuilder` in a _forthcoming_ chapter on reactive forms.
|
||||
#### Committing hero value changes
|
||||
|
||||
In two-way data binding, the user's changes flow automatically from the controls back to the data model properties.
|
||||
Reactive forms do not use data binding to update data model properties.
|
||||
The developer decides _when and how_ to update the data model from control values.
|
||||
|
||||
This sample updates the model twice:
|
||||
1. when the user submits the form
|
||||
1. when the user chooses to add a new hero
|
||||
|
||||
The `onSubmit` method simply replaces the `hero` object with the combined values of the form:
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/reactive/hero-form-reactive.component.ts' region='on-submit'}
|
||||
|
||||
|
||||
This example is "lucky" in that the `heroForm.value` properties _just happen_ to
|
||||
correspond _exactly_ to the hero data object properties.The `addHero` method discards pending changes and creates a brand new `hero` model object.
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/reactive/hero-form-reactive.component.ts' region='add-hero'}
|
||||
|
||||
Then it calls `buildForm` again which replaces the previous `heroForm` control model with a new one.
|
||||
The `<form>` tag's `[formGroup]` binding refreshes the page with the new control model.
|
||||
|
||||
Here's the complete reactive component file, compared to the two template-driven component files.
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="reactive/hero-form-reactive.component.ts (#3)">
|
||||
{@example 'cb-form-validation/ts/src/app/reactive/hero-form-reactive.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="template/hero-form-template2.component.ts (#2)">
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template2.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="template/hero-form-template1.component.ts (#1)">
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template1.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
|
||||
Run the [live example](#live-example) to see how the reactive form behaves
|
||||
and to compare all of the files in this cookbook sample.
|
||||
|
||||
|
||||
|
||||
{@a custom-validation}
|
||||
## Custom validation
|
||||
This cookbook sample has a custom `forbiddenNamevalidator` function that's applied to both the
|
||||
template-driven and the reactive form controls. It's in the `src/app/shared` folder
|
||||
and declared in the `SharedModule`.
|
||||
|
||||
Here's the `forbiddenNamevalidator` function itself:
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/shared/forbidden-name.directive.ts' region='custom-validator'}
|
||||
|
||||
The function is actually a factory that takes a regular expression to detect a _specific_ forbidden name
|
||||
and returns a validator function.
|
||||
|
||||
In this sample, the forbidden name is "bob";
|
||||
the validator rejects any hero name containing "bob".
|
||||
Elsewhere it could reject "alice" or any name that the configuring regular expression matches.
|
||||
|
||||
The `forbiddenNamevalidator` factory returns the configured validator function.
|
||||
That function takes an Angular control object and returns _either_
|
||||
null if the control value is valid _or_ a validation error object.
|
||||
The validation error object typically has a property whose name is the validation key ('forbiddenName')
|
||||
and whose value is an arbitrary dictionary of values that we could insert into an error message (`{name}`).
|
||||
|
||||
Learn more about validator functions in a _forthcoming_ chapter on custom form validation.#### Custom validation directive
|
||||
In the reactive forms component we added a configured `forbiddenNamevalidator`
|
||||
to the bottom of the `'name'` control's validator function list.
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/reactive/hero-form-reactive.component.ts' region='name-validators'}
|
||||
|
||||
In the template-driven component template, we add the selector (`forbiddenName`) of a custom _attribute directive_ to the name's input box
|
||||
and configured it to reject "bob".
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/template/hero-form-template2.component.html' region='name-input'}
|
||||
|
||||
The corresponding `ForbiddenValidatorDirective` is a wrapper around the `forbiddenNamevalidator`.
|
||||
|
||||
Angular forms recognizes the directive's role in the validation process because the directive registers itself
|
||||
with the `NG_VALIDATORS` provider, a provider with an extensible collection of validation directives.
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/shared/forbidden-name.directive.ts' region='directive-providers'}
|
||||
|
||||
The rest of the directive is unremarkable and we present it here without further comment.
|
||||
|
||||
{@example 'cb-form-validation/ts/src/app/shared/forbidden-name.directive.ts' region='directive'}
|
||||
|
||||
|
||||
See the [Attribute Directives](../guide/attribute-directives.html) chapter.
|
||||
|
||||
|
||||
|
||||
{@a testing}
|
||||
## Testing Considerations
|
||||
|
||||
We can write _isolated unit tests_ of validation and control logic in _Reactive Forms_.
|
||||
|
||||
_Isolated unit tests_ probe the component class directly, independent of its
|
||||
interactions with its template, the DOM, other dependencies, or Angular itself.
|
||||
|
||||
Such tests have minimal setup, are quick to write, and easy to maintain.
|
||||
They do not require the `Angular TestBed` or asynchronous testing practices.
|
||||
|
||||
That's not possible with _Template-driven_ forms.
|
||||
The template-driven approach relies on Angular to produce the control model and
|
||||
to derive validation rules from the HTML validation attributes.
|
||||
You must use the `Angular TestBed` to create component test instances,
|
||||
write asynchronous tests, and interact with the DOM.
|
||||
|
||||
While not difficult, this takes more time, work and skill —
|
||||
factors that tend to diminish test code coverage and quality.
|
@ -1,620 +0,0 @@
|
||||
@title
|
||||
Internationalization (i18n)
|
||||
|
||||
@intro
|
||||
Translate the app's template text into multiple languages.
|
||||
|
||||
@description
|
||||
|
||||
|
||||
{@a top}
|
||||
Angular's _internationalization_ (_i18n_) tools help make your app available in multiple languages.
|
||||
|
||||
## Table of contents
|
||||
|
||||
* [Angular and i18n template translation](#angular-i18n)
|
||||
* [Mark text with the _i18n_ attribute](#i18n-attribute)
|
||||
* [Add _i18n-..._ translation attributes](#translate-attributes)
|
||||
* [Handle singular and plural](#cardinality)
|
||||
* [Select among alternative texts](#select)
|
||||
* [Create a translation source file with the **_ng-xi18n_ extraction tool**](#ng-xi18n)
|
||||
* [Translate text messages](#translate)
|
||||
* [Merge the completed translation file into the app](#merge)
|
||||
* [Merge with the JIT compiler](#jit)
|
||||
* [Internationalization with the AOT compiler](#aot)
|
||||
* [Translation file maintenance and _id_ changes](#maintenance)
|
||||
**Try this** <live-example name="cb-i18n" title="i18n Example in Spanish">live example</live-example>
|
||||
of a JIT-compiled app, translated into Spanish.
|
||||
|
||||
|
||||
|
||||
{@a angular-i18n}
|
||||
|
||||
## Angular and _i18n_ template translation
|
||||
|
||||
Application internationalization is a challenging, many-faceted effort that
|
||||
takes dedication and enduring commitment.
|
||||
Angular's _i18n_ internationalization facilities can help.
|
||||
|
||||
This page describes the _i18n_ tools available to assist translation of component template text
|
||||
into multiple languages.
|
||||
|
||||
|
||||
Practitioners of _internationalization_ refer to a translatable text as a "_message_".
|
||||
This page uses the words "_text_" and "_message_" interchangably and in the combination, "_text message_".
|
||||
The _i18n_ template translation process has four phases:
|
||||
|
||||
1. Mark static text messages in your component templates for translation.
|
||||
|
||||
1. An angular _i18n_ tool extracts the marked messages into an industry standard translation source file.
|
||||
|
||||
1. A translator edits that file, translating the extracted text messages into the target language,
|
||||
and returns the file to you.
|
||||
|
||||
1. The Angular compiler imports the completed translation files,
|
||||
replaces the original messages with translated text, and generates a new version of the application
|
||||
in the target language.
|
||||
|
||||
You need to build and deploy a separate version of the application for each supported language.
|
||||
|
||||
|
||||
{@a i18n-attribute}
|
||||
|
||||
## Mark text with the _i18n_ attribute
|
||||
|
||||
The Angular `i18n` attribute is a marker for translatable content.
|
||||
Place it on every element tag whose fixed text should be translated.
|
||||
|
||||
|
||||
~~~ {.alert.is-helpful}
|
||||
|
||||
`i18n` is not an Angular _directive_.
|
||||
It's a custom _attribute_, recognized by Angular tools and compilers.
|
||||
After translation, the compiler removes it.
|
||||
|
||||
|
||||
~~~
|
||||
|
||||
In the accompanying sample, an `<h1>` tag displays a simple English language greeting
|
||||
that you translate into Spanish:
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.1.html' region='greeting'}
|
||||
|
||||
Add the `i18n` attribute to the tag to mark it for translation.
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.1.html' region='i18n-attribute'}
|
||||
|
||||
### Help the translator with a _description_ and _intent_
|
||||
|
||||
In order to translate it accurately, the translator may
|
||||
need a description of the message.
|
||||
Assign a description to the i18n attribute:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.1.html' region='i18n-attribute-desc'}
|
||||
|
||||
In order to deliver a correct translation, the translator may need to
|
||||
know your _intent_—the true _meaning_ of the text
|
||||
within _this particular_ application context.
|
||||
In front of the description, add some contextual meaning to the assigned string,
|
||||
separating it from the description with the `|` character (`<meaning>|<description>`):
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.html' region='i18n-attribute-meaning'}
|
||||
|
||||
While all appearances of a message with the _same_ meaning have the _same_ translation,
|
||||
a message with *a variety of possible meanings* could have different translations.
|
||||
The Angular extraction tool preserves both the _meaning_ and the _description_ in the translation source file
|
||||
to facilitiate contextually-specific translations.
|
||||
|
||||
### Translate text without creating an element
|
||||
|
||||
Suppose there is a stretch of text that you'd like to translate.
|
||||
You could wrap it in a `<span>` tag but for some reason (CSS comes to mind)
|
||||
you don't want to create a new DOM element merely to facilitate translation.
|
||||
|
||||
Here are two techniques to try.
|
||||
|
||||
(1) Wrap the text in an `<ng-container>` element. The `<ng-container>` is never renderered:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.html' region='i18n-ng-container'}
|
||||
|
||||
(2) Wrap the text in a pair of HTML comments:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.html' region='i18n-with-comment'}
|
||||
|
||||
|
||||
|
||||
|
||||
{@a translate-attributes}
|
||||
## Add _i18n-..._ translation attributes
|
||||
You've added an image to your template. You care about accessibility too so you add a `title` attribute:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.1.html' region='i18n-title'}
|
||||
|
||||
The `title` attribute needs to be translated.
|
||||
Angular i18n support has more translation attributes in the form,`i18n-x`, where `x` is the
|
||||
name of the attribute to translate.
|
||||
|
||||
To translate the `title` on the `img` tag from the previous example, write:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.html' region='i18n-title-translate'}
|
||||
|
||||
You can also assign a meaning and a description with the `i18n-x="<meaning>|<description>"` syntax.
|
||||
|
||||
|
||||
|
||||
{@a cardinality}
|
||||
## Handle singular and plural
|
||||
|
||||
Different languages have different pluralization rules.
|
||||
|
||||
Suppose your application says something about a collection of wolves.
|
||||
In English, depending upon the number of wolves, you could display "no wolves", "one wolf", "two wolves", or "a wolf pack".
|
||||
Other languages might express the _cardinality_ differently.
|
||||
|
||||
Here's how you could mark up the component template to display the phrase appropriate to the number of wolves:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.html' region='i18n-plural'}
|
||||
|
||||
* The first parameter is the key. It is bound to the component property (`wolves`)
|
||||
that determines the number of wolves.
|
||||
* The second parameter identifies this as a `plural` translation type.
|
||||
* The third parameter defines a pluralization pattern consisting of pluralization
|
||||
categories and their matching values.
|
||||
|
||||
Pluralization categories include:
|
||||
* =0
|
||||
* =1
|
||||
* =5
|
||||
* few
|
||||
* other
|
||||
|
||||
Put the default _English_ translation in braces (`{}`) next to the pluralization category.
|
||||
* When you're talking about one wolf, you could write `=1 {one wolf}`.
|
||||
* For zero wolves, you could write `=0 {no wolves}`.
|
||||
* For two wolves, you could write `=2 {two wolves}`.
|
||||
|
||||
You could keep this up for three, four, and every other number of wolves.
|
||||
Or you could specify the **`other`** category as a catch-all for any unmatched cardinality
|
||||
and write something like: `other {a wolf pack}`.
|
||||
|
||||
This syntax conforms to the
|
||||
<a href="http://userguide.icu-project.org/formatparse/messages" target="_blank" title="ICU Message Format">ICU Message Format</a>
|
||||
that derives from the
|
||||
<a href="http://cldr.unicode.org/" target="_blank" title="CLDR">Common Locale Data Repository (CLDR),</a>
|
||||
which specifies the
|
||||
<a href="http://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules" target="_blank" title="Pluralization Rules">pluralization rules</a>.
|
||||
|
||||
|
||||
{@a select}
|
||||
## Select among alternative texts
|
||||
The application displays different text depending upon whether the hero is male or female.
|
||||
These text alternatives require translation too.
|
||||
|
||||
You can handle this with a `select` translation.
|
||||
A `select` also follows the
|
||||
<a href="http://userguide.icu-project.org/formatparse/messages" target="_blank" title="ICU Message Format">ICU message syntax</a>.
|
||||
You choose among alternative translation based on a string value instead of a number.
|
||||
|
||||
The following format message in the component template binds to the component's `gender`
|
||||
property, which outputs either an "m" or an "f".
|
||||
The message maps those values to the appropriate translation:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.html' region='i18n-select'}
|
||||
|
||||
|
||||
|
||||
{@a ng-xi18n}
|
||||
|
||||
## Create a translation source file with the _ng-xi18n_ tool
|
||||
|
||||
Use the **_ng-xi18n_ extraction tool** to extract the `i18n`-marked texts
|
||||
into a translation source file in an industry standard format.
|
||||
|
||||
This is an Angular CLI tool in the `@angular/compiler-cli` npm package.
|
||||
If you haven't already installed the CLI and its `platform-server` peer dependency, do so now:
|
||||
|
||||
<code-example language="sh" class="code-shell">
|
||||
npm install @angular/compiler-cli @angular/platform-server --save
|
||||
|
||||
</code-example>
|
||||
|
||||
Open a terminal window at the root of the application project and enter the `ng-xi18n` command:
|
||||
|
||||
<code-example language="sh" class="code-shell">
|
||||
./node_modules/.bin/ng-xi18n
|
||||
|
||||
</code-example>
|
||||
|
||||
|
||||
Windows users may have to quote the command like this: `"./node_modules/.bin/ng-xi18n"`
|
||||
By default, the tool generates a translation file named **`messages.xlf`** in the
|
||||
<a href="https://en.wikipedia.org/wiki/XLIFF" target="_blank">XML Localisation Interchange File Format (XLIFF, version 1.2)</a>.
|
||||
|
||||
|
||||
{@a other-formats}
|
||||
### Other translation formats
|
||||
|
||||
You can generate a file named **`messages.xmb`** in the
|
||||
<a href="http://cldr.unicode.org/development/development-process/design-proposals/xmb" target="_blank">XML Message Bundle (XMB)</a> format
|
||||
by adding the `--i18nFormat=xmb` flag.
|
||||
|
||||
<code-example language="sh" class="code-shell">
|
||||
./node_modules/.bin/ng-xi18n --i18nFormat=xmb
|
||||
|
||||
</code-example>
|
||||
|
||||
This sample sticks with the _XLIFF_ format.
|
||||
|
||||
|
||||
{@a ng-xi18n-options}
|
||||
### Other options
|
||||
You may have to specify additional options.
|
||||
For example, if the `tsconfig.json` TypeScript configuration
|
||||
file is located somewhere other than in the root folder,
|
||||
you must identify the path to it with the `-p` option:
|
||||
<code-example language="sh" class="code-shell">
|
||||
./node_modules/.bin/ng-xi18n -p path/to/tsconfig.json
|
||||
./node_modules/.bin/ng-xi18n --i18nFormat=xmb -p path/to/tsconfig.json
|
||||
|
||||
|
||||
</code-example>
|
||||
|
||||
|
||||
|
||||
{@a npm-i18n-script}
|
||||
### Add an _npm_ script for convenience
|
||||
|
||||
Consider adding a convenience shortcut to the `scripts` section of the `package.json`
|
||||
to make the command easier to remember and run:
|
||||
<code-example format='.' language='sh'>
|
||||
"scripts": {
|
||||
"i18n": "ng-xi18n",
|
||||
...
|
||||
}
|
||||
</code-example>
|
||||
|
||||
Now you can issue command variations such as these:
|
||||
<code-example language="sh" class="code-shell">
|
||||
npm run i18n
|
||||
npm run i18n -- -p path/to/tsconfig.json
|
||||
npm run i18n -- --i18nFormat=xmb -p path/to/tsconfig.json
|
||||
</code-example>
|
||||
|
||||
Note the `--` flag before the options.
|
||||
It tells _npm_ to pass every flag thereafter to `ng-xi18n`.
|
||||
|
||||
|
||||
{@a translate}
|
||||
|
||||
## Translate text messages
|
||||
|
||||
The `ng-xi18n` command generates a translation source file
|
||||
in the project root folder named `messages.xlf`.
|
||||
The next step is to translate the English language template
|
||||
text into the specific language translation
|
||||
files. The cookbook sample creates a Spanish translation file.
|
||||
|
||||
|
||||
{@a localization-folder}
|
||||
### Create a localization folder
|
||||
|
||||
You will probably translate into more than one other language so it's a good idea
|
||||
for the project structure to reflect your entire internationalization effort.
|
||||
|
||||
One approach is to dedicate a folder to localization and store related assets
|
||||
(for example, internationalization files) there.
|
||||
Localization and internationalization are
|
||||
<a href="https://en.wikipedia.org/wiki/Internationalization_and_localization" target="_blank">different but closely related terms</a>.This cookbook follows that suggestion. It has a `locale` folder under the `src/`.
|
||||
Assets within the folder carry a filename extension that matches a language-culture code from a
|
||||
<a href="https://msdn.microsoft.com/en-us/library/ee825488(v=cs.20).aspx" target="_blank">well-known codeset</a>.
|
||||
|
||||
Make a copy of the `messages.xlf` file, put it in the `locale` folder and
|
||||
rename it `messages.es.xlf`for the Spanish language translation.
|
||||
Do the same for each target language.
|
||||
|
||||
### Translate text nodes
|
||||
In the real world, you send the `messages.es.xlf` file to a Spanish translator who fills in the translations
|
||||
using one of the
|
||||
<a href="https://en.wikipedia.org/wiki/XLIFF#Editors" target="_blank">many XLIFF file editors</a>.
|
||||
|
||||
This sample file is easy to translate without a special editor or knowledge of Spanish.
|
||||
Open `messages.es.xlf` and find the first `<trans-unit>` section:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/locale/messages.es.xlf.html' region='translated-hello'}
|
||||
|
||||
This XML element represents the translation of the `<h1>` greeting tag you marked with the `i18n` attribute.
|
||||
|
||||
Using the _source_, _description_, and _meaning_ elements to guide your translation,
|
||||
replace the `<target/>` tag with the Spanish greeting:
|
||||
|
||||
{@example 'cb-i18n/ts/src/locale/messages.es.xlf.html' region='translated-hello'}
|
||||
|
||||
|
||||
|
||||
~~~ {.alert.is-important}
|
||||
|
||||
Note that the tool generates the `id`. **Don't touch it.**
|
||||
Its value depends on the content of the message and its assigned meaning.
|
||||
Change either factor and the `id` changes as well.
|
||||
See the **[translation file maintenance discussion](#maintenance)**.
|
||||
|
||||
|
||||
~~~
|
||||
|
||||
Translate the other text nodes the same way:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/locale/messages.es.xlf.html' region='translated-other-nodes'}
|
||||
|
||||
|
||||
|
||||
{@a translate-plural-select}
|
||||
## Translate _plural_ and _select_
|
||||
Translating _plural_ and _select_ messages is a little tricky.
|
||||
|
||||
The `<source>` tag is empty for `plural` and `select` translation
|
||||
units, which makes them hard to correlate with the original template.
|
||||
The `XLIFF` format doesn't yet support the ICU rules; it soon will.
|
||||
However, the `XMB` format does support the ICU rules.
|
||||
|
||||
You'll just have to look for them in relation to other translation units that you recognize from elsewhere in the source template.
|
||||
In this example, you know the translation unit for the `select` must be just below the translation unit for the logo.
|
||||
### Translate _plural_
|
||||
To translate a `plural`, translate its ICU format match values:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/locale/messages.es.xlf.html' region='translated-plural'}
|
||||
|
||||
### Translate _select_
|
||||
The `select` behaves a little differently. Here again is the ICU format message in the component template:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/app.component.html' region='i18n-select'}
|
||||
|
||||
The extraction tool broke that into _two_ translation units.
|
||||
|
||||
The first unit contains the text that was _outside_ the `select`.
|
||||
In place of the `select` is a placeholder, `<x id="ICU">`, that represents the `select` message.
|
||||
Translate the text and leave the placeholder where it is.
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/locale/messages.es.xlf.html' region='translate-select-1'}
|
||||
|
||||
The second translation unit, immediately below the first one, contains the `select` message. Translate that.
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/locale/messages.es.xlf.html' region='translate-select-2'}
|
||||
|
||||
Here they are together, after translation:
|
||||
|
||||
|
||||
{@example 'cb-i18n/ts/src/locale/messages.es.xlf.html' region='translated-select'}
|
||||
|
||||
|
||||
<div class='l-main-content'>
|
||||
|
||||
</div>
|
||||
|
||||
The entire template translation is complete. It's
|
||||
time to incorporate that translation into the application.
|
||||
|
||||
<div id='app-pre-translation'>
|
||||
|
||||
</div>
|
||||
|
||||
### The app before translation
|
||||
|
||||
When the previous steps finish, the sample app _and_ its translation file are as follows:
|
||||
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="src/app/app.component.html">
|
||||
{@example 'cb-i18n/ts/src/app/app.component.html'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="src/app/app.component.ts">
|
||||
{@example 'cb-i18n/ts/src/app/app.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="src/app/app.module.ts">
|
||||
{@example 'cb-i18n/ts/src/app/app.module.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="src/main.ts">
|
||||
{@example 'cb-i18n/ts/src/main.1.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="src/locale/messages.es.xlf">
|
||||
{@example 'cb-i18n/ts/src/locale/messages.es.xlf.html'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
|
||||
|
||||
{@a merge}
|
||||
|
||||
## Merge the completed translation file into the app
|
||||
|
||||
To merge the translated text into component templates,
|
||||
compile the application with the completed translation file.
|
||||
The process is the same whether the file is in `.xlf` format or
|
||||
in another format (`.xlif` and `.xtb`) that Angular understands.
|
||||
|
||||
You provide the Angular compiler with three new pieces of information:
|
||||
* the translation file
|
||||
* the translation file format
|
||||
* the <a href="https://en.wikipedia.org/wiki/XLIFF" target="_blank">_Locale ID_</a>
|
||||
(`es` or `en-US` for instance)
|
||||
|
||||
_How_ you provide this information depends upon whether you compile with
|
||||
the JIT (_Just-in-Time_) compiler or the AOT (_Ahead-of-Time_) compiler.
|
||||
|
||||
* With [JIT](#jit), you provide the information at bootstrap time.
|
||||
* With [AOT](#aot), you pass the information as `ngc` options.
|
||||
|
||||
|
||||
{@a jit}
|
||||
|
||||
### Merge with the JIT compiler
|
||||
|
||||
The JIT compiler compiles the application in the browser as the application loads.
|
||||
Translation with the JIT compiler is a dynamic process of:
|
||||
|
||||
1. Determining the language version for the current user.
|
||||
2. Importing the appropriate language translation file as a string constant.
|
||||
3. Creating corresponding translation providers to guide the JIT compiler.
|
||||
4. Bootstrapping the application with those providers.
|
||||
|
||||
Open `index.html` and revise the launch script as follows:
|
||||
|
||||
{@example 'cb-i18n/ts/src/index.html' region='i18n'}
|
||||
|
||||
In this sample, the user's language is hardcoded as a global `document.locale` variable
|
||||
in the `index.html`.
|
||||
|
||||
|
||||
{@a text-plugin}
|
||||
### SystemJS Text plugin
|
||||
|
||||
Notice the SystemJS mapping of `text` to a `systemjs-text-plugin.js`.
|
||||
With the help of a text plugin, SystemJS can read any file as raw text and
|
||||
return the contents as a string.
|
||||
You'll need it to import the language translation file.
|
||||
|
||||
SystemJS doesn't ship with a raw text plugin but it's easy to add.
|
||||
Create the following `systemjs-text-plugin.js` in the `src/` folder:
|
||||
|
||||
{@example 'cb-i18n/ts/src/systemjs-text-plugin.js'}
|
||||
|
||||
### Create translation providers
|
||||
|
||||
Three providers tell the JIT compiler how to translate the template texts for a particular language
|
||||
while compiling the application:
|
||||
|
||||
* `TRANSLATIONS` is a string containing the content of the translation file.
|
||||
* `TRANSLATIONS_FORMAT` is the format of the file: `xlf`, `xlif` or `xtb`.
|
||||
* `LOCALE_ID` is the locale of the target language.
|
||||
|
||||
The `getTranslationProviders` function in the following `src/app/i18n-providers.ts`
|
||||
creates those providers based on the user's _locale_
|
||||
and the corresponding translation file:
|
||||
|
||||
{@example 'cb-i18n/ts/src/app/i18n-providers.ts'}
|
||||
|
||||
1. It gets the locale from the global `document.locale` variable that was set in `index.html`.
|
||||
|
||||
1. If there is no locale or the language is U.S. English (`en-US`), there is no need to translate.
|
||||
The function returns an empty `noProviders` array as a `Promise`.
|
||||
It must return a `Promise` because this function could read a translation file asynchronously from the server.
|
||||
|
||||
1. It creates a transaction filename from the locale according to the name and location convention
|
||||
[described earlier](#localization-folder).
|
||||
|
||||
1. The `getTranslationsWithSystemJs` method reads the translation and returns the contents as a string.
|
||||
Notice that it appends `!text` to the filename, telling SystemJS to use the [text plugin](#text-plugin).
|
||||
|
||||
1. The callback composes a providers array with the three translation providers.
|
||||
|
||||
1. Finally, `getTranslationProviders` returns the entire effort as a promise.
|
||||
|
||||
### Bootstrap the app with translation providers
|
||||
|
||||
The Angular `bootstrapModule` method has a second, _options_ parameter
|
||||
that can influence the behavior of the compiler.
|
||||
|
||||
You'll create an _options_ object with the translation providers from `getTranslationProviders`
|
||||
and pass it to `bootstrapModule`.
|
||||
Open the `src/main.ts` and modify the bootstrap code as follows:
|
||||
|
||||
{@example 'cb-i18n/ts/src/main.ts'}
|
||||
|
||||
Notice that it waits for the `getTranslationProviders` promise to resolve before
|
||||
bootstrapping the app.
|
||||
|
||||
The app is now _internationalized_ for English and Spanish and there is a clear path for adding
|
||||
more languages.
|
||||
|
||||
|
||||
{@a aot}
|
||||
|
||||
### _Internationalize_ with the AOT compiler
|
||||
|
||||
The JIT compiler translates the application into the target language
|
||||
while compiling dynamically in the browser.
|
||||
That's flexible but may not be fast enough for your users.
|
||||
|
||||
The AOT (_Ahead-of-Time_) compiler is part of a build process that
|
||||
produces a small, fast, ready-to-run application package.
|
||||
When you internationalize with the AOT compiler, you pre-build
|
||||
a separate application package for each
|
||||
language. Then in the host web page (`index.html`),
|
||||
you determine which language the user needs
|
||||
and serve the appropriate application package.
|
||||
|
||||
This cookbook doesn't cover how to build multiple application packages and
|
||||
serve them according to the user's language preference.
|
||||
It does explain the few steps necessary to tell the AOT compiler to apply a translations file.
|
||||
|
||||
Internationalization with the AOT compiler requires
|
||||
some setup specifically for AOT compilation.
|
||||
Start with the application project as shown
|
||||
[just before merging the translation file](#app-pre-translation)
|
||||
and refer to the [AOT cookbook](aot-compiler.html) to make it _AOT-ready_.
|
||||
|
||||
Next, issue an `ngc` compile command for each supported language (including English).
|
||||
The result is a separate version of the application for each language.
|
||||
|
||||
Tell AOT how to translate by adding three options to the `ngc` command:
|
||||
* `--i18nFile`: the path to the translation file
|
||||
* `--locale`: the name of the locale
|
||||
* `--i18nFormat`: the format of the localization file
|
||||
|
||||
For this sample, the Spanish language command would be
|
||||
<code-example language="sh" class="code-shell">
|
||||
./node_modules/.bin/ngc --i18nFile=./locale/messages.es.xlf --locale=es --i18nFormat=xlf
|
||||
|
||||
</code-example>
|
||||
|
||||
|
||||
Windows users may have to quote the command:
|
||||
<code-example language="sh" class="code-shell">
|
||||
"./node_modules/.bin/ngc" --i18nFile=./locale/messages.es.xlf --locale=es --i18nFormat=xlf
|
||||
|
||||
</code-example>
|
||||
|
||||
|
||||
|
||||
{@a maintenance}
|
||||
## Translation file maintenance and _id_ changes
|
||||
|
||||
As the application evolves, you will change the _i18n_ markup
|
||||
and re-run the `ng-xi18n` extraction tool many times.
|
||||
The _new_ markup that you add is not a problem;
|
||||
but _most_ changes to _existing_ markup trigger
|
||||
generation of _new_ `id`s for the affected translation units.
|
||||
|
||||
After an `id` changes, the translation files are no longer in-sync.
|
||||
**All translated versions of the application will fail** during re-compilation.
|
||||
The error messages identify the old `id`s that are no longer valid but
|
||||
they don't tell you what the new `id`s should be.
|
||||
|
||||
**Commit all translation message files to source control**,
|
||||
especially the English source `messages.xlf`.
|
||||
The difference between the old and the new `messages.xlf` file
|
||||
help you find and update `id` changes across your translation files.
|
@ -1,28 +0,0 @@
|
||||
@title
|
||||
Cookbook
|
||||
|
||||
@intro
|
||||
A collection of recipes for common Angular application scenarios
|
||||
|
||||
@description
|
||||
The *Cookbook* offers answers to common implementation questions.
|
||||
|
||||
Each cookbook chapter is a collection of recipes focused on a particular Angular feature or application challenge
|
||||
such as data binding, cross-component interaction, and communicating with a remote server via HTTP.
|
||||
|
||||
The cookbook is just getting started. Many more recipes are on the way.
|
||||
Each cookbook chapter links to a live sample with every recipe included.
|
||||
|
||||
Recipes are deliberately brief and code-centric.
|
||||
Each recipe links to a chapter of the Developer Guide or the API Guide
|
||||
where you can learn more about the purpose, context, and design choices behind the code snippets.
|
||||
|
||||
## Feedback
|
||||
|
||||
The cookbook is a perpetual *work-in-progress*.
|
||||
We welcome feedback! Leave a comment by clicking the icon in upper right corner of the banner.
|
||||
|
||||
Post *documentation* issues and pull requests on the
|
||||
[angular.io](https://github.com/angular/angular.io) github repository.
|
||||
|
||||
Post issues with *Angular itself* to the [angular](https://github.com/angular/angular) github repository.
|
@ -1,103 +0,0 @@
|
||||
@title
|
||||
Set the Document Title
|
||||
|
||||
@intro
|
||||
Setting the document or window title using the Title service.
|
||||
|
||||
@description
|
||||
|
||||
|
||||
{@a top}
|
||||
Our app should be able to make the browser title bar say whatever we want it to say.
|
||||
This cookbook explains how to do it.**See the <live-example name="cb-set-document-title"></live-example>**.
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
|
||||
<td>
|
||||
To see the browser title bar change in the live example,
|
||||
open it again in the Plunker editor by clicking the icon in the upper right,
|
||||
then pop out the preview window by clicking the blue 'X' button in the upper right corner.
|
||||
</td>
|
||||
|
||||
|
||||
<td>
|
||||
<img src='assets/images/devguide/plunker-switch-to-editor-button.png' width="200px" height="70px" alt="pop out the window" align="right"> </img> <br> </br> <img src='assets/images/devguide/plunker-separate-window-button.png' width="200px" height="47px" alt="pop out the window" align="right"> </img>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
## The problem with *<title>*
|
||||
|
||||
The obvious approach is to bind a property of the component to the HTML `<title>` like this:
|
||||
<code-example format=''>
|
||||
<title>{{This_Does_Not_Work}}</title>
|
||||
</code-example>
|
||||
|
||||
Sorry but that won't work.
|
||||
The root component of our application is an element contained within the `<body>` tag.
|
||||
The HTML `<title>` is in the document `<head>`, outside the body, making it inaccessible to Angular data binding.
|
||||
|
||||
We could grab the browser `document` object and set the title manually.
|
||||
That's dirty and undermines our chances of running the app outside of a browser someday.
|
||||
Running your app outside a browser means that you can take advantage of server-side
|
||||
pre-rendering for near-instant first app render times and for SEO. It means you could run from
|
||||
inside a Web Worker to improve your app's responsiveness by using multiple threads. And it
|
||||
means that you could run your app inside Electron.js or Windows Universal to deliver it to the desktop.
|
||||
## Use the *Title* service
|
||||
Fortunately, Angular bridges the gap by providing a `Title` service as part of the *Browser platform*.
|
||||
The [Title](../api/platform-browser/index/Title-class.html) service is a simple class that provides an API
|
||||
for getting and setting the current HTML document title:
|
||||
|
||||
* `getTitle() : string` — Gets the title of the current HTML document.
|
||||
* `setTitle( newTitle : string )` — Sets the title of the current HTML document.
|
||||
|
||||
Let's inject the `Title` service into the root `AppComponent` and expose a bindable `setTitle` method that calls it:
|
||||
|
||||
|
||||
{@example 'cb-set-document-title/ts/src/app/app.component.ts' region='class'}
|
||||
|
||||
We bind that method to three anchor tags and, voilà!
|
||||
<figure class='image-display'>
|
||||
<img src="assets/images/cookbooks/set-document-title/set-title-anim.gif" alt="Set title"> </img>
|
||||
</figure>
|
||||
|
||||
Here's the complete solution
|
||||
|
||||
<md-tab-group>
|
||||
|
||||
<md-tab label="src/main.ts">
|
||||
{@example 'cb-set-document-title/ts/src/main.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="src/app/app.module.ts">
|
||||
{@example 'cb-set-document-title/ts/src/app/app.module.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
<md-tab label="src/app/app.component.ts">
|
||||
{@example 'cb-set-document-title/ts/src/app/app.component.ts'}
|
||||
</md-tab>
|
||||
|
||||
|
||||
</md-tab-group>
|
||||
|
||||
|
||||
## Why we provide the *Title* service in *bootstrap*
|
||||
|
||||
We generally recommended providing application-wide services in the root application component, `AppComponent`.
|
||||
|
||||
Here we recommend registering the title service during bootstrapping,
|
||||
a location we reserve for configuring the runtime Angular environment.
|
||||
|
||||
That's exactly what we're doing.
|
||||
The `Title` service is part of the Angular *browser platform*.
|
||||
If we bootstrap our application into a different platform,
|
||||
we'll have to provide a different `Title` service that understands the concept of a "document title" for that specific platform.
|
||||
Ideally the application itself neither knows nor cares about the runtime environment.[Back to top](#top)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user