z*|J&B46MPlUS$Lwe<1;T({9nBKg_8l3g7*am4wO0xl6%o7+T{)sJOwY`mkKXlfEzS
zC2*j}FN^k(OQUM^dK~I0GyiB|j%%Tpj@l&i~qQY7+)j+_^o$Nfq>%gqfs`cx3Zf~LydqrOA9{T8N-@nteQd0Z
z>Id&B-!CdL&7`Bx5T|2=E&mzXoc!zgBeohq1K<%+OoA%vV~_a>{MQI#uNcM2h14Cz
zWcW~jL>59`=?``@;Dd;A@*lzf1XEGuaWUzob4lXkxda3;6;I`eG)mk1lI#f>I%z7R
z%z?g|;vxtlCPgVw%=H3tj~_Z8T2#FFi(8%+2^Gkh5dG6xv#0^T=iCf~WW=7)(U1+d
z{}?C?N5#?N`I9YowwvtrHO%dI@>SkU@>5;9Iy%a${~=Be6D4mK5PUj5l>^!$p=2hvmPXstq+w-3r87vfe8MU?{x=|Bd+HLeuO
zI8npvdb|iG*QF|n8!bR_%1CadJ
zx05L-21X<%l?<5Cr;7(X++yVG6*vlV6#{{KScbV80>hSdHIhMaDyZFFU67G0+y;|B3
zexT5~n#J3fBuc-IfM9ka*wOe40F-X4zf!ZeQO9pkfD>1WBQztCGD(tPVIGR(vD6bP
zfFy<1HYMD05t*t`EE58h|m)%iBHo*lmgq~T9VRd(Te*@0XixCZ=bI+lgvjn^+P}zwxrCB+1u);?izF)a_BRNBC8<(PEaXT4LIOxjFc0IMCPMf}MU4URNbDw?
zXRM8&m{>Fiwe7W6vDu(Swzj{1+Tq~2VN!Rp@N2FI95|01WZ-<7AstN?c`~%@b{eCW(o<}DG5D~j@^TszK)Q=D@`
zWI457e2}Q0o%l56<~kCVks^>_@HY!v>3Q`f%{tYGm+FfE
z%mL3Gee6(ix(a`RhA}0Oa_&o6w^lp$`El*FL19D|66oE-b&=2
z*`zk2W$PvU^9-3Je-49{v4sVh`oh*2A>{TS+cun`>rKo{s0J$NTkOSU`FC)JVjLE{
zu89U+U>iwf^sivWDe;UaepSklv}fFD_+>WFwc0EF?n}DS*@_()3K|U_5N8P5>>`|8
zO$_81UQ48cO+fEc;@?Zj1o4a8ti1?QG4DqP=ka#afyd$QCfn`9j=k)Uh2_m9nsoAS
zcF_&gK+s&?yvL>86+)WF9X*i{`*H71Uh@W<^o1;rS&hnzhFvPt_@W6>AIf({53Q1^-yl>l5hC2BI}vx>w;q%ked6A*2%%F
z-$W;S@ubM0i?R2uDG;N@%JX&X>3d$2!@rA0aOscDRtorjP5fyB5db1fMLo2ZmnNtO
z$I2*e2Rgb$&?E9pcHcn=
zJfi)k17%Ys6cI=9r|BOswI7<)fb^Wc5Q?6VKO`v_94kIm`G_kYpZyLfZ4B?)=
zP#TXXr3q(Dy?-o%_#!UzB0o6!I830!8MV#ZRU<)`v(EqVv9A1%IZWRxb6?H1&gy^r
zd#+h|tefdbysyQ_f(fmriNNSG`n*4VEupqe2h9o%LnrQ<`zdp@>9z
z@KZdI3^0voUu6!cZc?gw<2~iXd??lEsIEy6z*a1R4z$FN`d=gc=Ku9RHM*ZT@@M0#
z)~%aKuD}DY=jc~DWnz-N6`dq9{jVQ|bd-=|0`N&-#JZ`%;Y%{8(l3;f5U5FUJ&@|o
zsbT^1b^hg@6dF9z=L3Xk=nRXN3@|46B@FkiLc(TiRK4t690q`v6y$*(mfRmC(x353|
zVp|d_*YzW;o0gW>*ZpWRONaIKILF7tgx9auWA7}vd!yq8+^2o4egZ*BeloRrIKn`{
zx8{1n;H$nmgXC8z|K^4^QZP4Tq6zSMdu2cbfH)IC!(GeEnp@uV13KcgN6d>W5@%Ca
zn_qL`goNv>>&7{zWTPYQjZIy(H&Gw5m+%OL;mAD>h&@-`#W|5}jg1e^PBs?@G)z1C
zU4~kp|HiIt{n~F&Pe@;d|8jo4j5Rh+6q_7K@J?mFTZ5teC*I>w3GCVZCjQg>kdzd}
zP#D`L(AxO(+XA2VY46{_Q2c|C1^?o`pHQaw-PdC~
zQNC>1zCXpMxtmaA?Ya$j@8SCwf4cw9LC&+{S_NH0Wy}vxJ
ziU*@u5DU{-+e@LU2UT<2
zhfaLfB-TWGhkP%$A)v2*K)kkaL=9pU44XjRFpegSlxY_IyI^Nj+hC&8zHz@il}7aw
zT#Sl5$e~I?XpP_O&2{t=mzv%$DrWYovYP$=H;AT*d~w$4rg8L+yHi8J+CG9J1v*jx
z#gSN`z>VdX=eMl|3rTF;V&tb7jG`HQQd7oVn+cU^RZ|t{6x=FTtDof~vZD4SWGe&7
zYj54lKap{93z)~dvV>MI`$EItsoUH_=3Ram8`Ls4uVv9yinnRk6&v{m&&9UU(9mQY
zA2ezFSzgep+LTvueeV{k<*92RYzcEL^*Ev0{bR_caSmyp9+Y*hf~O%se|o-m-dYPu
zku&2ihdr457A@W$IP>#PZO}I6PZMmp2U9khpW$!--6*&nAq{-a#FQrunk*hgO7&14
z;-7nLbEuJd!TLY+Cp6~sNVKp6><4x8Xft;!gtG!zmXX8Di)Zp?2P3C5G3lz}7l=`q
z54Hvf%ZtXVF(>8wMyhOdi6h)Tn12M;Ma*?lje`!%Ry$VX`jbu8RR3_V5u9~1#kBLY
zvtw?zxO11J&=#nyM52!OgUIe__ky>6}q)loGqqd2IrM^(M0trVR0v2@5#P$5jD4pWTgO62ngIr8&+C2
z|9P8knKVxIrz<5L#lKCR8?SEf0>C>yKX2GRc%7VVO=t#Fyqqj#!_Gjnr8~!z|7EIU
zGA+s+(b(8{^UHi_qYqnAos$a-okY?K7jJ6qflQe$yjt<}2<-^z)Ly~&AH)kaI#pC8
zS-28R4=kxIQdz&a?)hPVP!eHA;hn@~^#v(8>8W-mhOvm2gS>~qv+-RAiH0Zas
z$*iEN3Ngk&$!OMSSM_Ik^N1`jOBcTXDM4oI{On=>AlJa+eJGRLqD*F6Gcadmt)(
znfJcA*jeLtVR9D7RgH#xb2HTuD552F1I6GQ3>E&?z~~|HY3@{>p_3Xy)5gY?;Ublh
zX7p;*>eXYB%2N{P^cuHlnps?RYv^s|3U7^@D0F`+7jaUX=Cz{WI|9kDVKg87V_Kf!
z75IkxG;0J32%^Wt#4*)W6#pc`x7DWDBX_{nfQp7;V18!a26!O7F!B(NLz~0<3?b*#UHn-vY#LrT`6uQ
zqVQLn>Q0U=%*5ICxcoq;b|Eez*0#8#Tcj5+o|g4vMNgmfvjgfvJn4-popf)!af2+!
zTe5Mo>9Yl+W+Uk6<&^Y;wm*ibyD4pe=}QE_IkBc$*WogWIq3YG}_
z2-$Zb!V2{2Gk`8NVn_vj0+?38ixTuNhQuXVzkyCCMf15=hK(BS3u%O&zc0oy_u_0g
zXCz&yPX%M}th?;gAIn1Ak6+*@h@6sPoPh<0_-$B-J29?%jHGrY@FC7Br%#aE9w)Py
z899ffK7rF@t-=4m?b`x{=n1pMjTBZO*@7lhJywsTy6`P?@n8u{IfNZi;zZcaB=TZQK{F**Yx!
z3N|!bqx49GC4h5SkKchL+;wh9u`ClP!3O<31ucCRSDP(vM!@(tPni#`$lcA}k>*>0B|jQD2|uB9
zitjHbAPb{}g!VfLK1wPBZ`mjYHiY5h=|+^d$#zi+f`Vn<>*Wsz)XR0_3VHu7q^t7&
zirCc37qfk>O;wm1(8tWF5Mnwdo^xPoxk?BmeC;}cyS2;4#tk3YF~RbG_csT7OAk24
zJAl8A`+Dh6RpY_@Mtk(~1Uze?e39uU2uKXhkt9VnVp=X)m5w;jmCApCde-+=R#sfG
z{5iMa_R3IQZ}ouLbunz$-$Rg1Ow6Bvl0h5(65jw$azvnwTRP!=qK$rQcFa7y{$_~(P%
z%a9ixF4I$lS~9SC1B$7^4PO~!6I*PB^PSjuwCK;Ld0`fNXVB8N^l07_%;Js5Tb9=f
zxmS=rZNV@zqQ6gPt4<&P2jmFo2VRWO792IwWH{J2X|!|^Z@7KN`D#(`JMT&TuplvF
z;^Dm~SwxZ>3{4XSnvmoK2t>bfC%;*UV*@|Bi5FZv-R2VZ!mi#g6+kVHjYeW)pX@HB
z5^HCc5_e9SfVOY=@l@va`uPB$M~P!z=@B)Q&|=0x-DS8qS^fD-o*1wqNW4)Wk5enw
zC1mH$to%JgGKuZ%THGRWtLzX(m=mAk9P8+*_lF9jng2Ks>7|(|AkXIB`2$L6xi}>B
zGT+})?I?_D?LUInu_X$2ZkpEP8D|DRmK?vAmGkaORptSg{8gSaVM{P7kKgfyLRI0v-OPJqeO(MC)_1uB=h>{@ur^NRy1TJ}m!D9!uKHJ-bB
zYWanOh8=W=;cDI{Yg%`XWF#C0{AUN=U=M@~U=`|BwF@#}+O>ifXdx_ccgA%_LH5DR
zm4-NES73@>MZ^S&3Dl*&x+DPMKp)tL1loXpZ}l#tm$Vm<77G?8pgxCU;0~F)Y7Ob#
zc*aO}T#G}0E&g35utKLN=P3)Tm-j?cs6>g64fliwXk?!SZ6k99>kCaaF_ovSyoPSM
zdD#@RWG;=_RI>o$6?lP%MbQ3UAWeCZl^Yj89Yn9Fr`YL>5*?279n2ON^qd-pg+gz!
zk0!_%=0}fBs9&ABMT1imfs}!)#cYf#oyS}cvS_=(wLGHpNB;V0T&)NCD)B=Z#cmi
z8Qp!XP0Zn&2gU&(?I-9luq5b(s^gKzG|vz&&b;N$jM0?P6F2fA|SU!$$pNHG)*Xle`Vg#Lla0Q)Mx&R;Gc|LCh(!%
zKM&qBL`k&=cpwq6eZUEJPocKL1UN-TwO?oOg30V-_+Qp=`#|_}nT`yeqRnbF7=Ui^
z0oz@avVInk4bXY^gYHx3eRl@;*krs%Azui{o+>=&3eywdd;=VM)xREGfLEXiL--*g
zp?~7VBBv3H2kaW_au-H0Z>z(p?$?{OC-A*kzdDT{Nomr4G3zZYb#2FMW%!PB$vg*C
z_5tA00ndu9$%2(hjOXALF6z(G_Z8vPeAUfE1|ZH@fXoi~opXr;NC^PpBOKpSMaKq;
zfy|XRKjQk!MX-R5Q2}lvNGgl%qA>Xy@^-uGe?WNqBdai3MxS>*?Mr90J0akcm)r&3
zbBk()HD}^VkBauMPoj`DQT@{Ochkd`v_ezb?FwVT{MU9qZ}m1EKRg)>^x^ghtgTK}
zpP>M7A|uzfk4ul2n>S3Io5TzjS5<+LVO&}D1>t|m<2{qSTssTiLkEwJwU8Mmvy})z
z+<`IP^5ZLb`X?ORw`#ySxVKcUTB@!kBYamJ*VP*xd_751yjsC(Bn
zZLkiLagO|VwuHa
zNWRuwo}0{sPD2m>J8QYtwi}S<4d25}6DLH0`26oYtW#}~^Na^P%`E|0nZhYuSKZ%X`2V_uO5E^InOAnbpq`WHgJag
zI&98$6&=59m-czq$;k6Fs6hv^zl?~;@|jFzqTh;-Yx(dEWHCJ4{2$t7%TVfUhi5=q
zhh3vOzz(bLZekYL6ra8(@{4$Ol394Vj&}+3!X`C5PO)P{G?zF4O5b%R1nX1hldzj@
zkGPUH`f0=K%eVW;sqwIdU}@*d)>)wOcpusF`P?*Jq04!n6
z!XIYdoU~m7;~PH8KYx;fgOz;rYJF1?V{=tB!)Q9%i0$1z^iog_mrjv=UmK4zt7hW-
z-aY$V!yg`%`jFfO;QprgboOI-VR8~$^@_pQ%QBks=aN
z%iGi9rY4`UJRaldxxnLzbNTLSc9!w{&;Sm*R9C*Xw&qexB@s*}k&xn%FS6+Xl4uQR$1+=tDaHPibEj
zRY$P2JA319L4v!x26qi^8wsv~;6Z|J+=2%U?jBr%1P>70-QC^ga?V=!<=+48f2;Yr
zYu2oquA1puUsdRQ6BKd_WVhja1_{mWoJQ3u$-x7=5S}lov%E#i
zhb^0fALOI9LD=JUy9O8*yf}=F+A@R54%ku|
zELBPlkwNDHK*Q|d@YU!}6+r1SX^Y{W)OiIrHg;B;pQCpj#M#h#gQ{;SD@v6ciB#Wq
zO1N-%Br4FPpn>bx2=)3Hf^&CGjfhJRnRFkCtS+^~~(skm4urt@IS
z*Y`>GV`F_D7ww3Nx`3;t6_-pP??LwE@JT0b!wLW^>%g1UxuIwNv+B;cw8xV{B3Eb_
zoKNrmr_e*Rtz9{O(MJIb|4{L*?9FqOZM
zm8(eX<8z5^W#ydGEuTBjstMFe2y8E1h#KV@Mr*tfw{_ic2B;{0idwU=s7)mUR+qJ+
zA{-365pX0nuSyvoLimX{-u}#^hp|m^mC$}l9@d{L`F%gT=if_d+SO}S)X;F`4$s)sr$)DPRXVm5#@U!wu5+PAW%AonhqPhLPx-n{
zPCizsFbfiBj0$syDSqbDn@+N5lqcrCPvE}VOPXQqH}0G+Wvk+5bh*vF;_zKDF{-^|
zE}Jq8asAMgDfs<|ZjK4AGhIMc96<`!8)g1J+6+eSeVf^H!%q~LxE@sb>ZFyftUODH
z6|#4;|CbBEVq5L%Z{ARj6Q1y?c3~*CG&LOSQnTgb*s=f1-oIy!&tr`T^Hh4=ce_Uvg;h(jEVA9IN7rs3{p{6f*kQT^wmlJ+alU-V~6
zyuRqQb`&&`u(Xe@Wf$B6m#HhY2_FA;)(zh)5pT<)XmhWVa+!TA`4EVQ-OKAr)UR@O
zeUkVaN$v8w8K0MUDr(c<7ro(8R6)Y+wSGRgQMkygNZoZ&qrF}!(aNZ6cpX!9Y&xr<
z_bWM$TGvuYCF0Y=>&~Q);9uMcEV}&=`Je3p^v$v&OEjOETpk|=sSSl@>EO#i?qt@P2=9dW^
zNX!vBDOFHEeeW9}QiyQI(H8^!MVWw=UU8~bi$%JBb74PTO=iFNVq0|qA~eGZv``u}
zTk8oJRXKf}-fY!KZ+e8>cRV5ah{yriusY&2w6--rmr>5i5isICuBu)MU;hef42GVx
z&kFM+uw;w~jSgMipLub1Mqm
z=$9@y|F4|fSlhMpaeor71Qn^APX(*eFKR~m
zC*A19_AUh`R$s|ZW3tog-`2!uec8%Hu0!6<;_G5KK;Y~dIvn0$E55pgWdE!7ZaUcx1+sHrh
zs`@TU<*r&r5$#GfTOv2jaE*F=CQYl=S_7CKNTJLkLz@ghYTLqyRsB}&b9ck+23rN>
zrc7J9;e_0yY%NxU#9MrTPYd(P#0D(ZSh$i#b)o0LnM*|`h4q++i(|TQnhNUsWzK1n
zGP@9j;0*vdbL8KE5x-
z)Nr1Ah_qvwo!w4k0Al*Bw=s!$d^E;_Q@kT`>I&>P7CG))NE{Sv%8+fI-QYXW?gv9_
zY+NCDrg22A3`Xm2>#r)7k?bp6z|1hT4H=dvRp%|2F@&u*=V9rbywF-V0O
zBjZ!}Vtlrb?BeyzL1|h?1YUe3kY7gV>-FO!8OV5jpkpG9^k9-%bn%Z3iFj&S^kF29
zf%UAc@?j1&u(M>MPq>
zNFzoySI~$!T!Ep|B?1BAROFN)8`A}x^YXds86$ZprVE=`_hNu|f4mpAve@EdK;~sO
zGY>V#p+vL{ZNm7%HUmHv?(Yt4zyxb+%A+?*%T4Zqw!VkZ5gKUFuRs+Wf2syC09ic+
z^b5i5Iv8fb0b!on;)XV1Fx4Ct@~gCWFX9=Y2eJVC#PyK%o{|Z`_&&Fuv}=z~>}IBS
zh*^=8k7nE>(G^Z%PbYGkwTiLk_N(67Gld-1FOYipdn<8f;yP;KCP1tbt=ds1^fymN
z?Eq6XB#
zYJA^&A)F8|^w`%`)Gnl|;pD9u!FQ+&yOw|$7#KkRLJka-dA5vvT+dLcefsiRDlF&v
z+9eAX3sI6x+b+&FDHq_ErFIbo8Cy?v`3C+X7tY}aN?6mItXK69&C?!P39OVy@`fgC
zZ;{Nr#K9o)ZmoRee{C54%PQZTY~jUrX{Xc>DE+kkLRG6L+b3rRnr89Yd_Juqhx{dg
zh9{fhlT67mmc4|r?22vzUILIVr4EXc4IsNl2jY%{L;3fLn@3Va9m6;qpZV6)q|O*wu$
z_s_?6@y@tMtqa1fEv|77^8Yzs-a`P?zbcVZ&vU>Vmz(c?k(`r0B3f;zzB&AEjhkGe
zZ*sLbV$UXz2fV+*Zr|8U`}&JPmEZCh*EhD+W195SCu7826hN^zi1S|(@D-SU-ZU
z5#ikt$E~&8YVmcS@xNZX#scnMG!p{%-@au8(4lU*O8$9svUW8t-~hvocO~R^OYz?`
zK5}<%CTmg2fAx;muuxPlL|8c7b
ziBDAiKc&lf;&YiWudZ1NbU47nA%QA=B7_tlq!Y>vugxveTYQ)+2Q;AYAId1(;7t1Q
z(kxBFLdhb@QymN9h5IE9=8-lUsG>}2qMHlIS32-S4RwFak<^a3NiLqIxoto=>E
zEaLrr99b$L1ZI<0IDT$5%gh!JP*s61w9r!qDgM)95TA%Vf%#5^K(Q-3lKtNV4x!)&qy$E2}7A~9)Exd2{!r0kFUKu)(Vmvw86P!gMs&CCL@Q-2ThvyH6f
z0y1OdQv;j!cXndDgqdR>q8ppjcL&(Q_xMo7ZX#4#lnO#B-@gUf403AErd#|#&$N~5
zhF{3=&7LAOMTR_Y*-%Qv&C|Gnnf$OAAQq8mv)36x3g5WgHBzvI83nmX?jR`^zKXC@Jg|&K_Ri#cEMxAifcE
z**^xtgu>>5g@Bi=;3a&u2x9wBDZK}tCvScCl(G2O0VW(joCWURu*8Nx0q%~k5QdOW
z6@uh^n(P~NsA#r*X$>4&-#EG2E-}<-KpnC*Lzo+_G0PSz>-mBTQ}xjkVyeK+cv0xS
zNaO=#r(%^|7!nWM4$lj^0O~Je-T!`BT4KKX0hvFuvSV(SC%kcFCvwo86
zng}+EI(0JQDhPh)AE0^b9q}r$Wp|Ql*_yly!}6uz_2HD
zPh{)NeC%Q2@t0CccKo6GOU&AOb$;(fnfE-dVmW5)DToXp=s!p3iFIdnuY`$3pHcmx
zuh^8eDorFYS$^NpBwJW1$(ZKoO9)^I-1xQPMh6+g1N0M-n>y*+;+DrP3n7Z?aB@hY
z-AF?0fTIWD7Ju|EH<0VHVp6?B)0PwfW_L{5sUl|hZVoaN!5PpBK<{Xfq)wxT6K)Ok
zxJQOQPukxa^gm$YQlvBg8>gM`TkqR=#hbQ3M&Dykt{W0I_Rov*gKu@6%8w>cOhs^n
zf@86&4N(myM|NHafs`JeY4^Wam)<;@nXu$6f#>1ZJhant2_HG&)}Y>p@Id9ckU_a<
zj|$M5xBdYz#@8h7EkGA5{1G#u|BM3tdgcv?iS|*IWgI|RwDM!r2KP_^R?>AzVR@(3
z1Z&v@K9B^#{3OaU3=T@EbhB+>wL1Q(H!wNt8->*B0IU(s
z`t3cmA(23fJD-)1Tz`U~h|PLMxTNkfS`_Zy56|7FZU=y-(48x_%FVgRB`zS3diEiP
z?LD`0fNG^2^|;t<$*ky#*siCropaZ|DM;|2(?WwQu&{FfsNP^B0S^~e+;3M*_m?Z{
zov{WSj}}w|XwvD7lMr0_%?ox++_kRUZ52g>`ZA--x|6XSwvi&vMoqX}y@Im?gnyMz
z=MiMj4~&zi-)|cs$A+xuUOYUPofct1p|y``VFh#CfMNfqjhC)uSRh0exe65++S&@R
z3DXXM1(?f_V;$m{HLVsWXHhnl)L(To1T1l?_~4)_SQLJZYQI%M1a+mq-3tM$<3&9{
zh?-E$YDe0;42FGxToAyTnaFiP?V!9Zf+kpFLnV`K!{!ok(jjF+c&}?XdmXbTv>FGA{@$2pSH05wrLQ@`82BmnhG1+zPm
zEP4{R@=}}CBtW)nh9I@&NJZLT`B*OJ
zo}Q1{NIu?`O#IjCI~A@%*&2V#w>wnPprHG
zhDYgQwh|zSW`Bti5UR;*ZV-ZBQ&t(0@qq`@2;1081jj)s9Y687Z<(X`xOzyu(UvG=
zbUWI%u>i8*opdZ{zjiMQhzb~&CKQeB$QG2~tY&<7IZMuBoCDE*r{)1u7)v*12QKk?l4=04kEDXP1XI}Zcz4On0d@@<
z930xvObFgF$y$2GQ-VNc!J-p9LuTLUCA*p+%tbaMuzD8x?~R|RBk|VF1dg0Eg`f{3
zK|nw8&5pzE11@cPp;Ln(9eO|wlEf%~Da^)mJ6jvg0UXN`V1VI)g&C&?ZGtxj#~#v5
zcwWsP(t{X~wQyo^zjzU?^S%`~*f?eP=VPCM*v>pfy(nnRjq$uq%GrR}4njflp?R!x
zQzDFQZa(11`Upd!E6xH{><+eW5+Epc+7FhJivMlR${+~;7=`IKKmdfDJgU38Q1tM_
zOoFVvC}_UCm4qQdS8ORfbWvmqJ_F_(uIW**BudIKYtFbvv%D_oFDSrEf2aG2JOIXQ
zT9U6A`BrCLOwvb9FfbD+06e#9`Vd>J&&%rC&@!T*47p)#xM2A8)PbPn#AQ}=S`>nB
z0l)$w*{?X<2Ozik=)JYPe;zB)Q4m2+%gfYyC8Q0VFTWJ-x|L7ye;(d^=vlNpvf?xf
zU7hBM4w=US^o{q{Ie{wWRS&h#8N0#88~ONJ3FK
~21%!>
zKm(ZUW6lT~A3C`(OPpcyBxV_aQeUlYN_PSyHQGAVYRyi!WIsnK7MEn1Cq_0{kcR{e
zNTr5yr(-k6*ZRfRPG%S!E4wfPc8%ibiX!tcza=)}cQ)(?Yx{bx(89o8pM{+m6PP+SoV$j$UHAlxT
zvHTEkoq6MSJed9MIk=?ERku4;?w8>uVY)~E^f)EK3fP*jQArQ$+@yK=taIEv6_|?b
z8|cgtxNpA|3Xc}R6gR1%@V1JIF1@~auSVYh&xg+`l>
zq8pQoZrR(0k%|Ju0c{PMMiO1q3hkl#3T7(W!ZEUDq9VG%LW9A3Ob*uVYkuDX!Mgr<
zn|k$10}J=uP?x=&!#vqJ5>F-w^9j-O*shzp&3R8|JRzso;Kdf0z_51}nXI7VoTCqw
zBxxFbH&bCflB6ceU3dK5gsEcAN)3yQTM6Z%^8LK6sjQXgquD0O?U^RGNk^LZy7Q1i
zD5V9-ZCeHLWZR!=5+c)vw)MNjV`OE0t>Q6V#a^8nwb|tx(#6cRh6c?DX?^4?*YdG5
z2)wd+0|e}~o4U?4Vv9?8WXN!o(RkEh05d3L>PDA2XMfR`JQg9~ffxK`+yb(6oaEJt
zGg4F2W-id6M<_P9PFS5>-KVg*Ja@z7F!x6$bE4gC+>I?Y9G8{==jKV&?c|m&iW{_c
zhx)Op84g|&9!zvf`G+!14+*ijF09>Jsv;yMTV1upPO0A1Zn7n9?#n9oaKU2GCz}br
zpD8Wvt3F=IOOKCK4;wWD`D{17TPM7ao2*{VpdZIJOf?c(56`Wg+8OAZm)@ze0$im$
zIvM}$gS<-)^n0T}?f)~nmk
zYu2vn)~4Q5W{`Ol)0g<0bfQno{oAMG<6_=Q<_%AKhyo_bLUqa{dV!vRv-a|s%=Q?l
z=-k|Cj=fi{IMVk{Z~s9LGUnJUermUqtgQfIRd%?|ETwxG(>S}hlsSx8Mlw1$*!eIe
zozYZhK+v4v7LCDU9XU`(;ce?|vs9HXAgnU!>9c0~h%-}ZZE^^cVeq_E->U3C-#PKC
zrP7Z3sNWSoz2MNYkho}1mk@$FIeYz7H$F(mb&{X_nybfW!TP5;E|UACKisZ4YFI1b
znp;?&V+WD`sT}mbOM3qg+H1ul&-dG*z(`ecz}Yt2Di?3(MI&Rf@spR9`rT1k&8eSJ
zi{GEevuQtH_%&278pPM9d#|03>ybtfABv|(h}J8%h@alg@a$?t{mAQ!#0vnPivUew
z-`Pb0FL5s**eYq5yQ@RdsShCYGXSV3zfdnn9#dcWHLU;v;FcKUFv
z9<`+)93P0-3EKB)NxmvY{XqQF{41a5&Ii-!lDywbbnLDPaR*DWLv9*oya&7rJXWq<
z@7Nn6!WPF{!4aU0W!Ki{kEu(Zg>2D+*f_s+@`|FFK09j_M*kR_S2jLbP;|4n-Jk!8
z1To_!oTRb2?y#3IdF0iy)u;wHcthC`A5N327ic4|UNvqQOHNc#7x%nZLlHTR!
zVhNL~upvYVJCaJ(OrO{5iOurpgCNKod
zY486|L*=$d=;{9y#RFj;*9(*^;y#2^m<_|18OSt+-_%)99M;_?a4k3^AelfCW@FfA
z5!N9R2&TzM1$gP9X!AA8eW-mLlGEkW8Vpd@$#7u~_v}`iX`Z?c0V)tR^zDts0@z}h
zVqiG{i5!vq4T8n|F9b&XF9d@94-^Xoyv=$8!~qpfXzuXtzvmuREU~va9)&O|ecqT59aFeeiM);YtLb88wyA&5Qd(yrB@YK&C%i_3O)Oqwe!Knl=f}sIa{eh
zF!clHK%jn|J?gO!JzB*zLj0|Fyxx)40ZR4{j6srak{V)S-}vH3P80CmCYi1eg6gM?
zFfzQ3>-LNfpq_AeM{A2(PKid=#~?JxyX$k?dQzW`Km1VBjd_b^^ZVf2j>Fd;h55@|
zh#M>El4+O5+~VL)-Q=nMl@LaqYeCyJ+?X(%3R-WZhImEb<@5ClLk<$?XkMrd|7sT5
z*xK&({bDfPO{3p8>dOu<9l)n1od)`09H@LRGZ>SkA1J+kKGHvK4y(lxIXa$4F^1+H%r_h(bfYo^PSHn!>{B{fF3ZL#J%|
zfHbp)Zj`>3$s!qZ5DaO*${3)qKBf_NTq#@bSmx{zd|YKIR|q&j5kbZnqO14wyuKf9
z;TkQEzRF&=7ht2GS0P<)#j8OayzXk-<#S&-r^hsC9~{n_M#)nc1{A`_Jq8OVQEu|h
z|9x$5Dn(S_Om^A(2jV_#-FDg*IPBE3d2I5C-)K
zqGpebCf?P1FPZnAt*K
z#h}siu?5^4A_GRNCUB!XpB4gm_&48wmnpgdH?db9_YVcDK1$F)ORA4M_ak4vdjtWN
zTNxAf3UL=`^Ro{92-;tAc~mo@x#wuNUw-jHNA~v=7I0Ya*x2}=k#dR-Cng*hs>%^1
z1CKk2UPgL*oRdwY%N#itSly)>xdL7yYM1|9oEacgwaf`Z4%Az9gp8P=rq&&6p9pPZ
zP%aenM?dei7HiCQ!FOLJ=^-1d%%(7gMNZ~CugAD5_*$7jtuYLam~SCPwo5_cY6%GQ^Z#}Ix$HB
zV{#LJ?x2qWX*;I>t@-`h8imZ
zF}d=G(^^MLbZK|B(@6(|-wUkBs&1Ka*%pPIs$M(hh%wm4!sjsn+Un!VobjMCatSG_
zHySMBkUkX4h^UgOqj*H-_lNiZWIKrA4CW)@=U5c)&c-q^dsY>_x{LhxQlu+{giK-w
zKju&0n{KuDOdsCDK941sVIU`HH=gcYR%7+@p_%
zB;rk5Vj%IYb;L+&kN%--*6>|wZS9P7_eT5A3)$=7At(MIp}!;`w?07F^d;p|xb83D
z5+@)qfsY_CGM!<^tpF{Zemj!`zI!Rbuk<CUyy#e)lnUH}zrKQ9d5y5n7PrCbbt_LnUAr}Bbhi=ETF7NX2%GH=QsJ3knb
zw-APszCCb=8AuO#=d(I}*Ib}9$rNzXhdIY)^k{D4BhLK|Zbjs_vu~^z*e>>2J%H4~
zJe2DF!hZeSrxMW9V}kU~mrbnmsp+~5yT2Z;`19{Elk7iaOsoOAqzoO(5g$ykVcCv3
zwuxX?btK8fj8DvuR0X&o$I$B>^(DD5oA!ZIX!sW$Gw!=aN)_
z)1;tbg}iy!ux2rZC2F_+%%Y?z`}~MwebNKJ4$tGi><&e%oi$^>^>UW~KG9H^F$HC+
zzHkLgNi@9bwsphp-Of8-SBOEN*Zmmpe~$~7pwPud0|Uu;jp!?rZpl~QamXF2Vg#Co
zpDtn{;{WlFy;wc{)k>}jf%q(LjE0i26MN3p3+LOfD$srh6mGUN*tdJ{Wo@H}NSbhh
zO1lM4U0=jR6`&Pz+au{Nd>IKynCp5+iB_LD9{2Rr?9lyu6hJ43UqlVv-8X60f7If2
zk0zzWx00&Bod=6#MtoyoyTV6yJeyg;`e%Ib_i)CjvJ!|HaeezZ1aEGzwF^>;IbYq$
z45EJ0SBznC`wrla*iP?86TF2l9cX@-CVc$
zZr=j#;rQGaMvFz>^&$xgVEgMFP1#N6`2cH8hH1>bsgxk%rXd7={t09mt-z*mQ1m_v
z@KY{Q36}tqq0}Ij*&kWIDn^mnW3k}+d;3S}PQWB~PU*g)4MY;Q2u{(L&*b%%iroBk
z>hj1ek|AA}QW8JsxC=azaBBVabn)wid!cN4`hhW>V
zskU2M0#UAmc|LsDe>CJk#JuBzyAw1~cjQ9|@$Nf82!li@>I(kBd7LH52`=4ibc+Z%
zuIh0Znf@yJSU#dg#Mn^YQt$s~;KUgjle`KFeq*_XeZ2n+!iEVUOS}~1n8Wrn1WN^&
zS+OOP+*kf7lcb&f{E7ha2mFJvvLofu8zenw`!|ps;h}HHKKQ4QBsB~e78C&Zzg3xs
zg8wUmFv$@yVG+TwVo-9i|H#1GxcHk4yn#;`D6whc3~!4>kwr`>*;$JOWOI(6fgkM#
zM?X}f8jcM60Vk;33h!^SK?ovzp+rf>sHwSDJqgJE4+Pqth%xA4yMccnxNq#tN
z>YK*9v|?W-YBV6Y$FmcdogH6U#vRfnss18=dyJE~nuve+s2sWSy}=BosPq>DjAGfa>2J*vPNhgIk*0Og0J_9vlMxjvBUQ^-@Jh{u`+iX5#uGH3_v!nU|TU~uM
zbB$MsUe_qWvstT~AoRPAo}j+(Tj6Pnx*`ITahRbo9>lZvyIScv@y9G`-o5wm3;H<;
qJw)||x&-Fr1pm{X`2WYCaBHu1`o>ZF+!izc8I_k-ma3F64){NuZ}JuZ
diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md
index d197052d4c4..91b9868741a 100644
--- a/doc/user/project/members/index.md
+++ b/doc/user/project/members/index.md
@@ -329,9 +329,7 @@ To sort members:
GitLab users can request to become a member of a project.
1. On the left sidebar, select **Search or go to** and find the project you want to be a member of.
-1. By the project's name, select **Request Access**.
-
-
+1. In the top right, select the vertical ellipsis (**{ellipsis_v}**) and select **Request Access**.
An email is sent to the most recently active project Maintainers or Owners.
Up to ten project Maintainers or Owners are notified.
diff --git a/lib/gitlab/bitbucket_server_import/importers/pull_request_importer.rb b/lib/gitlab/bitbucket_server_import/importers/pull_request_importer.rb
index 6d743d69c27..3aa1d88347a 100644
--- a/lib/gitlab/bitbucket_server_import/importers/pull_request_importer.rb
+++ b/lib/gitlab/bitbucket_server_import/importers/pull_request_importer.rb
@@ -73,7 +73,7 @@ module Gitlab
return [] unless object[:reviewers].present?
object[:reviewers].filter_map do |reviewer|
- if Feature.enabled?(:bitbucket_server_user_mapping_by_username, type: :ops)
+ if Feature.enabled?(:bitbucket_server_user_mapping_by_username, project, type: :ops)
user_finder.find_user_id(by: :username, value: reviewer.dig('user', 'slug'))
else
user_finder.find_user_id(by: :email, value: reviewer.dig('user', 'emailAddress'))
diff --git a/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event.rb b/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event.rb
index b612394b485..632377229cb 100644
--- a/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event.rb
+++ b/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event.rb
@@ -15,8 +15,11 @@ module Gitlab
event_id: approved_event[:id]
)
- user_id = user_finder.find_user_id(by: :username, value: approved_event[:approver_username]) ||
- user_finder.find_user_id(by: :email, value: approved_event[:approver_email])
+ user_id = if Feature.enabled?(:bitbucket_server_user_mapping_by_username, project, type: :ops)
+ user_finder.find_user_id(by: :username, value: approved_event[:approver_username])
+ else
+ user_finder.find_user_id(by: :email, value: approved_event[:approver_email])
+ end
if user_id.nil?
log_info(
diff --git a/lib/gitlab/bitbucket_server_import/importers/pull_request_notes_importer.rb b/lib/gitlab/bitbucket_server_import/importers/pull_request_notes_importer.rb
index 725c5bd4ac0..283c405eff2 100644
--- a/lib/gitlab/bitbucket_server_import/importers/pull_request_notes_importer.rb
+++ b/lib/gitlab/bitbucket_server_import/importers/pull_request_notes_importer.rb
@@ -76,8 +76,11 @@ module Gitlab
event_id: approved_event.id
)
- user_id = user_finder.find_user_id(by: :username, value: approved_event.approver_username) ||
- user_finder.find_user_id(by: :email, value: approved_event.approver_email)
+ user_id = if Feature.enabled?(:bitbucket_server_user_mapping_by_username, project, type: :ops)
+ user_finder.find_user_id(by: :username, value: approved_event.approver_username)
+ else
+ user_finder.find_user_id(by: :email, value: approved_event.approver_email)
+ end
return unless user_id
diff --git a/lib/gitlab/bitbucket_server_import/user_finder.rb b/lib/gitlab/bitbucket_server_import/user_finder.rb
index 68bd2d4851a..fec0af16013 100644
--- a/lib/gitlab/bitbucket_server_import/user_finder.rb
+++ b/lib/gitlab/bitbucket_server_import/user_finder.rb
@@ -24,7 +24,7 @@ module Gitlab
def uid(object)
# We want this to only match either username or email depending on the flag state.
# There should be no fall-through.
- if Feature.enabled?(:bitbucket_server_user_mapping_by_username, type: :ops)
+ if Feature.enabled?(:bitbucket_server_user_mapping_by_username, project, type: :ops)
find_user_id(by: :username, value: object.is_a?(Hash) ? object[:author_username] : object.author_username)
else
find_user_id(by: :email, value: object.is_a?(Hash) ? object[:author_email] : object.author_email)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e090209aca1..6b82e387770 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -39307,6 +39307,9 @@ msgstr ""
msgid "ProductAnalytics|Back to dashboards"
msgstr ""
+msgid "ProductAnalytics|By providing feedback on AI-generated content, you acknowledge that GitLab may review the prompts you submitted alongside this feedback."
+msgstr ""
+
msgid "ProductAnalytics|Collector host"
msgstr ""
@@ -39361,6 +39364,9 @@ msgstr ""
msgid "ProductAnalytics|Events over time"
msgstr ""
+msgid "ProductAnalytics|Feedback acknowledgement"
+msgstr ""
+
msgid "ProductAnalytics|For more information, see the %{linkStart}docs%{linkEnd}."
msgstr ""
@@ -39385,12 +39391,18 @@ msgstr ""
msgid "ProductAnalytics|Help us improve Product Analytics Dashboards by sharing your experience."
msgstr ""
+msgid "ProductAnalytics|Helpful"
+msgstr ""
+
msgid "ProductAnalytics|How many sessions a user has"
msgstr ""
msgid "ProductAnalytics|How often users returned compared to all sessions"
msgstr ""
+msgid "ProductAnalytics|How was the result?"
+msgstr ""
+
msgid "ProductAnalytics|I agree to event collection and processing in this region."
msgstr ""
@@ -39505,6 +39517,9 @@ msgstr ""
msgid "ProductAnalytics|Tell us what you think!"
msgstr ""
+msgid "ProductAnalytics|Thank you for your feedback."
+msgstr ""
+
msgid "ProductAnalytics|The Product Analytics Beta on GitLab.com is offered only in the Google Cloud Platform zone %{zone}."
msgstr ""
@@ -39553,6 +39568,9 @@ msgstr ""
msgid "ProductAnalytics|Uncheck if you would like to configure a different provider for this project."
msgstr ""
+msgid "ProductAnalytics|Unhelpful"
+msgstr ""
+
msgid "ProductAnalytics|Unique Users"
msgstr ""
@@ -39595,6 +39613,9 @@ msgstr ""
msgid "ProductAnalytics|What metric do you want to visualize?"
msgstr ""
+msgid "ProductAnalytics|Wrong"
+msgstr ""
+
msgid "ProductAnalytics|You can instrument your application using a JS module or an HTML script. Follow the instructions below for the option you prefer."
msgstr ""
diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js
index bb2479b0c08..dd658ad96be 100644
--- a/spec/frontend/observability/client_spec.js
+++ b/spec/frontend/observability/client_spec.js
@@ -1267,4 +1267,165 @@ describe('buildClient', () => {
});
});
});
+
+ describe('fetchLogsSearchMetadata', () => {
+ const mockResponse = {
+ start_ts: 1713513680617331200,
+ end_ts: 1714723280617331200,
+ summary: {
+ service_names: ['adservice', 'cartservice', 'quoteservice', 'recommendationservice'],
+ trace_flags: [0, 1],
+ severity_names: ['info', 'warn'],
+ severity_numbers: [9, 13],
+ },
+ severity_numbers_counts: [
+ {
+ time: 1713519360000000000,
+ counts: {
+ 13: 0,
+ 9: 0,
+ },
+ },
+ {
+ time: 1713545280000000000,
+ counts: {
+ 13: 0,
+ 9: 0,
+ },
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ axiosMock.onGet(logsSearchMetadataUrl).reply(200, mockResponse);
+ });
+
+ it('fetches logs metadata from the logs URL', async () => {
+ const result = await client.fetchLogsSearchMetadata();
+
+ expect(axios.get).toHaveBeenCalledTimes(1);
+ expect(axios.get).toHaveBeenCalledWith(logsSearchMetadataUrl, {
+ withCredentials: true,
+ params: expect.any(URLSearchParams),
+ });
+ expect(result).toEqual(mockResponse);
+ });
+
+ describe('filters', () => {
+ describe('date range filter', () => {
+ it('handle predefined date range value', async () => {
+ await client.fetchLogsSearchMetadata({
+ filters: { dateRange: { value: '5m' } },
+ });
+ expect(getQueryParam()).toContain(`period=5m`);
+ });
+
+ it('handle custom date range value', async () => {
+ await client.fetchLogsSearchMetadata({
+ filters: {
+ dateRange: {
+ endDate: new Date('2020-07-06'),
+ startDate: new Date('2020-07-05'),
+ value: 'custom',
+ },
+ },
+ });
+ expect(getQueryParam()).toContain(
+ 'start_time=2020-07-05T00:00:00.000Z&end_time=2020-07-06T00:00:00.000Z',
+ );
+ });
+
+ it('handles exact timestamps', async () => {
+ await client.fetchLogsSearchMetadata({
+ filters: {
+ dateRange: {
+ timestamp: '2024-02-19T16:10:15.4433398Z',
+ endDate: new Date('2024-02-19'),
+ startDate: new Date('2024-02-19'),
+ value: 'custom',
+ },
+ },
+ });
+ expect(getQueryParam()).toContain(
+ 'start_time=2024-02-19T16:10:15.4433398Z&end_time=2024-02-19T16:10:15.4433398Z',
+ );
+ });
+ });
+
+ describe('attributes filters', () => {
+ it('converts filter to proper query params', async () => {
+ await client.fetchLogsSearchMetadata({
+ filters: {
+ attributes: {
+ service: [
+ { operator: '=', value: 'serviceName' },
+ { operator: '!=', value: 'serviceName2' },
+ ],
+ severityName: [
+ { operator: '=', value: 'info' },
+ { operator: '!=', value: 'warning' },
+ ],
+ severityNumber: [
+ { operator: '=', value: '9' },
+ { operator: '!=', value: '10' },
+ ],
+ traceId: [{ operator: '=', value: 'traceId' }],
+ spanId: [{ operator: '=', value: 'spanId' }],
+ fingerprint: [{ operator: '=', value: 'fingerprint' }],
+ traceFlags: [
+ { operator: '=', value: '1' },
+ { operator: '!=', value: '2' },
+ ],
+ attribute: [{ operator: '=', value: 'attr=bar' }],
+ resourceAttribute: [{ operator: '=', value: 'res=foo' }],
+ search: [{ value: 'some-search' }],
+ },
+ },
+ });
+ expect(getQueryParam()).toEqual(
+ `service_name=serviceName¬[service_name]=serviceName2` +
+ `&severity_name=info¬[severity_name]=warning` +
+ `&severity_number=9¬[severity_number]=10` +
+ `&trace_id=traceId` +
+ `&span_id=spanId` +
+ `&fingerprint=fingerprint` +
+ `&trace_flags=1¬[trace_flags]=2` +
+ `&log_attr_name=attr&log_attr_value=bar` +
+ `&res_attr_name=res&res_attr_value=foo` +
+ `&body=some-search`,
+ );
+ });
+
+ it('ignores unsupported operators', async () => {
+ await client.fetchLogsSearchMetadata({
+ filters: {
+ attributes: {
+ traceId: [{ operator: '!=', value: 'traceId2' }],
+ spanId: [{ operator: '!=', value: 'spanId2' }],
+ fingerprint: [{ operator: '!=', value: 'fingerprint2' }],
+ attribute: [{ operator: '!=', value: 'bar' }],
+ resourceAttribute: [{ operator: '!=', value: 'resourceAttribute2' }],
+ unsupported: [{ value: 'something', operator: '=' }],
+ },
+ },
+ });
+ expect(getQueryParam()).toEqual('');
+ });
+ });
+
+ it('ignores empty filter', async () => {
+ await client.fetchLogsSearchMetadata({
+ filters: { attributes: {}, dateRange: {} },
+ });
+ expect(getQueryParam()).toBe('');
+ });
+
+ it('ignores undefined filter', async () => {
+ await client.fetchLogsSearchMetadata({
+ filters: { dateRange: undefined, attributes: undefined },
+ });
+ expect(getQueryParam()).toBe('');
+ });
+ });
+ });
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
index f19ebae279e..221a992e9f5 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
@@ -4,7 +4,7 @@ import { GlEmptyState } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue';
import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue';
import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
@@ -92,7 +92,6 @@ describe('Tags List', () => {
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(imageTagsMock());
- jest.spyOn(Tracking, 'event');
});
describe('registry list', () => {
@@ -153,6 +152,16 @@ describe('Tags List', () => {
});
describe('delete event', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
describe('single item', () => {
beforeEach(() => {
findRegistryList().vm.$emit('delete', [tags[0]]);
@@ -167,7 +176,7 @@ describe('Tags List', () => {
});
it('tracks a single delete event', () => {
- expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
});
@@ -187,7 +196,7 @@ describe('Tags List', () => {
});
it('tracks multiple delete event', () => {
- expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'bulk_registry_tag_delete',
});
});
@@ -266,8 +275,10 @@ describe('Tags List', () => {
describe('delete event', () => {
let mutationResolver;
+ let trackingSpy;
beforeEach(async () => {
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock);
resolver = jest.fn().mockResolvedValue(imageTagsMock());
await mountComponent({ mutationResolver });
@@ -275,12 +286,16 @@ describe('Tags List', () => {
findTagsListRow().at(0).vm.$emit('delete');
});
+ afterEach(() => {
+ unmockTracking();
+ });
+
it('opens the modal', () => {
expect(DeleteModal.methods.show).toHaveBeenCalled();
});
it('tracks a single delete event', () => {
- expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
});
@@ -361,12 +376,22 @@ describe('Tags List', () => {
});
describe('cancel event', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
it('tracks cancel_delete', async () => {
await mountComponent();
findDeleteModal().vm.$emit('cancel');
- expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'cancel_delete', {
label: 'registry_tag_delete',
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
index 9dbdf57b587..00aa81bda66 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
@@ -24,7 +24,7 @@ import {
import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import {
graphQLImageDetailsMock,
@@ -100,10 +100,6 @@ describe('Details Page', () => {
});
};
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
- });
-
describe('when isLoading is true', () => {
it('shows the loader', () => {
mountComponent();
@@ -173,6 +169,16 @@ describe('Details Page', () => {
});
describe('cancel event', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
it('tracks cancel_delete', async () => {
mountComponent();
@@ -180,7 +186,7 @@ describe('Details Page', () => {
findDeleteModal().vm.$emit('cancel');
- expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'cancel_delete', {
label: 'registry_image_delete',
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
index c217227c398..b58e6679101 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
@@ -22,7 +22,7 @@ import {
import deleteContainerRepositoryMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql';
import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql';
import component from '~/packages_and_registries/container_registry/explorer/pages/list.vue';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import PersistedPagination from '~/packages_and_registries/shared/components/persisted_pagination.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import MetadataDatabaseAlert from '~/packages_and_registries/shared/components/container_registry_metadata_database_alert.vue';
@@ -671,22 +671,26 @@ describe('List Page', () => {
});
describe('tracking', () => {
+ let trackingSpy;
+
beforeEach(() => {
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
mountComponent();
fireFirstSortUpdate();
});
+ afterEach(() => {
+ unmockTracking();
+ });
+
const testTrackingCall = (action) => {
- expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, action, {
label: 'registry_repository_delete',
});
};
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
- });
-
- it('send an event when delete button is clicked', () => {
+ it('send an event when delete button is clicked', async () => {
+ await waitForPromises();
findImageList().vm.$emit('delete', {});
testTrackingCall('click_button');
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js
index 204134f1ee9..3cc658c6b99 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js
@@ -16,7 +16,7 @@ import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants';
import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants';
import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue';
import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { mavenPackage, mavenFiles, npmPackage } from '../../mock_data';
@@ -229,7 +229,11 @@ describe('PackagesApp', () => {
let eventSpy;
beforeEach(() => {
- eventSpy = jest.spyOn(Tracking, 'event');
+ eventSpy = mockTracking(undefined, undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
});
it(`delete button on delete modal call event with ${TRACKING_ACTIONS.DELETE_PACKAGE}`, () => {
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
index ec58a35f304..9aea186a655 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js
@@ -13,7 +13,7 @@ import { stubComponent } from 'helpers/stub_component';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { s__ } from '~/locale';
import { createAlert } from '~/alert';
import {
@@ -181,9 +181,16 @@ describe('Package Files', () => {
});
describe('link', () => {
- beforeEach(async () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
createComponent();
- await waitForPromises();
+ return waitForPromises();
+ });
+
+ afterEach(() => {
+ unmockTracking();
});
it('exists', () => {
@@ -195,11 +202,9 @@ describe('Package Files', () => {
});
it('tracks "download-file" event on click', () => {
- const eventSpy = jest.spyOn(Tracking, 'event');
-
findFirstRowDownloadLink().vm.$emit('click');
- expect(eventSpy).toHaveBeenCalledWith(
+ expect(trackingSpy).toHaveBeenCalledWith(
eventCategory,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
expect.any(Object),
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js
index d83d571872c..a3969f68c68 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js
@@ -17,7 +17,7 @@ import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import waitForPromises from 'helpers/wait_for_promises';
import getPackagePipelines from '~/packages_and_registries/package_registry/graphql/queries/get_package_pipelines.query.graphql';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import {
TRACKING_ACTION_CLICK_PIPELINE_LINK,
TRACKING_ACTION_CLICK_COMMIT_LINK,
@@ -194,8 +194,12 @@ describe('Package History', () => {
const category = 'UI::Packages';
beforeEach(() => {
+ eventSpy = mockTracking(undefined, undefined, jest.spyOn);
mountComponent();
- eventSpy = jest.spyOn(Tracking, 'event');
+ });
+
+ afterEach(() => {
+ unmockTracking();
});
it('clicking pipeline link tracks the right action', () => {
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
index 8e22e9a3b0c..760dba47496 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
@@ -11,7 +11,7 @@ import PackageVersionsList from '~/packages_and_registries/package_registry/comp
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import {
CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
@@ -248,12 +248,16 @@ describe('PackageVersionsList', () => {
const { findDeletePackagesModal } = uiElements;
beforeEach(async () => {
- eventSpy = jest.spyOn(Tracking, 'event');
+ eventSpy = mockTracking(undefined, undefined, jest.spyOn);
mountComponent({ props: { canDestroy: true } });
await waitForPromises();
finderFunction().vm.$emit('delete', deletePayload);
});
+ afterEach(() => {
+ unmockTracking();
+ });
+
it('passes itemsToBeDeleted to the modal', () => {
expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual([
packageVersions()[0],
@@ -308,11 +312,15 @@ describe('PackageVersionsList', () => {
const { findDeletePackagesModal, findRegistryList } = uiElements;
beforeEach(async () => {
- eventSpy = jest.spyOn(Tracking, 'event');
+ eventSpy = mockTracking(undefined, undefined, jest.spyOn);
mountComponent({ props: { canDestroy: true } });
await waitForPromises();
});
+ afterEach(() => {
+ unmockTracking();
+ });
+
it('binds the right props', () => {
expect(uiElements.findRegistryList().props()).toMatchObject({
items: packageVersions(),
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
index 8a774321a17..554c1cc3334 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
@@ -17,7 +17,7 @@ import {
CANCEL_DELETE_PACKAGES_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import PackagesList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { defaultPackageGroupSettings, packageData } from '../../mock_data';
describe('packages_list', () => {
@@ -158,21 +158,25 @@ describe('packages_list', () => {
${'when the user can destroy the package'} | ${findPackagesListRow} | ${firstPackage}
${'when the user can bulk destroy packages and deletes only one package'} | ${findRegistryList} | ${[firstPackage]}
`('$description', ({ finderFunction, deletePayload }) => {
- let eventSpy;
+ let trackingSpy;
const category = 'UI::NpmPackages';
beforeEach(() => {
- eventSpy = jest.spyOn(Tracking, 'event');
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
mountComponent({ stubs: { RegistryList } });
finderFunction().vm.$emit('delete', deletePayload);
});
+ afterEach(() => {
+ unmockTracking();
+ });
+
it('passes itemsToBeDeleted to the modal', () => {
expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual([firstPackage]);
});
it('requesting delete tracks the right action', () => {
- expect(eventSpy).toHaveBeenCalledWith(
+ expect(trackingSpy).toHaveBeenCalledWith(
category,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
expect.any(Object),
@@ -193,7 +197,7 @@ describe('packages_list', () => {
});
it('tracks the right action', () => {
- expect(eventSpy).toHaveBeenCalledWith(
+ expect(trackingSpy).toHaveBeenCalledWith(
category,
DELETE_PACKAGE_TRACKING_ACTION,
expect.any(Object),
@@ -210,7 +214,7 @@ describe('packages_list', () => {
it('canceling delete tracks the right action', () => {
findDeletePackagesModal().vm.$emit('cancel');
- expect(eventSpy).toHaveBeenCalledWith(
+ expect(trackingSpy).toHaveBeenCalledWith(
category,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
expect.any(Object),
@@ -219,22 +223,26 @@ describe('packages_list', () => {
});
describe('when the user can bulk destroy packages', () => {
- let eventSpy;
+ let trackingSpy;
const items = [firstPackage, secondPackage];
beforeEach(() => {
- eventSpy = jest.spyOn(Tracking, 'event');
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
mountComponent();
findRegistryList().vm.$emit('delete', items);
});
+ afterEach(() => {
+ unmockTracking();
+ });
+
it('passes itemsToBeDeleted to the modal', () => {
expect(findDeletePackagesModal().props('itemsToBeDeleted')).toStrictEqual(items);
expect(wrapper.emitted('delete')).toBeUndefined();
});
it('requesting delete tracks the right action', () => {
- expect(eventSpy).toHaveBeenCalledWith(
+ expect(trackingSpy).toHaveBeenCalledWith(
undefined,
REQUEST_DELETE_PACKAGES_TRACKING_ACTION,
expect.any(Object),
@@ -251,7 +259,7 @@ describe('packages_list', () => {
});
it('tracks the right action', () => {
- expect(eventSpy).toHaveBeenCalledWith(
+ expect(trackingSpy).toHaveBeenCalledWith(
undefined,
DELETE_PACKAGES_TRACKING_ACTION,
expect.any(Object),
@@ -268,7 +276,7 @@ describe('packages_list', () => {
it('canceling delete tracks the right action', () => {
findDeletePackagesModal().vm.$emit('cancel');
- expect(eventSpy).toHaveBeenCalledWith(
+ expect(trackingSpy).toHaveBeenCalledWith(
undefined,
CANCEL_DELETE_PACKAGES_TRACKING_ACTION,
expect.any(Object),
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js
index 5c64d4cb697..c26a69a2f35 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js
@@ -9,7 +9,7 @@ import component from '~/packages_and_registries/settings/project/components/con
import { UPDATE_SETTINGS_ERROR_MESSAGE } from '~/packages_and_registries/settings/project/constants';
import updateContainerExpirationPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql';
import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data';
describe('Container Expiration Policy Settings Form', () => {
@@ -120,10 +120,6 @@ describe('Container Expiration Policy Settings Form', () => {
});
};
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
- });
-
describe.each`
model | finder | fieldName | type | defaultValue
${'enabled'} | ${findEnableToggle} | ${'Enable'} | ${'toggle'} | ${false}
@@ -269,14 +265,26 @@ describe('Container Expiration Policy Settings Form', () => {
});
});
- it('tracks the submit event', async () => {
- mountComponentWithApollo({
- mutationResolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
+ describe('tracking', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
});
- await submitForm();
+ afterEach(() => {
+ unmockTracking();
+ });
- expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload);
+ it('tracks the submit event', async () => {
+ mountComponentWithApollo({
+ mutationResolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()),
+ });
+
+ await submitForm();
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload);
+ });
});
it('redirects to package and registry project settings page when submitted successfully', async () => {
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js
index 50b72d3ad72..84df6c67f9d 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js
@@ -13,7 +13,7 @@ import {
} from '~/packages_and_registries/settings/project/constants';
import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql';
import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { packagesCleanupPolicyPayload, packagesCleanupPolicyMutationPayload } from '../mock_data';
Vue.use(VueApollo);
@@ -110,10 +110,6 @@ describe('Packages Cleanup Policy Settings Form', () => {
});
};
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
- });
-
afterEach(() => {
fakeApollo = null;
});
@@ -274,18 +270,30 @@ describe('Packages Cleanup Policy Settings Form', () => {
});
});
- it('tracks the submit event', () => {
- mountComponentWithApollo({
- mutationResolver: jest.fn().mockResolvedValue(packagesCleanupPolicyMutationPayload()),
+ describe('tracking', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
});
- findForm().trigger('submit');
+ afterEach(() => {
+ unmockTracking();
+ });
- expect(Tracking.event).toHaveBeenCalledWith(
- undefined,
- 'submit_packages_cleanup_form',
- trackingPayload,
- );
+ it('tracks the submit event', () => {
+ mountComponentWithApollo({
+ mutationResolver: jest.fn().mockResolvedValue(packagesCleanupPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+
+ expect(trackingSpy).toHaveBeenCalledWith(
+ undefined,
+ 'submit_packages_cleanup_form',
+ trackingPayload,
+ );
+ });
});
it('show a success toast when submit succeed', async () => {
diff --git a/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js b/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js
index 9041cb757ab..774e2f4291a 100644
--- a/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js
@@ -13,7 +13,7 @@ import {
PUSH_COMMAND_LABEL,
COPY_PUSH_TITLE,
} from '~/packages_and_registries/container_registry/explorer/constants';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import { dockerCommands } from 'jest/packages_and_registries/container_registry/explorer/mock_data';
@@ -35,7 +35,6 @@ describe('cli_commands', () => {
};
beforeEach(() => {
- jest.spyOn(Tracking, 'event');
mountComponent();
});
@@ -43,13 +42,25 @@ describe('cli_commands', () => {
expect(findDropdownButton().text()).toContain(QUICK_START);
});
- it('clicking on the dropdown emit a tracking event', () => {
- findDropdownButton().vm.$emit('shown');
- expect(Tracking.event).toHaveBeenCalledWith(
- undefined,
- 'click_dropdown',
- expect.objectContaining({ label: 'quickstart_dropdown' }),
- );
+ describe('tracking', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('clicking on the dropdown emit a tracking event', () => {
+ findDropdownButton().vm.$emit('shown');
+ expect(trackingSpy).toHaveBeenCalledWith(
+ undefined,
+ 'click_dropdown',
+ expect.objectContaining({ label: 'quickstart_dropdown' }),
+ );
+ });
});
describe.each`
diff --git a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
index 299535775e0..bcc274cf7cd 100644
--- a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
+++ b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import Tracking from '~/tracking';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
@@ -59,7 +59,11 @@ describe('Package code instruction', () => {
const trackingLabel = 'foo_label';
beforeEach(() => {
- eventSpy = jest.spyOn(Tracking, 'event');
+ eventSpy = mockTracking(undefined, undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
});
it('should not track when no trackingAction is provided', () => {
diff --git a/spec/graphql/types/project_sort_enum_spec.rb b/spec/graphql/types/project_sort_enum_spec.rb
new file mode 100644
index 00000000000..baa6f1cb631
--- /dev/null
+++ b/spec/graphql/types/project_sort_enum_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ProjectSort'], feature_category: :groups_and_projects do
+ specify { expect(described_class.graphql_name).to eq('ProjectSort') }
+
+ it_behaves_like 'common sort values'
+
+ it 'exposes all the existing issue sort values' do
+ expect(described_class.values.keys).to include(
+ *%w[
+ ID_ASC ID_DESC LATEST_ACTIVITY_ASC LATEST_ACTIVITY_DESC
+ NAME_ASC NAME_DESC PATH_ASC PATH_DESC STARS_ASC STARS_DESC
+ ]
+ )
+ end
+end
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index 8773766c3a9..2d764f86791 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -38,6 +38,7 @@ RSpec.describe GitlabSchema.types['User'], feature_category: :user_profile do
groupCount
projectMemberships
starredProjects
+ contributedProjects
callouts
namespace
timelogs
diff --git a/spec/helpers/appearances_helper_spec.rb b/spec/helpers/appearances_helper_spec.rb
index aa8f52d1c57..6c57cbad98b 100644
--- a/spec/helpers/appearances_helper_spec.rb
+++ b/spec/helpers/appearances_helper_spec.rb
@@ -234,24 +234,14 @@ RSpec.describe AppearancesHelper do
describe '#custom_sign_in_description' do
it 'returns an empty string if no custom description is found' do
allow(helper).to receive(:current_appearance).and_return(nil)
- allow(Gitlab::CurrentSettings).to receive(:sign_in_text).and_return(nil)
- allow(Gitlab::CurrentSettings).to receive(:help_text).and_return(nil)
expect(helper.custom_sign_in_description).to eq('')
end
- it 'returns a custom description if all the setting options are found' do
- allow(helper).to receive(:markdown_field).and_return('1')
- allow(helper).to receive(:markdown).and_return('2', '3')
+ it 'returns a markdown of the custom description' do
+ allow(helper).to receive(:markdown_field).and_return('1
')
- expect(helper.custom_sign_in_description).to eq('1
2
3')
- end
-
- it 'returns a custom description if only one setting options is found' do
- allow(helper).to receive(:markdown_field).and_return('')
- allow(helper).to receive(:markdown).and_return('2', '')
-
- expect(helper.custom_sign_in_description).to eq('2')
+ expect(helper.custom_sign_in_description).to eq('1
')
end
end
diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event_spec.rb
index 036d40f254d..c7d68f01449 100644
--- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event_spec.rb
+++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes/approved_event_spec.rb
@@ -61,23 +61,44 @@ RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestNotes::Appro
end
context 'when a user with a matching username does not exist' do
- before do
- pull_request_author.update!(username: 'another_username')
+ let(:approved_event) { super().merge(approver_username: 'another_username') }
+
+ it 'does not set an approver' do
+ expect_log(
+ stage: 'import_approved_event',
+ message: 'skipped due to missing user',
+ iid: merge_request.iid,
+ event_id: 4
+ )
+
+ expect { importer.execute(approved_event) }
+ .to not_change { merge_request.approvals.count }
+ .and not_change { merge_request.notes.count }
+ .and not_change { merge_request.reviewers.count }
+
+ expect(merge_request.approvals).to be_empty
end
- it 'finds the user based on email' do
- importer.execute(approved_event)
+ context 'when bitbucket_server_user_mapping_by_username flag is disabled' do
+ before do
+ stub_feature_flags(bitbucket_server_user_mapping_by_username: false)
+ end
- approval = merge_request.approvals.first
+ it 'finds the user based on email' do
+ importer.execute(approved_event)
- expect(approval.user).to eq(pull_request_author)
+ approval = merge_request.approvals.first
+
+ expect(approval.user).to eq(pull_request_author)
+ end
end
context 'when no users match email or username' do
- let_it_be(:another_author) { create(:user) }
-
- before do
- pull_request_author.destroy!
+ let(:approved_event) do
+ super().merge(
+ approver_username: 'another_username',
+ approver_email: 'anotheremail@example.com'
+ )
end
it 'does not set an approver' do
diff --git a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes_importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes_importer_spec.rb
index 11c42e715eb..7f7d12eb8fc 100644
--- a/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes_importer_spec.rb
+++ b/spec/lib/gitlab/bitbucket_server_import/importers/pull_request_notes_importer_spec.rb
@@ -342,12 +342,27 @@ RSpec.describe Gitlab::BitbucketServerImport::Importers::PullRequestNotesImporte
pull_request_author.update!(username: 'another_username')
end
- it 'finds the user based on email' do
- importer.execute
+ it 'does not set an approver' do
+ expect { importer.execute }
+ .to not_change { merge_request.approvals.count }
+ .and not_change { merge_request.notes.count }
+ .and not_change { merge_request.reviewers.count }
- approval = merge_request.approvals.first
+ expect(merge_request.approvals).to be_empty
+ end
- expect(approval.user).to eq(pull_request_author)
+ context 'when bitbucket_server_user_mapping_by_username flag is disabled' do
+ before do
+ stub_feature_flags(bitbucket_server_user_mapping_by_username: false)
+ end
+
+ it 'finds the user based on email' do
+ importer.execute
+
+ approval = merge_request.approvals.first
+
+ expect(approval.user).to eq(pull_request_author)
+ end
end
context 'when no users match email or username' do
diff --git a/spec/lib/gitlab/bitbucket_server_import/user_finder_spec.rb b/spec/lib/gitlab/bitbucket_server_import/user_finder_spec.rb
index 16aff872c6d..78d258bd444 100644
--- a/spec/lib/gitlab/bitbucket_server_import/user_finder_spec.rb
+++ b/spec/lib/gitlab/bitbucket_server_import/user_finder_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::BitbucketServerImport::UserFinder, :clean_gitlab_redis_sh
let_it_be(:user) { create(:user) }
let(:created_id) { 1 }
- let(:project) { instance_double(Project, creator_id: created_id, id: 1) }
+ let(:project) { build_stubbed(:project, creator_id: created_id, id: 1) }
subject(:user_finder) { described_class.new(project) }
diff --git a/spec/requests/api/graphql/user/contributed_projects_query_spec.rb b/spec/requests/api/graphql/user/contributed_projects_query_spec.rb
new file mode 100644
index 00000000000..87dd1c1ded6
--- /dev/null
+++ b/spec/requests/api/graphql/user/contributed_projects_query_spec.rb
@@ -0,0 +1,407 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Getting contributedProjects of the user', feature_category: :groups_and_projects do
+ include GraphqlHelpers
+
+ let(:query) { graphql_query_for(:user, user_params, user_fields) }
+ let(:user_params) { { username: user.username } }
+ let(:user_fields) { 'contributedProjects { nodes { id } }' }
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:current_user) { create(:user) }
+
+ let_it_be(:public_project) { create(:project, :public) }
+ let_it_be(:private_project) { create(:project, :private) }
+ let_it_be(:internal_project) { create(:project, :internal) }
+
+ let(:path) { %i[user contributed_projects nodes] }
+
+ before_all do
+ private_project.add_developer(user)
+ private_project.add_developer(current_user)
+
+ travel_to(4.hours.from_now) { create(:push_event, project: private_project, author: user) }
+ travel_to(3.hours.from_now) { create(:push_event, project: internal_project, author: user) }
+ travel_to(2.hours.from_now) { create(:push_event, project: public_project, author: user) }
+ end
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+ end
+
+ describe 'sorting' do
+ let(:user_fields_with_sort) { "contributedProjects(sort: #{sort_parameter}) { nodes { id } }" }
+ let(:query_with_sort) { graphql_query_for(:user, user_params, user_fields_with_sort) }
+
+ context 'when sort parameter is not provided' do
+ it 'returns contributed projects in default order(LATEST_ACTIVITY_DESC)' do
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ private_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ public_project.to_global_id.to_s
+ ])
+ end
+ end
+
+ context 'when sort parameter for id is provided' do
+ context 'when ID_ASC is provided' do
+ let(:sort_parameter) { 'ID_ASC' }
+
+ it 'returns contributed projects in id ascending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ public_project.to_global_id.to_s,
+ private_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s
+ ])
+ end
+ end
+
+ context 'when ID_DESC is provided' do
+ let(:sort_parameter) { 'ID_DESC' }
+
+ it 'returns contributed projects in id descending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ internal_project.to_global_id.to_s,
+ private_project.to_global_id.to_s,
+ public_project.to_global_id.to_s
+ ])
+ end
+ end
+ end
+
+ context 'when sort parameter for name is provided' do
+ before_all do
+ public_project.update!(name: 'Project A')
+ internal_project.update!(name: 'Project B')
+ private_project.update!(name: 'Project C')
+ end
+
+ context 'when NAME_ASC is provided' do
+ let(:sort_parameter) { 'NAME_ASC' }
+
+ it 'returns contributed projects in name ascending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ public_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ private_project.to_global_id.to_s
+ ])
+ end
+ end
+
+ context 'when NAME_DESC is provided' do
+ let(:sort_parameter) { 'NAME_DESC' }
+
+ it 'returns contributed projects in name descending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ private_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ public_project.to_global_id.to_s
+ ])
+ end
+ end
+ end
+
+ context 'when sort parameter for path is provided' do
+ before_all do
+ public_project.update!(path: 'Project-1')
+ internal_project.update!(path: 'Project-2')
+ private_project.update!(path: 'Project-3')
+ end
+
+ context 'when PATH_ASC is provided' do
+ let(:sort_parameter) { 'PATH_ASC' }
+
+ it 'returns contributed projects in path ascending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ public_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ private_project.to_global_id.to_s
+ ])
+ end
+ end
+
+ context 'when PATH_DESC is provided' do
+ let(:sort_parameter) { 'PATH_DESC' }
+
+ it 'returns contributed projects in path descending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ private_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ public_project.to_global_id.to_s
+ ])
+ end
+ end
+ end
+
+ context 'when sort parameter for stars is provided' do
+ before_all do
+ public_project.update!(star_count: 10)
+ internal_project.update!(star_count: 20)
+ private_project.update!(star_count: 30)
+ end
+
+ context 'when STARS_ASC is provided' do
+ let(:sort_parameter) { 'STARS_ASC' }
+
+ it 'returns contributed projects in stars ascending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ public_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ private_project.to_global_id.to_s
+ ])
+ end
+ end
+
+ context 'when STARS_DESC is provided' do
+ let(:sort_parameter) { 'STARS_DESC' }
+
+ it 'returns contributed projects in stars descending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ private_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ public_project.to_global_id.to_s
+ ])
+ end
+ end
+ end
+
+ context 'when sort parameter for latest activity is provided' do
+ context 'when LATEST_ACTIVITY_ASC is provided' do
+ let(:sort_parameter) { 'LATEST_ACTIVITY_ASC' }
+
+ it 'returns contributed projects in latest activity ascending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ public_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ private_project.to_global_id.to_s
+ ])
+ end
+ end
+
+ context 'when LATEST_ACTIVITY_DESC is provided' do
+ let(:sort_parameter) { 'LATEST_ACTIVITY_DESC' }
+
+ it 'returns contributed projects in latest activity descending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ private_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ public_project.to_global_id.to_s
+ ])
+ end
+ end
+ end
+
+ context 'when sort parameter for created_at is provided' do
+ before_all do
+ public_project.update!(created_at: Time.current + 1.hour)
+ internal_project.update!(created_at: Time.current + 2.hours)
+ private_project.update!(created_at: Time.current + 3.hours)
+ end
+
+ context 'when CREATED_ASC is provided' do
+ let(:sort_parameter) { 'CREATED_ASC' }
+
+ it 'returns contributed projects in created_at ascending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ public_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ private_project.to_global_id.to_s
+ ])
+ end
+ end
+
+ context 'when CREATED_DESC is provided' do
+ let(:sort_parameter) { 'CREATED_DESC' }
+
+ it 'returns contributed projects in created_at descending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ private_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ public_project.to_global_id.to_s
+ ])
+ end
+ end
+ end
+
+ context 'when sort parameter for updated_at is provided' do
+ before_all do
+ public_project.update!(updated_at: Time.current + 1.hour)
+ internal_project.update!(updated_at: Time.current + 2.hours)
+ private_project.update!(updated_at: Time.current + 3.hours)
+ end
+
+ context 'when UPDATED_ASC is provided' do
+ let(:sort_parameter) { 'UPDATED_ASC' }
+
+ it 'returns contributed projects in updated_at ascending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ public_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ private_project.to_global_id.to_s
+ ])
+ end
+ end
+
+ context 'when UPDATED_DESC is provided' do
+ let(:sort_parameter) { 'UPDATED_DESC' }
+
+ it 'returns contributed projects in updated_at descending order' do
+ post_graphql(query_with_sort, current_user: current_user)
+
+ expect(graphql_data_at(*path).pluck('id')).to eq([
+ private_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ public_project.to_global_id.to_s
+ ])
+ end
+ end
+ end
+ end
+
+ describe 'accessible' do
+ context 'when user profile is public' do
+ context 'when a logged in user with membership in the private project' do
+ it 'returns contributed projects with visibility to the logged in user' do
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_data_at(*path)).to contain_exactly(
+ a_graphql_entity_for(private_project),
+ a_graphql_entity_for(internal_project),
+ a_graphql_entity_for(public_project)
+ )
+ end
+ end
+
+ context 'when a logged in user with no visibility to the private project' do
+ let_it_be(:current_user_2) { create(:user) }
+
+ it 'returns contributed projects with visibility to the logged in user' do
+ post_graphql(query, current_user: current_user_2)
+
+ expect(graphql_data_at(*path)).to contain_exactly(
+ a_graphql_entity_for(internal_project),
+ a_graphql_entity_for(public_project)
+ )
+ end
+ end
+
+ context 'when an anonymous user' do
+ it 'returns nothing' do
+ post_graphql(query, current_user: nil)
+
+ expect(graphql_data_at(*path)).to be_nil
+ end
+ end
+ end
+
+ context 'when user profile is private' do
+ let(:user_params) { { username: private_user.username } }
+ let_it_be(:private_user) { create(:user, :private_profile) }
+
+ before_all do
+ private_project.add_developer(private_user)
+ private_project.add_developer(current_user)
+
+ create(:push_event, project: private_project, author: private_user)
+ create(:push_event, project: internal_project, author: private_user)
+ create(:push_event, project: public_project, author: private_user)
+ end
+
+ context 'when a logged in user' do
+ it 'returns no project' do
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_data_at(*path)).to be_empty
+ end
+ end
+
+ context 'when an anonymous user' do
+ it 'returns nothing' do
+ post_graphql(query, current_user: nil)
+
+ expect(graphql_data_at(*path)).to be_nil
+ end
+ end
+
+ context 'when a logged in user is the user' do
+ it 'returns the user\'s all contributed projects' do
+ post_graphql(query, current_user: private_user)
+
+ expect(graphql_data_at(*path)).to contain_exactly(
+ a_graphql_entity_for(private_project),
+ a_graphql_entity_for(internal_project),
+ a_graphql_entity_for(public_project)
+ )
+ end
+ end
+ end
+ end
+
+ describe 'sorting and pagination' do
+ let(:data_path) { [:user, :contributed_projects] }
+
+ def pagination_query(params)
+ graphql_query_for(:user, user_params, "contributedProjects(#{params}) { #{page_info} nodes { id } }")
+ end
+
+ context 'when sorting in latest activity ascending order' do
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { :LATEST_ACTIVITY_ASC }
+ let(:first_param) { 1 }
+ let(:all_records) do
+ [
+ public_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ private_project.to_global_id.to_s
+ ]
+ end
+ end
+ end
+
+ context 'when sorting in latest activity descending order' do
+ it_behaves_like 'sorted paginated query' do
+ let(:sort_param) { :LATEST_ACTIVITY_DESC }
+ let(:first_param) { 1 }
+ let(:all_records) do
+ [
+ private_project.to_global_id.to_s,
+ internal_project.to_global_id.to_s,
+ public_project.to_global_id.to_s
+ ]
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb
index 7a3b3d6924c..6c0aebaf7b7 100644
--- a/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/types/merge_request_interactions_type_shared_examples.rb
@@ -33,6 +33,7 @@ RSpec.shared_examples "a user type with merge request interaction type" do
groupCount
projectMemberships
starredProjects
+ contributedProjects
callouts
merge_request_interaction
namespace