mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
Compare commits
608
Commits
v0.1.26
...
add-webstorm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3876ffe61 | ||
|
|
8f136a1f92 | ||
|
|
6bf8da8087 | ||
|
|
d39414064e | ||
|
|
ffcc35485d | ||
|
|
8ff94dc03e | ||
|
|
dace4f862f | ||
|
|
fc667d6312 | ||
|
|
aae4d9f8dd | ||
|
|
5eb8b300a3 | ||
|
|
6c9a832906 | ||
|
|
35430dab52 | ||
|
|
0eac4bc4b3 | ||
|
|
635de3d76a | ||
|
|
931d3ba81f | ||
|
|
6a4f439541 | ||
|
|
ac5e6df6c9 | ||
|
|
bf7d3c2775 | ||
|
|
5c93fbc955 | ||
|
|
5ac7b3f7c7 | ||
|
|
c742f17080 | ||
|
|
b4d6a5d6b8 | ||
|
|
43f01600ce | ||
|
|
e7cf9ee69d | ||
|
|
9c8b0c3aca | ||
|
|
8088c39fd6 | ||
|
|
1279f1d556 | ||
|
|
0defbc1dc3 | ||
|
|
76c6253ae0 | ||
|
|
5ec25687cd | ||
|
|
33a1557aed | ||
|
|
cbc2ce06e9 | ||
|
|
82466aaa9f | ||
|
|
d3e3a83a0d | ||
|
|
ee611d65b6 | ||
|
|
5ce4562eed | ||
|
|
21c7761403 | ||
|
|
682fc54778 | ||
|
|
e06b691d5d | ||
|
|
022eb33234 | ||
|
|
161b2c2378 | ||
|
|
52dfdb1913 | ||
|
|
bdaa6b65aa | ||
|
|
1900f43049 | ||
|
|
29b6f2a86f | ||
|
|
638c208609 | ||
|
|
7b1144dafe | ||
|
|
940bc6243b | ||
|
|
51b83768c7 | ||
|
|
cdbaa8d29c | ||
|
|
b2229a28b9 | ||
|
|
d888c8f126 | ||
|
|
102ef06c30 | ||
|
|
5cb424b2e6 | ||
|
|
fd9dfb0cc8 | ||
|
|
03380cfad0 | ||
|
|
6ce0e1e91f | ||
|
|
64c2515b94 | ||
|
|
e90241c445 | ||
|
|
06fbeb413b | ||
|
|
390a3402ab | ||
|
|
27ddc95862 | ||
|
|
c63240b18c | ||
|
|
a5aca2312b | ||
|
|
e01a0abdf2 | ||
|
|
3397e6c589 | ||
|
|
c4cccf5bd2 | ||
|
|
0130a637c8 | ||
|
|
8c2ea33da8 | ||
|
|
0d88ce2270 | ||
|
|
6907f6e71d | ||
|
|
84638ecd9c | ||
|
|
809e5fbbdc | ||
|
|
a264058a30 | ||
|
|
702e2e7db9 | ||
|
|
c32bc847bf | ||
|
|
7a2d976081 | ||
|
|
2d9ca6983a | ||
|
|
e44e495481 | ||
|
|
bedf792616 | ||
|
|
5db9942125 | ||
|
|
a889dbcc28 | ||
|
|
5c1e869bc5 | ||
|
|
a693ff560c | ||
|
|
89baf7ff38 | ||
|
|
c65a851205 | ||
|
|
85acdbb05e | ||
|
|
4e32f9b7bd | ||
|
|
744ca7a2bc | ||
|
|
3110bae209 | ||
|
|
16efdb2c95 | ||
|
|
1e9b7f1157 | ||
|
|
018ebd5f29 | ||
|
|
4706d9e3bd | ||
|
|
ba0c4a3fff | ||
|
|
2a8da5d4c1 | ||
|
|
43542ac858 | ||
|
|
fefe260f0a | ||
|
|
a17f7d2d20 | ||
|
|
c5a69a1ad9 | ||
|
|
155c88254b | ||
|
|
22c83118d0 | ||
|
|
7e99529bde | ||
|
|
a6f6c169c8 | ||
|
|
d69fcb861d | ||
|
|
c3313ff82a | ||
|
|
1182092b94 | ||
|
|
8d1fb45f73 | ||
|
|
8f7de021f4 | ||
|
|
65573499af | ||
|
|
9154f8fc4d | ||
|
|
3d3e327378 | ||
|
|
42cfed514c | ||
|
|
99114ddd11 | ||
|
|
e73c332e4f | ||
|
|
1594e41602 | ||
|
|
4896cfe970 | ||
|
|
dcffe46f90 | ||
|
|
7b4b42068c | ||
|
|
100502ae1e | ||
|
|
178b4cd618 | ||
|
|
9a6a8ce497 | ||
|
|
325ab500b3 | ||
|
|
60bbfdde25 | ||
|
|
71ce95de90 | ||
|
|
255c2665cc | ||
|
|
176942bbf1 | ||
|
|
1ae2f9280e | ||
|
|
49402b854f | ||
|
|
9476b3fc1d | ||
|
|
29037abd54 | ||
|
|
23ffec5072 | ||
|
|
1e64dd5509 | ||
|
|
cacbbf4053 | ||
|
|
4ca04fc661 | ||
|
|
f924d33076 | ||
|
|
763cfa9333 | ||
|
|
fd894dc3d7 | ||
|
|
9ea181a072 | ||
|
|
a96f2d7652 | ||
|
|
44da0c67b2 | ||
|
|
55c4e58aa3 | ||
|
|
a2b1498c3f | ||
|
|
df617a4c8f | ||
|
|
a64292f2b0 | ||
|
|
7ff5933b08 | ||
|
|
bb9ef76017 | ||
|
|
d6413404e0 | ||
|
|
8a585e60f2 | ||
|
|
1d795f6c32 | ||
|
|
9bd5f852e7 | ||
|
|
48516f0b9c | ||
|
|
0bf8e8b5b2 | ||
|
|
994ee488b9 | ||
|
|
a854096c35 | ||
|
|
5d89f9444a | ||
|
|
63905950cc | ||
|
|
ffd07ec17c | ||
|
|
2d63bc3893 | ||
|
|
55acb8a539 | ||
|
|
4c52f272fd | ||
|
|
a91f79053c | ||
|
|
7b4db04a81 | ||
|
|
ac9c2c5642 | ||
|
|
99200eabba | ||
|
|
5f2bb87a17 | ||
|
|
897c18dd5f | ||
|
|
51a865cd24 | ||
|
|
9dc3d116b4 | ||
|
|
a4326ec5c0 | ||
|
|
cc09b61b19 | ||
|
|
26d69e2006 | ||
|
|
4b7c623592 | ||
|
|
b91884bc54 | ||
|
|
963c79265a | ||
|
|
ade1e338ea | ||
|
|
c13972c835 | ||
|
|
d8d04c545e | ||
|
|
613450bac8 | ||
|
|
cafff08a30 | ||
|
|
cb60f2a596 | ||
|
|
58d72bea87 | ||
|
|
953f7898e7 | ||
|
|
fd06e109be | ||
|
|
ba1bb1646e | ||
|
|
3ee11efcd0 | ||
|
|
1930a8a2f2 | ||
|
|
f7fd41a5f8 | ||
|
|
3af5b0f031 | ||
|
|
cce8dee21c | ||
|
|
2d02db6ae0 | ||
|
|
66732a2f48 | ||
|
|
30ebd4b777 | ||
|
|
e95554d782 | ||
|
|
5cf8b7549d | ||
|
|
9c4dee5364 | ||
|
|
c635eabff3 | ||
|
|
51411182fe | ||
|
|
f53c770a71 | ||
|
|
8d55764313 | ||
|
|
4b12ebd5c0 | ||
|
|
27c8cfbd4b | ||
|
|
07b077f1a2 | ||
|
|
ee50d3b8d0 | ||
|
|
4b93f990d2 | ||
|
|
87f297e755 | ||
|
|
b50b065bcc | ||
|
|
4e8e2589bf | ||
|
|
8eddb2ee18 | ||
|
|
0ba2f6b194 | ||
|
|
5b22aedaff | ||
|
|
e3b6e2dcfa | ||
|
|
0d9bda12e6 | ||
|
|
a94bc0720c | ||
|
|
7504d20b67 | ||
|
|
db39417be9 | ||
|
|
e0e4f228eb | ||
|
|
f6630e16ff | ||
|
|
82a235dada | ||
|
|
3784357b7e | ||
|
|
8d0a9f9224 | ||
|
|
e758734807 | ||
|
|
352a4e793a | ||
|
|
8047394154 | ||
|
|
e5fd61f131 | ||
|
|
487af3c822 | ||
|
|
a7439c8e56 | ||
|
|
930660074d | ||
|
|
fd030e8673 | ||
|
|
bb73147efd | ||
|
|
04b21f089c | ||
|
|
fb42c8eea2 | ||
|
|
971f74e2ab | ||
|
|
4318aa2bb1 | ||
|
|
a1761363fa | ||
|
|
096417d5ac | ||
|
|
35446e01a2 | ||
|
|
6c3559e90f | ||
|
|
409ab466ca | ||
|
|
efb8c8f229 | ||
|
|
b1d663557b | ||
|
|
a460c5e9b9 | ||
|
|
831e3289e7 | ||
|
|
094864d779 | ||
|
|
147482d6ed | ||
|
|
921ddf47a8 | ||
|
|
f287c7f972 | ||
|
|
9eb1f93296 | ||
|
|
95d56ddf15 | ||
|
|
62b1e94257 | ||
|
|
01bab92fed | ||
|
|
dae677edeb | ||
|
|
03a5323755 | ||
|
|
2e9f935dbc | ||
|
|
93845db70a | ||
|
|
d5085294df | ||
|
|
a3aed9b8b6 | ||
|
|
7ca428677c | ||
|
|
9ad3e7661b | ||
|
|
05a4e8f5a3 | ||
|
|
1b0fe4aa47 | ||
|
|
29876acae5 | ||
|
|
657b3e1ccd | ||
|
|
a2ab0af3f1 | ||
|
|
bb9e181c20 | ||
|
|
7bc1f04e27 | ||
|
|
394b9cac13 | ||
|
|
be41911083 | ||
|
|
8f11902cb7 | ||
|
|
e2b2b7c701 | ||
|
|
af35cccb5f | ||
|
|
6342057a6f | ||
|
|
89923b5f76 | ||
|
|
54b5a22688 | ||
|
|
ab3443fd52 | ||
|
|
95ad879f82 | ||
|
|
80e153f861 | ||
|
|
dfa376f5ca | ||
|
|
76c357c88c | ||
|
|
5adde123e3 | ||
|
|
57291b1fd9 | ||
|
|
89f285ffd0 | ||
|
|
e6c06e2263 | ||
|
|
ca443b3ecf | ||
|
|
d8e9298222 | ||
|
|
c667aca3e0 | ||
|
|
7892b3cbe1 | ||
|
|
f2000ff789 | ||
|
|
759815cfac | ||
|
|
68df304867 | ||
|
|
a2aac3797f | ||
|
|
f13178a9ad | ||
|
|
c1d71dfedc | ||
|
|
2e7bc49c4c | ||
|
|
55dd3a9b1a | ||
|
|
5b3aea8bfe | ||
|
|
e47eb64de0 | ||
|
|
eada5eec53 | ||
|
|
1ee7cace36 | ||
|
|
612731a5b5 | ||
|
|
f50252c3b1 | ||
|
|
3cd1552ddc | ||
|
|
caab3f9686 | ||
|
|
bf8ba4da8f | ||
|
|
3fcc1ecedb | ||
|
|
46d7512cc4 | ||
|
|
00e172798b | ||
|
|
bb560809c7 | ||
|
|
765a11c7ab | ||
|
|
08e37540eb | ||
|
|
1768e2787f | ||
|
|
6d467d4659 | ||
|
|
701480c11c | ||
|
|
9ce6b45cf9 | ||
|
|
7eebee2de0 | ||
|
|
ff2fc72581 | ||
|
|
cdd7071885 | ||
|
|
30cba9550e | ||
|
|
1318372e81 | ||
|
|
185990f46b | ||
|
|
508cd524a8 | ||
|
|
08f25f065a | ||
|
|
36327cad00 | ||
|
|
939b5c22db | ||
|
|
5567652c6c | ||
|
|
a65f4c1465 | ||
|
|
253319ee50 | ||
|
|
4c1b11f733 | ||
|
|
6b67e6569b | ||
|
|
f5a28a434d | ||
|
|
638c633a21 | ||
|
|
935e55eb27 | ||
|
|
c4782ec71c | ||
|
|
81ee887d03 | ||
|
|
c2c5bc815e | ||
|
|
bd635cf07e | ||
|
|
ad565c2555 | ||
|
|
ac991e064c | ||
|
|
fadeca82eb | ||
|
|
891dbb0f6e | ||
|
|
140e0ef965 | ||
|
|
8c49f74fd9 | ||
|
|
535774a235 | ||
|
|
fa45ab593b | ||
|
|
a54687d3ef | ||
|
|
6cc81e3ab4 | ||
|
|
7699d2fcbd | ||
|
|
77cdfbcb06 | ||
|
|
cbab9332a9 | ||
|
|
fa0346921a | ||
|
|
c6aff35db2 | ||
|
|
af8509d012 | ||
|
|
0e6aba2886 | ||
|
|
e969f42c68 | ||
|
|
876c08ebc2 | ||
|
|
8ba5df358a | ||
|
|
771acd11a1 | ||
|
|
d4735edd45 | ||
|
|
e36784e510 | ||
|
|
a237285808 | ||
|
|
ee6d8072ed | ||
|
|
c8d259b1df | ||
|
|
2da56634a1 | ||
|
|
27be8c3b1e | ||
|
|
988c432ded | ||
|
|
dce4e668f1 | ||
|
|
2c69008a4a | ||
|
|
19452c2742 | ||
|
|
956828fa55 | ||
|
|
b873523eab | ||
|
|
b862f7b72d | ||
|
|
af0f629073 | ||
|
|
a4f433195d | ||
|
|
12ea75ab65 | ||
|
|
a6f24508e8 | ||
|
|
d562fd40c0 | ||
|
|
cded2c52ab | ||
|
|
df99442123 | ||
|
|
b9b1601bb8 | ||
|
|
5efac0dfaf | ||
|
|
ea41dbc8e7 | ||
|
|
c9ca1697e5 | ||
|
|
ed3ed78ec3 | ||
|
|
37aaa7b155 | ||
|
|
4b4e55a246 | ||
|
|
d6977352aa | ||
|
|
87145f1e6a | ||
|
|
7f9dafab68 | ||
|
|
07720f1d99 | ||
|
|
c0e63f76da | ||
|
|
93f4b84b8b | ||
|
|
804d5d2487 | ||
|
|
babc724220 | ||
|
|
138c774297 | ||
|
|
50ea5a76fb | ||
|
|
54282409bb | ||
|
|
f5b66f4745 | ||
|
|
907074a454 | ||
|
|
1f764d418c | ||
|
|
e2248015aa | ||
|
|
1e5b809b70 | ||
|
|
d79ea8b15c | ||
|
|
c60ea67a9b | ||
|
|
4819011c97 | ||
|
|
9be754cdbf | ||
|
|
35ed62ad0f | ||
|
|
d705d68f2f | ||
|
|
43a1f46047 | ||
|
|
1911946ed1 | ||
|
|
1572c95b4d | ||
|
|
cc80d05e49 | ||
|
|
83b681cebb | ||
|
|
57f8c7615c | ||
|
|
d9bcf2a439 | ||
|
|
051868c74c | ||
|
|
ffee4a472a | ||
|
|
00a2eec5f6 | ||
|
|
859d234543 | ||
|
|
207bdd1fae | ||
|
|
d102a8fd64 | ||
|
|
fcbdb09796 | ||
|
|
59f3f4737a | ||
|
|
c80c3e0256 | ||
|
|
ad1cb4c200 | ||
|
|
68ab7b81a1 | ||
|
|
f1cf4ebe60 | ||
|
|
0a452fad04 | ||
|
|
eb618638d2 | ||
|
|
7b8a2fbcef | ||
|
|
928169a4f2 | ||
|
|
0af666e3ac | ||
|
|
d41808efe1 | ||
|
|
aec28c8b67 | ||
|
|
3b5daa1451 | ||
|
|
f6579dbeb4 | ||
|
|
ab40786882 | ||
|
|
5f065c2685 | ||
|
|
6b50ace2bd | ||
|
|
c46c03158e | ||
|
|
7395fc6fd1 | ||
|
|
c35d649f6d | ||
|
|
60e833245d | ||
|
|
f424503234 | ||
|
|
1d42542514 | ||
|
|
d1314a4d5d | ||
|
|
48c3308c31 | ||
|
|
ba149330b1 | ||
|
|
6731879931 | ||
|
|
b15159caeb | ||
|
|
ec8c51a014 | ||
|
|
84d3aa1962 | ||
|
|
0bc693f157 | ||
|
|
5ae7a0c9c2 | ||
|
|
e6bc752b68 | ||
|
|
78fc1fe862 | ||
|
|
84500cdcae | ||
|
|
480af26b57 | ||
|
|
2003f308f3 | ||
|
|
a042fbbe43 | ||
|
|
ee5577c2da | ||
|
|
24945a9498 | ||
|
|
f6f689d570 | ||
|
|
7703625e76 | ||
|
|
17b81fb132 | ||
|
|
111576e2ca | ||
|
|
862cc43db7 | ||
|
|
48924626df | ||
|
|
72ea9b7a72 | ||
|
|
e0f9b33d23 | ||
|
|
c7fe944e73 | ||
|
|
584f5ce05e | ||
|
|
614c085310 | ||
|
|
866aeb8ad9 | ||
|
|
23aaecd99a | ||
|
|
77fb74c188 | ||
|
|
70fb5f5bfd | ||
|
|
26c07b671f | ||
|
|
c9f2e01131 | ||
|
|
f4f3e4204d | ||
|
|
d7d1e2d169 | ||
|
|
6ab97c579e | ||
|
|
3d4ac57bd0 | ||
|
|
efb3df8233 | ||
|
|
a952112910 | ||
|
|
20fa1a3a3b | ||
|
|
7609ccee4c | ||
|
|
9065dcef54 | ||
|
|
4ab307b10f | ||
|
|
675d825f6b | ||
|
|
1328e0cc05 | ||
|
|
f0ba64f23b | ||
|
|
22c27dd583 | ||
|
|
35da664a09 | ||
|
|
1323cb13c4 | ||
|
|
3287f2d00b | ||
|
|
d15a1451b7 | ||
|
|
9d370a18b8 | ||
|
|
865e25b3a9 | ||
|
|
82ab598426 | ||
|
|
09fae62888 | ||
|
|
7e0a220fde | ||
|
|
ed9a15c0bd | ||
|
|
b763d4358e | ||
|
|
5f2d4ac122 | ||
|
|
3bdc90a661 | ||
|
|
b5212a69c9 | ||
|
|
0bc903fa21 | ||
|
|
e2f20f0e24 | ||
|
|
30a225ce9f | ||
|
|
30dd54a318 | ||
|
|
abc8ad3fd4 | ||
|
|
4897627943 | ||
|
|
18533ef52b | ||
|
|
faa9c9c491 | ||
|
|
11f6494c20 | ||
|
|
9364da3414 | ||
|
|
ed5bc3091e | ||
|
|
8ff51eb176 | ||
|
|
90fb5e1f33 | ||
|
|
cda08ec033 | ||
|
|
21c585abec | ||
|
|
a1d0492d8d | ||
|
|
745f2e2768 | ||
|
|
ef55fecfe2 | ||
|
|
cc675da49c | ||
|
|
a9d5502fad | ||
|
|
849f49b622 | ||
|
|
d89f1ea663 | ||
|
|
5701543746 | ||
|
|
57db31c662 | ||
|
|
eadca4a96b | ||
|
|
4a111099c4 | ||
|
|
6f34699e4b | ||
|
|
aa6c521394 | ||
|
|
e80db346da | ||
|
|
26c7f3f5f3 | ||
|
|
0057ee7b72 | ||
|
|
0a3e55734b | ||
|
|
0b88383b01 | ||
|
|
a364edde17 | ||
|
|
22c6df3f85 | ||
|
|
93a3a742ac | ||
|
|
0abca5e0c7 | ||
|
|
f275474fec | ||
|
|
f12dc6127b | ||
|
|
e526f375c8 | ||
|
|
3ae1102d13 | ||
|
|
d1632d80e3 | ||
|
|
cad4110e24 | ||
|
|
fc7369384b | ||
|
|
c45b414476 | ||
|
|
73bd0c1dba | ||
|
|
502dd2d2e2 | ||
|
|
80a2f8052c | ||
|
|
a495138374 | ||
|
|
7f69166e97 | ||
|
|
2268790656 | ||
|
|
3a02d1cdd6 | ||
|
|
2e560677e7 | ||
|
|
b4a6b098a4 | ||
|
|
12f8c0db96 | ||
|
|
d39c95e0e7 | ||
|
|
f74031b50d | ||
|
|
8e67f5a707 | ||
|
|
a194a901b4 | ||
|
|
049edbfcaf | ||
|
|
7c18616160 | ||
|
|
70191ceaa8 | ||
|
|
99bac0fd27 | ||
|
|
e8b55eccab | ||
|
|
40ebd1b730 | ||
|
|
b5102e7d7e | ||
|
|
f3036ad2c9 | ||
|
|
4a01b9ec90 | ||
|
|
2f13806a2c | ||
|
|
5f5599a1a8 | ||
|
|
f2680563bd | ||
|
|
79d658243d | ||
|
|
3994fa65c0 | ||
|
|
10671cf999 | ||
|
|
40b9b4672e | ||
|
|
44fc4bfeff | ||
|
|
402f3e32ae | ||
|
|
436a595839 | ||
|
|
8a360281c6 | ||
|
|
730f34753a | ||
|
|
013f573cff | ||
|
|
ee8a81d2e1 | ||
|
|
bed03be332 | ||
|
|
debe4b543b | ||
|
|
1c601e09b6 | ||
|
|
ba54aae206 | ||
|
|
36961f83bf | ||
|
|
6db99c03cb | ||
|
|
999d100464 | ||
|
|
9190d86148 | ||
|
|
4b5cb5a3e2 | ||
|
|
847e1d2c60 | ||
|
|
970df6478b | ||
|
|
3371f17606 | ||
|
|
0f65fe894c | ||
|
|
2ac27a3e3e | ||
|
|
defcc54af2 | ||
|
|
fe84c5c9a3 | ||
|
|
2ff8041e15 | ||
|
|
1d3e551e7f | ||
|
|
79795b6f49 | ||
|
|
16da3300e3 |
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [boudra]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
- "android-v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
@@ -16,7 +17,7 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-android-apk:
|
publish-android-apk:
|
||||||
@@ -31,6 +32,36 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
shell: bash
|
||||||
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Ensure GitHub release exists
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_args=(
|
||||||
|
release create "$RELEASE_TAG"
|
||||||
|
--repo "${{ github.repository }}"
|
||||||
|
--title "Paseo $RELEASE_TAG"
|
||||||
|
--generate-notes
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
release_args+=(--prerelease)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! gh "${release_args[@]}"; then
|
||||||
|
echo "Release creation raced with another workflow; continuing."
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -94,24 +125,6 @@ jobs:
|
|||||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Wait for GitHub release tag
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in $(seq 1 90); do
|
|
||||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
|
||||||
echo "Found release for tag $RELEASE_TAG"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
|
|
||||||
sleep 20
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Upload APK to GitHub Release
|
- name: Upload APK to GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
- '!v*-rc.*'
|
||||||
|
- 'app-v*'
|
||||||
|
- '!app-v*-rc.*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -25,6 +28,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: npm run typecheck --workspace=@getpaseo/app
|
run: npm run typecheck --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ on:
|
|||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
- 'patches/**'
|
- 'patches/**'
|
||||||
- '.github/workflows/deploy-website.yml'
|
- '.github/workflows/deploy-website.yml'
|
||||||
|
release:
|
||||||
|
types: [published, edited]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -3,21 +3,21 @@ name: Desktop Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- 'v*'
|
||||||
- "desktop-v*"
|
- 'desktop-v*'
|
||||||
- "desktop-macos-v*"
|
- 'desktop-macos-v*'
|
||||||
- "desktop-linux-v*"
|
- 'desktop-linux-v*'
|
||||||
- "desktop-windows-v*"
|
- 'desktop-windows-v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: "Existing tag to build (e.g. v0.1.0)"
|
description: 'Existing tag to build (e.g. v0.1.0)'
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
platform:
|
platform:
|
||||||
description: "Optional desktop platform to build."
|
description: 'Optional desktop platform to build.'
|
||||||
required: false
|
required: false
|
||||||
default: "all"
|
default: 'all'
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- all
|
- all
|
||||||
@@ -31,18 +31,55 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
|
DESKTOP_WORKSPACE: '@getpaseo/desktop'
|
||||||
|
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
create-release:
|
||||||
|
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: scripts
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve release metadata
|
||||||
|
shell: bash
|
||||||
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||||
|
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||||
|
else
|
||||||
|
prerelease_flag=""
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
prerelease_flag="--prerelease"
|
||||||
|
fi
|
||||||
|
gh release create "$RELEASE_TAG" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--title "Paseo $RELEASE_TAG" \
|
||||||
|
--generate-notes \
|
||||||
|
$prerelease_flag
|
||||||
|
fi
|
||||||
|
|
||||||
publish-macos:
|
publish-macos:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
|
needs: [create-release]
|
||||||
|
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- runner: macos-14
|
- runner: macos-14
|
||||||
rust_target: aarch64-apple-darwin
|
electron_arch: arm64
|
||||||
- runner: macos-15-intel
|
- runner: macos-15-intel
|
||||||
rust_target: x86_64-apple-darwin
|
electron_arch: x64
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -56,187 +93,169 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
set -euo pipefail
|
|
||||||
source_tag="${SOURCE_TAG}"
|
|
||||||
release_tag="$source_tag"
|
|
||||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
|
||||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
|
||||||
release_tag="v${source_tag#${prefix}}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
version="${release_tag#v}"
|
|
||||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
|
||||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set desktop version from tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const version = process.env.DESKTOP_VERSION;
|
|
||||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
|
||||||
console.log(`Setting desktop version to ${version}`);
|
|
||||||
|
|
||||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
|
||||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
|
||||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
|
||||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
|
||||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
|
||||||
|
|
||||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
|
||||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
|
||||||
let inPackage = false, updated = false;
|
|
||||||
const result = lines.map((line) => {
|
|
||||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
|
||||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
|
||||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
|
||||||
updated = true;
|
|
||||||
return `version = "${version}"`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
|
||||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: "https://npm.pkg.github.com"
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
scope: "@boudra"
|
scope: '@boudra'
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.rust_target }}
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: desktop-release-macos-${{ matrix.rust_target }}
|
|
||||||
workspaces: |
|
|
||||||
.
|
|
||||||
packages/desktop/src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build web app for Tauri
|
- name: Set desktop package version from tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const version = process.env.DESKTOP_VERSION;
|
||||||
|
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Build managed runtime
|
- name: Build desktop release
|
||||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
shell: bash
|
||||||
|
|
||||||
- name: Validate managed runtime bundle
|
|
||||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Import Apple code-signing certificate
|
|
||||||
uses: apple-actions/import-codesign-certs@v3
|
|
||||||
with:
|
|
||||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Sign bundled managed runtime
|
|
||||||
env:
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
run: node ./packages/desktop/scripts/sign-managed-runtime-macos.mjs
|
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
EP_GH_IGNORE_TIME: true
|
||||||
|
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
publish_mode="never"
|
||||||
|
publish_args=()
|
||||||
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
|
publish_mode="always"
|
||||||
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||||
|
|
||||||
|
- name: Upload manifest artifact
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mac-manifest-${{ matrix.electron_arch }}
|
||||||
|
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
finalize-mac-manifest:
|
||||||
|
needs: [publish-macos]
|
||||||
|
if: ${{ needs.publish-macos.result == 'success' }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: scripts
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
shell: bash
|
||||||
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Download manifest artifacts
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: mac-manifest-*
|
||||||
|
|
||||||
|
- name: Merge manifests
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
release_draft="false"
|
|
||||||
fi
|
|
||||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build and publish macOS Tauri release
|
node <<'NODE'
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
const fs = require('node:fs');
|
||||||
id: tauri_build
|
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
with:
|
|
||||||
projectPath: packages/desktop
|
|
||||||
tagName: ${{ env.RELEASE_TAG }}
|
|
||||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
|
||||||
releaseBody: See the assets to download and install this version.
|
|
||||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
|
||||||
prerelease: false
|
|
||||||
args: --target ${{ matrix.rust_target }}
|
|
||||||
|
|
||||||
- name: Notarize and re-upload DMG
|
// Simple YAML parser for electron-builder's latest-mac.yml format
|
||||||
|
function parseManifest(text) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const result = { files: [] };
|
||||||
|
let currentFile = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
|
||||||
|
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
|
||||||
|
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
|
||||||
|
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
|
||||||
|
else if (line.trim().startsWith('- url:')) {
|
||||||
|
currentFile = { url: line.trim().replace('- url: ', '') };
|
||||||
|
result.files.push(currentFile);
|
||||||
|
} else if (line.trim().startsWith('sha512:') && currentFile) {
|
||||||
|
currentFile.sha512 = line.trim().split(': ')[1].trim();
|
||||||
|
} else if (line.trim().startsWith('size:') && currentFile) {
|
||||||
|
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
|
||||||
|
currentFile = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toYaml(manifest) {
|
||||||
|
let out = `version: ${manifest.version}\n`;
|
||||||
|
out += `files:\n`;
|
||||||
|
for (const f of manifest.files) {
|
||||||
|
out += ` - url: ${f.url}\n`;
|
||||||
|
out += ` sha512: ${f.sha512}\n`;
|
||||||
|
out += ` size: ${f.size}\n`;
|
||||||
|
}
|
||||||
|
out += `path: ${manifest.path}\n`;
|
||||||
|
out += `sha512: ${manifest.sha512}\n`;
|
||||||
|
out += `releaseDate: '${manifest.releaseDate}'\n`;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
||||||
|
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
||||||
|
|
||||||
|
const arm64 = parseManifest(arm64Text);
|
||||||
|
const x64 = parseManifest(x64Text);
|
||||||
|
|
||||||
|
// Merge: all files from both, default path points to arm64 zip
|
||||||
|
const merged = {
|
||||||
|
version: arm64.version,
|
||||||
|
files: [...arm64.files, ...x64.files],
|
||||||
|
path: arm64.path,
|
||||||
|
sha512: arm64.sha512,
|
||||||
|
releaseDate: arm64.releaseDate || x64.releaseDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = toYaml(merged);
|
||||||
|
fs.writeFileSync('latest-mac.yml', output);
|
||||||
|
console.log('Merged manifest:\n' + output);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Upload merged manifest to release
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
artifacts='${{ steps.tauri_build.outputs.artifactPaths }}'
|
|
||||||
dmg_path=$(echo "$artifacts" | jq -r '.[] | select(endswith(".dmg"))')
|
|
||||||
if [ -z "$dmg_path" ]; then
|
|
||||||
echo "::error::No DMG found in tauri build artifacts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "DMG: $dmg_path"
|
|
||||||
|
|
||||||
echo "Signing DMG..."
|
|
||||||
codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$dmg_path"
|
|
||||||
|
|
||||||
echo "Submitting DMG for notarization..."
|
|
||||||
xcrun notarytool submit "$dmg_path" \
|
|
||||||
--apple-id "$APPLE_ID" \
|
|
||||||
--password "$APPLE_PASSWORD" \
|
|
||||||
--team-id "$APPLE_TEAM_ID" \
|
|
||||||
--wait
|
|
||||||
|
|
||||||
echo "Stapling notarization ticket..."
|
|
||||||
xcrun stapler staple "$dmg_path"
|
|
||||||
|
|
||||||
echo "Verifying..."
|
|
||||||
spctl --assess --type install --verbose "$dmg_path"
|
|
||||||
|
|
||||||
echo "Replacing release asset with notarized DMG..."
|
|
||||||
gh release upload "$RELEASE_TAG" "$dmg_path" --repo "${{ github.repository }}" --clobber
|
|
||||||
|
|
||||||
- name: Build macOS app (smoke only)
|
|
||||||
if: env.IS_SMOKE_TAG == 'true'
|
|
||||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --target ${{ matrix.rust_target }} --no-bundle
|
|
||||||
|
|
||||||
publish-linux:
|
publish-linux:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
needs: [create-release]
|
||||||
|
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -250,149 +269,60 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
set -euo pipefail
|
|
||||||
source_tag="${SOURCE_TAG}"
|
|
||||||
release_tag="$source_tag"
|
|
||||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
|
||||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
|
||||||
release_tag="v${source_tag#${prefix}}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
version="${release_tag#v}"
|
|
||||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
|
||||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set desktop version from tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const version = process.env.DESKTOP_VERSION;
|
|
||||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
|
||||||
console.log(`Setting desktop version to ${version}`);
|
|
||||||
|
|
||||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
|
||||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
|
||||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
|
||||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
|
||||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
|
||||||
|
|
||||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
|
||||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
|
||||||
let inPackage = false, updated = false;
|
|
||||||
const result = lines.map((line) => {
|
|
||||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
|
||||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
|
||||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
|
||||||
updated = true;
|
|
||||||
return `version = "${version}"`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
|
||||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Install Linux packaging dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libfuse2
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: "https://npm.pkg.github.com"
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
scope: "@boudra"
|
scope: '@boudra'
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: desktop-release-linux
|
|
||||||
workspaces: |
|
|
||||||
.
|
|
||||||
packages/desktop/src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build web app for Tauri
|
- name: Set desktop package version from tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const version = process.env.DESKTOP_VERSION;
|
||||||
|
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
run: npm run build:web --workspace=@getpaseo/app
|
||||||
|
|
||||||
- name: Build managed runtime
|
- name: Build desktop release
|
||||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Validate managed runtime bundle
|
|
||||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Strip CUDA dependencies from onnxruntime
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
|
||||||
find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*cuda*' -o -name '*tensorrt*' \) -delete || true
|
|
||||||
# Remove CUDA shared library references from onnxruntime .so files so linuxdeploy
|
|
||||||
# doesn't try to bundle them (they're optional runtime deps, not needed for CPU inference)
|
|
||||||
for f in $(find packages/desktop/src-tauri/resources/managed-runtime -path '*/onnxruntime-node/bin/*' \( -name '*.so' -o -name '*.so.*' \)); do
|
|
||||||
for lib in $(patchelf --print-needed "$f" 2>/dev/null | grep -iE 'cublas|cudnn|cudart|cufft|curand|cusolver|cusparse|nccl|nvrtc|tensorrt|nvinfer'); do
|
|
||||||
echo "Removing needed $lib from $f"
|
|
||||||
patchelf --remove-needed "$lib" "$f"
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
EP_GH_IGNORE_TIME: true
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
publish_mode="never"
|
||||||
:
|
publish_args=()
|
||||||
else
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
release_draft="false"
|
publish_mode="always"
|
||||||
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build and publish Linux Tauri release
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
NO_STRIP: "true"
|
|
||||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
|
||||||
with:
|
|
||||||
projectPath: packages/desktop
|
|
||||||
tagName: ${{ env.RELEASE_TAG }}
|
|
||||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
|
||||||
releaseBody: See the assets to download and install this version.
|
|
||||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
|
||||||
prerelease: false
|
|
||||||
args: --bundles appimage
|
|
||||||
|
|
||||||
- name: Build Linux app (smoke only)
|
|
||||||
if: env.IS_SMOKE_TAG == 'true'
|
|
||||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
|
||||||
|
|
||||||
publish-windows:
|
publish-windows:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
needs: [create-release]
|
||||||
|
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -406,126 +336,61 @@ jobs:
|
|||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
set -euo pipefail
|
|
||||||
source_tag="${SOURCE_TAG}"
|
|
||||||
release_tag="$source_tag"
|
|
||||||
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
|
|
||||||
if [[ "$source_tag" == ${prefix}* ]]; then
|
|
||||||
release_tag="v${source_tag#${prefix}}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
version="${release_tag#v}"
|
|
||||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
|
||||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set desktop version from tag
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'NODE'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const version = process.env.DESKTOP_VERSION;
|
|
||||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
|
||||||
console.log(`Setting desktop version to ${version}`);
|
|
||||||
|
|
||||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
|
||||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
|
||||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
|
||||||
if (!tauriRe.test(tauriConfText)) throw new Error('Failed to find version in tauri.conf.json');
|
|
||||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
|
||||||
|
|
||||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
|
||||||
const lines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
|
||||||
let inPackage = false, updated = false;
|
|
||||||
const result = lines.map((line) => {
|
|
||||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
|
||||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
|
||||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
|
||||||
updated = true;
|
|
||||||
return `version = "${version}"`;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
if (!updated) throw new Error('Failed to update Cargo.toml version');
|
|
||||||
fs.writeFileSync(cargoTomlPath, result.join('\n') + '\n');
|
|
||||||
NODE
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: '22'
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
registry-url: "https://npm.pkg.github.com"
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
scope: "@boudra"
|
scope: '@boudra'
|
||||||
|
|
||||||
- name: Install Rust stable
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Restore Rust cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
shared-key: desktop-release-windows
|
|
||||||
workspaces: |
|
|
||||||
.
|
|
||||||
packages/desktop/src-tauri -> target
|
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build web app for Tauri
|
- name: Set desktop package version from tag
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
|
||||||
|
|
||||||
- name: Build managed runtime
|
|
||||||
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Validate managed runtime bundle
|
|
||||||
run: npm run validate:managed-runtime --workspace=@getpaseo/desktop
|
|
||||||
|
|
||||||
- name: Detect existing GitHub release state
|
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const version = process.env.DESKTOP_VERSION;
|
||||||
|
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
- name: Build workspace dependencies
|
||||||
|
run: npm run build:workspace-deps --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
- name: Build web app for desktop
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
||||||
|
$env:NODE_OPTIONS = "--require=$patchPath"
|
||||||
|
npx expo export --platform web
|
||||||
|
working-directory: packages/app
|
||||||
|
|
||||||
|
- name: Build desktop release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
EP_GH_IGNORE_TIME: true
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
publish_mode="never"
|
||||||
:
|
publish_args=()
|
||||||
else
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
release_draft="false"
|
publish_mode="always"
|
||||||
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
fi
|
fi
|
||||||
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build and publish Windows Tauri release
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
|
||||||
uses: tauri-apps/tauri-action@v0
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
with:
|
|
||||||
projectPath: packages/desktop
|
|
||||||
tagName: ${{ env.RELEASE_TAG }}
|
|
||||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
|
||||||
releaseBody: See the assets to download and install this version.
|
|
||||||
releaseDraft: ${{ env.RELEASE_DRAFT }}
|
|
||||||
prerelease: false
|
|
||||||
args: --bundles nsis
|
|
||||||
|
|
||||||
- name: Build Windows app (smoke only)
|
|
||||||
if: env.IS_SMOKE_TAG == 'true'
|
|
||||||
run: npm run tauri --workspace=@getpaseo/desktop build -- --no-bundle
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Fix Nix hash
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fix-nix-hash:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref || github.ref }}
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- name: Fix lockfile and update hash
|
||||||
|
run: ./scripts/update-nix.sh
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
run: |
|
||||||
|
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add package-lock.json nix/package.nix
|
||||||
|
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||||
|
git push
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Nix Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'nix/**'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'packages/highlight/**'
|
||||||
|
- 'packages/server/**'
|
||||||
|
- 'packages/relay/**'
|
||||||
|
- 'packages/cli/**'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'nix/**'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'packages/highlight/**'
|
||||||
|
- 'packages/server/**'
|
||||||
|
- 'packages/relay/**'
|
||||||
|
- 'packages/cli/**'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
|
- name: Build Nix package
|
||||||
|
run: nix build .#default -o result
|
||||||
|
|
||||||
|
- name: Verify lockfile is complete
|
||||||
|
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||||
|
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||||
|
run: |
|
||||||
|
node scripts/fix-lockfile.mjs package-lock.json
|
||||||
|
git diff --exit-code package-lock.json || {
|
||||||
|
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||||
|
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||||
|
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Check npmDepsHash is up to date
|
||||||
|
run: ./scripts/update-nix.sh --check
|
||||||
@@ -19,11 +19,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
draft:
|
|
||||||
description: "Create missing release as draft."
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
@@ -48,7 +43,6 @@ jobs:
|
|||||||
REF: ${{ github.ref }}
|
REF: ${{ github.ref }}
|
||||||
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
||||||
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
||||||
INPUT_DRAFT: ${{ github.event.inputs.draft }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -70,8 +64,4 @@ jobs:
|
|||||||
args+=(--create-if-missing)
|
args+=(--create-if-missing)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
|
|
||||||
args+=(--draft)
|
|
||||||
fi
|
|
||||||
|
|
||||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ jobs:
|
|||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
- name: Build relay dependency
|
- name: Build relay dependency
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
@@ -44,3 +47,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm run test --workspace=@getpaseo/server
|
run: npm run test --workspace=@getpaseo/server
|
||||||
|
env:
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ test-results/
|
|||||||
# Vercel
|
# Vercel
|
||||||
.vercel/
|
.vercel/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Build outputs
|
|
||||||
dist
|
|
||||||
.next
|
|
||||||
.expo
|
|
||||||
build
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Coverage
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# Lock files
|
|
||||||
*.lock
|
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# Generated
|
|
||||||
android
|
|
||||||
ios
|
|
||||||
.turbo
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"tabWidth": 2,
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
||||||
+334
-3
@@ -1,5 +1,336 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.50 - 2026-04-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
|
||||||
|
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
|
||||||
|
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
|
||||||
|
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
|
||||||
|
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
|
||||||
|
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Workspaces load instantly on connect instead of waiting for a full sync.
|
||||||
|
- File explorer and diff pane remember which folders are expanded when you switch tabs.
|
||||||
|
- Closing a workspace tab is now instant.
|
||||||
|
- Settings shows a Refresh button for providers and displays error details inline.
|
||||||
|
- Reload agent moved away from the close button to prevent accidental taps.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Voice mode no longer drifts into false speech detection during long sessions.
|
||||||
|
- Garbled overlapping text on plan cards.
|
||||||
|
- Changes pane could show stale diffs when working with git worktrees.
|
||||||
|
- Restarting an agent quickly could crash the session.
|
||||||
|
- Copilot no longer pauses for permission prompts in autopilot mode.
|
||||||
|
- Connection and pairing dialogs now display correctly on tablets.
|
||||||
|
- Orchestration errors from agents are now surfaced instead of silently lost.
|
||||||
|
- Diff stats no longer reset to zero when reconnecting.
|
||||||
|
|
||||||
|
## 0.1.49 - 2026-04-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
|
||||||
|
- Model picker only shows models from the agent's own provider, not every provider on the server.
|
||||||
|
- Model lists stay consistent regardless of which screen you open first.
|
||||||
|
|
||||||
|
## 0.1.48 - 2026-04-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
|
||||||
|
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
|
||||||
|
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
|
||||||
|
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
|
||||||
|
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
|
||||||
|
- Removed noisy agent lifecycle toasts that fired on every state change.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
|
||||||
|
- Input field on running agent screens now correctly receives keyboard focus.
|
||||||
|
- Mobile model selector alignment and sizing.
|
||||||
|
|
||||||
|
## 0.1.47 - 2026-04-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
|
||||||
|
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
|
||||||
|
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
|
||||||
|
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
|
||||||
|
|
||||||
|
## 0.1.46 - 2026-04-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
|
||||||
|
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
|
||||||
|
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
|
||||||
|
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
|
||||||
|
|
||||||
|
## 0.1.45 - 2026-04-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
|
||||||
|
- Copilot agent provider re-enabled after ACP compatibility fixes.
|
||||||
|
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
|
||||||
|
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
|
||||||
|
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
|
||||||
|
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
|
||||||
|
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
|
||||||
|
- Daemon status dialog in desktop settings for quick health checks.
|
||||||
|
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
|
||||||
|
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
|
||||||
|
- Settings sections reordered for better grouping (integrations and daemon together).
|
||||||
|
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
|
||||||
|
- Silero VAD crash caused by external buffer mode in CircularBuffer.
|
||||||
|
- Bulk close now correctly archives stored agents instead of leaving orphans.
|
||||||
|
- Pinned archived agents are no longer pruned when closing tabs.
|
||||||
|
- OpenCode event stream starvation during slash command execution.
|
||||||
|
- Duplicate workspaces when multiple git worktrees share the same root.
|
||||||
|
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
|
||||||
|
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
|
||||||
|
- Forward-compatible provider handling so older app clients don't break on new provider types.
|
||||||
|
- Input event listener race condition in the web scrollbar hook.
|
||||||
|
- Open-project screen content now vertically centered.
|
||||||
|
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
|
||||||
|
|
||||||
|
## 0.1.44 - 2026-04-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Desktop app now stops the daemon cleanly before auto-update restarts.
|
||||||
|
- Disabled claude-acp and copilot providers from the agent registry.
|
||||||
|
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
|
||||||
|
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
|
||||||
|
- Shell injection, symlink escape, and pairing endpoint security hardening.
|
||||||
|
|
||||||
|
## 0.1.43 - 2026-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
|
||||||
|
- Searchable model favorites — quickly find and pin preferred models.
|
||||||
|
- Slash command support for OpenCode agents.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Refined model selector UX with better mobile sheet behavior.
|
||||||
|
- Workspace status now uses amber alert styling for "needs input" state.
|
||||||
|
- Themed scrollbar on message input for consistent styling.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
|
||||||
|
- Shell arguments with spaces are now properly quoted on Windows.
|
||||||
|
- Claude models with 1M context support are now correctly reported.
|
||||||
|
|
||||||
|
## 0.1.42 - 2026-04-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
|
||||||
|
|
||||||
|
## 0.1.41 - 2026-04-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
|
||||||
|
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
|
||||||
|
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
|
||||||
|
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
|
||||||
|
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
|
||||||
|
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
|
||||||
|
- Fixed context menu for copy/paste across the desktop app.
|
||||||
|
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1–F12).
|
||||||
|
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
|
||||||
|
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
|
||||||
|
|
||||||
|
## 0.1.40 - 2026-04-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Workspace tabs can now be closed in batches.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- OpenCode reasoning content no longer appears duplicated as assistant text.
|
||||||
|
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
|
||||||
|
- Archive tab now correctly reconciles agent visibility after archiving.
|
||||||
|
- File diff tracking in workspaces now works correctly on Linux.
|
||||||
|
- iPad layout now renders correctly in desktop mode.
|
||||||
|
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
|
||||||
|
|
||||||
|
## 0.1.39 - 2026-03-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
|
||||||
|
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
|
||||||
|
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
|
||||||
|
|
||||||
|
## 0.1.38 - 2026-03-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
|
||||||
|
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
|
||||||
|
|
||||||
|
## 0.1.37 - 2026-03-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
|
||||||
|
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed broken PATH propagation and Claude binary resolution on Windows.
|
||||||
|
- Dictation errors now show a visible toast instead of failing silently.
|
||||||
|
|
||||||
|
## 0.1.36 - 2026-03-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed Windows drive-letter path handling across the codebase.
|
||||||
|
- Fixed stale Nix hash with automatic lockfile-change detection.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added metrics collection and terminal performance tests.
|
||||||
|
|
||||||
|
## 0.1.35 - 2026-03-26
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
|
||||||
|
- Codex file deletions now display correctly as removed lines in diffs.
|
||||||
|
- OpenCode questions are now surfaced in the permission UI.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed queued prompt dispatch after idle transition.
|
||||||
|
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added support for Nix and NixOS installation.
|
||||||
|
|
||||||
|
## 0.1.34 - 2026-03-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
|
||||||
|
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
|
||||||
|
- Added a redesigned command autocomplete with a detail card and dropdown styling.
|
||||||
|
- Linked Android download surfaces to the Google Play Store.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
|
||||||
|
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
|
||||||
|
- Restored per-provider form preferences and removed the Auto model fallback.
|
||||||
|
- Improved Codex activity logs with clearer tool-call summaries.
|
||||||
|
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
|
||||||
|
- Improved chat transcript readability.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed `paseo send --no-wait` not taking effect.
|
||||||
|
- Fixed stale abort results contaminating replacement turns after an interrupt.
|
||||||
|
- Fixed Claude interrupt handling and autonomous wake reliability.
|
||||||
|
- Fixed nested Claude Code session detection and provider availability checks.
|
||||||
|
- Fixed agent input focus scoping across panels.
|
||||||
|
- Fixed terminal snapshot ordering when subscribing.
|
||||||
|
- Fixed `chat read --since` to accept message IDs.
|
||||||
|
- Fixed keyboard pane focus syncing with the active panel.
|
||||||
|
- Fixed assistant text selection on web.
|
||||||
|
- Fixed archived-agent notifications still appearing in chat rooms.
|
||||||
|
- Fixed the attach-images button interaction in the message composer.
|
||||||
|
- Pruned wrong-platform native binaries from Electron desktop builds.
|
||||||
|
|
||||||
|
## 0.1.33 - 2026-03-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
|
||||||
|
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
|
||||||
|
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
|
||||||
|
- The notification test button now surfaces errors instead of failing silently.
|
||||||
|
|
||||||
|
## 0.1.32 - 2026-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
|
||||||
|
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
|
||||||
|
- Added line numbers and word-wrap toggle to file previews.
|
||||||
|
- Added an archived agent callout with an unarchive button so you can restore agents directly from the chat view.
|
||||||
|
- Added workspace kind indicators in the sidebar (e.g. worktree vs standalone).
|
||||||
|
- Expanded diff syntax highlighting to cover more languages.
|
||||||
|
- Added status bar tooltips for project and agent status.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
|
||||||
|
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
|
||||||
|
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
|
||||||
|
- Automatic cleanup of stale workspaces: deleted worktree directories and fully-archived workspaces are pruned automatically.
|
||||||
|
- After archiving a workspace, the app now redirects to the next available workspace instead of leaving you on a dead screen.
|
||||||
|
- Reopening an archived agent tab now keeps it open instead of collapsing back to archived state.
|
||||||
|
- Reduced unnecessary re-renders across the workspace screen, sidebar, and agent list for smoother scrolling and interaction.
|
||||||
|
- Agent list no longer refreshes in the background when the screen is unfocused, saving resources.
|
||||||
|
- Desktop key repeat now works correctly on macOS.
|
||||||
|
- Desktop notifications on macOS are more reliable.
|
||||||
|
- Daemon startup no longer blocks on model downloads.
|
||||||
|
- Better error messages from the daemon — RPC errors now include the actual underlying details.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
|
||||||
|
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
|
||||||
|
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
|
||||||
|
- Fixed desktop window drag coordinates being applied when they shouldn't be.
|
||||||
|
|
||||||
|
## 0.1.30 - 2026-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
|
||||||
|
- Added a combined model selector and agent mode visuals across key UI surfaces.
|
||||||
|
- Added Open Graph metadata improvements for richer website sharing previews.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
|
||||||
|
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
|
||||||
|
- Improved project picker path display and general workspace UI polish.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
|
||||||
|
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
|
||||||
|
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
|
||||||
|
|
||||||
|
## 0.1.28 - 2026-03-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added OpenCode build and plan modes.
|
||||||
|
- Added website landing pages for Claude Code, Codex, and OpenCode.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved the git action menu for more reliable repository actions.
|
||||||
|
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
|
||||||
|
- Updated the website hero copy and added a sponsor callout section.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed assistant file links so they open the correct workspace files from chat.
|
||||||
|
|
||||||
|
## 0.1.27 - 2026-03-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added voice runtime with new audio engine architecture for voice interactions.
|
||||||
|
- Added Grep tool support in Claude tool-call mapping.
|
||||||
|
- Added ability to open workspace files directly from agent chat messages.
|
||||||
|
- Added desktop notifications via a custom native bridge.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Improved image picker, markdown rendering, and UI interactions.
|
||||||
|
- Improved shell environment detection using shell-env.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed platform-specific markdown link rendering.
|
||||||
|
- Fixed Linux AppImage CLI resource paths.
|
||||||
|
- Fixed Codex replacement stream being killed by stale turn notifications.
|
||||||
|
|
||||||
## 0.1.26 - 2026-03-12
|
## 0.1.26 - 2026-03-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -118,7 +449,7 @@
|
|||||||
- Redesigned the website get-started experience into a clearer two-step flow.
|
- Redesigned the website get-started experience into a clearer two-step flow.
|
||||||
- Simplified website GitHub navigation and changelog headings.
|
- Simplified website GitHub navigation and changelog headings.
|
||||||
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
||||||
- Enabled drag interactions in previously unhandled areas on the desktop (Tauri) draft screen.
|
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
|
||||||
- Hid empty filter groups in the left sidebar.
|
- Hid empty filter groups in the left sidebar.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -140,7 +471,7 @@
|
|||||||
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
||||||
- Improved desktop command autocomplete behavior to match combobox interactions.
|
- Improved desktop command autocomplete behavior to match combobox interactions.
|
||||||
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
||||||
- Improved desktop settings and permissions UX in Tauri.
|
- Improved desktop settings and permissions UX on desktop.
|
||||||
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -179,7 +510,7 @@
|
|||||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||||
- Fixed Claude session identity preservation when reloading existing agents.
|
- Fixed Claude session identity preservation when reloading existing agents.
|
||||||
- Fixed combobox option behavior and related interactions.
|
- Fixed combobox option behavior and related interactions.
|
||||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||||
|
|
||||||
## 0.1.7 - 2026-02-16
|
## 0.1.7 - 2026-02-16
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ This is an npm workspace monorepo:
|
|||||||
- `packages/app` — Mobile + web client (Expo)
|
- `packages/app` — Mobile + web client (Expo)
|
||||||
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
||||||
- `packages/relay` — E2E encrypted relay for remote access
|
- `packages/relay` — E2E encrypted relay for remote access
|
||||||
- `packages/desktop` — Tauri desktop wrapper
|
- `packages/desktop` — Electron desktop wrapper
|
||||||
- `packages/website` — Marketing site (paseo.sh)
|
- `packages/website` — Marketing site (paseo.sh)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
@@ -45,10 +45,14 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||||
- **Always run typecheck after every change.**
|
- **Always run typecheck after every change.**
|
||||||
|
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||||
|
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||||
|
- Never change a field from optional to required.
|
||||||
|
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
||||||
|
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
||||||
|
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||||
|
|
||||||
## Orchestrator mode
|
## Debugging
|
||||||
|
|
||||||
- Prefix agent titles with "🎭" (e.g., "🎭 Feature Implementation")
|
|
||||||
- Launch agents in the most permissive mode
|
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||||
- Set cwd to the repository root
|
|
||||||
- When agent control tool calls fail, list agents first — it may be a wait timeout
|
|
||||||
|
|||||||
@@ -4,39 +4,107 @@
|
|||||||
|
|
||||||
<h1 align="center">Paseo</h1>
|
<h1 align="center">Paseo</h1>
|
||||||
|
|
||||||
<p align="center">Manage coding agents from your phone and desktop.</p>
|
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> [!WARNING]
|
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||||
> **Early development** — Features may break or change without notice. Use at your own risk.
|
|
||||||
|
|
||||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||||
|
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||||
|
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||||
|
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||||
|
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
You need at least one agent CLI installed and configured with your credentials:
|
||||||
|
|
||||||
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||||
|
- [Codex](https://github.com/openai/codex)
|
||||||
|
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||||
|
|
||||||
|
### Desktop app (recommended)
|
||||||
|
|
||||||
|
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
|
||||||
|
|
||||||
|
To connect from your phone, scan the QR code shown in Settings.
|
||||||
|
|
||||||
|
### CLI / headless
|
||||||
|
|
||||||
|
Install the CLI and start Paseo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @getpaseo/cli
|
npm install -g @getpaseo/cli
|
||||||
paseo
|
paseo
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the app and connect to your daemon.
|
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
|
||||||
|
|
||||||
For full setup and configuration, see:
|
For full setup and configuration, see:
|
||||||
- [Docs](https://paseo.sh/docs)
|
- [Docs](https://paseo.sh/docs)
|
||||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
Everything you can do in the app, you can do from the terminal.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||||
|
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||||
|
|
||||||
|
paseo ls # list running agents
|
||||||
|
paseo attach abc123 # stream live output
|
||||||
|
paseo send abc123 "also add tests" # follow-up task
|
||||||
|
|
||||||
|
# run on a remote daemon
|
||||||
|
paseo --host workstation.local:6767 run "run the full test suite"
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||||
|
|
||||||
|
## Orchestration skills (Unstable)
|
||||||
|
|
||||||
|
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add getpaseo/paseo
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use them in any agent conversation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use handoff when you discuss something with an agent but want another one to implement.
|
||||||
|
# I use this to plan with Claude and then handoff to Codex to implement.
|
||||||
|
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
|
||||||
|
|
||||||
|
# Use loops when you have clear acceptance criteria (aka Ralph loops).
|
||||||
|
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
|
||||||
|
|
||||||
|
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
|
||||||
|
# Very opinionated and expects both Codex and Claude to work.
|
||||||
|
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Quick monorepo package map:
|
Quick monorepo package map:
|
||||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||||
- `packages/app`: Expo client (iOS, Android, web)
|
- `packages/app`: Expo client (iOS, Android, web)
|
||||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||||
- `packages/desktop`: Tauri desktop app
|
- `packages/desktop`: Electron desktop app
|
||||||
- `packages/relay`: Relay package for remote connectivity
|
- `packages/relay`: Relay package for remote connectivity
|
||||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||||
|
|
||||||
@@ -49,12 +117,16 @@ npm run dev
|
|||||||
# run individual surfaces
|
# run individual surfaces
|
||||||
npm run dev:server
|
npm run dev:server
|
||||||
npm run dev:app
|
npm run dev:app
|
||||||
|
npm run dev:desktop
|
||||||
npm run dev:website
|
npm run dev:website
|
||||||
|
|
||||||
|
# build the daemon
|
||||||
|
npm run build:daemon
|
||||||
|
|
||||||
# repo-wide checks
|
# repo-wide checks
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
AGPL-3.0
|
||||||
|
|||||||
+17
-5
@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
|||||||
1. The daemon generates a persistent ECDH keypair and stores it locally
|
1. The daemon generates a persistent ECDH keypair and stores it locally
|
||||||
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
||||||
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
||||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
|
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
|
||||||
|
|
||||||
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||||
|
|
||||||
@@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
|
|||||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||||
|
|
||||||
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
||||||
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
|
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||||
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
|
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||||
- **Replay old messages** — Each session derives fresh encryption keys
|
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||||
|
|
||||||
### Trust model
|
### Trust model
|
||||||
|
|
||||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||||
|
|
||||||
|
## Local daemon trust boundary
|
||||||
|
|
||||||
|
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
|
||||||
|
|
||||||
|
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||||
|
|
||||||
|
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
|
||||||
|
|
||||||
|
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||||
|
|
||||||
|
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
|
||||||
|
|
||||||
## DNS rebinding protection
|
## DNS rebinding protection
|
||||||
|
|
||||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||||
@@ -51,4 +63,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
|
|||||||
|
|
||||||
## Reporting vulnerabilities
|
## Reporting vulnerabilities
|
||||||
|
|
||||||
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
|
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": ["**", "!*.lock"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"trailingCommas": "all",
|
||||||
|
"semicolons": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"cssModules": true,
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -20,7 +20,7 @@ From repo root:
|
|||||||
```bash
|
```bash
|
||||||
npm run android:development # Debug build
|
npm run android:development # Debug build
|
||||||
npm run android:production # Release build
|
npm run android:production # Release build
|
||||||
npm run android:clean # Clean native project
|
npm run android:clear # Remove generated Android project
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from `packages/app`:
|
Or from `packages/app`:
|
||||||
@@ -34,8 +34,8 @@ APP_VARIANT=development npx expo run:android --variant=debug
|
|||||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||||
APP_VARIANT=production npx expo run:android --variant=release
|
APP_VARIANT=production npx expo run:android --variant=release
|
||||||
|
|
||||||
# Clean
|
# Clear generated Android project
|
||||||
npx expo prebuild --platform android --clean --non-interactive
|
rm -rf android
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -46,11 +46,13 @@ adb exec-out screencap -p > screenshot.png
|
|||||||
|
|
||||||
## Cloud build + submit (EAS)
|
## Cloud build + submit (EAS)
|
||||||
|
|
||||||
Tag pushes like `v0.1.0` trigger:
|
Stable tag pushes like `v0.1.0` trigger:
|
||||||
|
|
||||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||||
|
|
||||||
|
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||||
|
|
||||||
### Useful commands
|
### Useful commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+12
-4
@@ -9,7 +9,7 @@ Your code never leaves your machine. Paseo is local-first.
|
|||||||
```
|
```
|
||||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
│ Mobile App │ │ CLI │ │ Desktop App │
|
│ Mobile App │ │ CLI │ │ Desktop App │
|
||||||
│ (Expo) │ │ (Commander) │ │ (Tauri) │
|
│ (Expo) │ │ (Commander) │ │ (Electron) │
|
||||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
│ WebSocket │ WebSocket │ Managed subprocess
|
│ WebSocket │ WebSocket │ Managed subprocess
|
||||||
@@ -31,6 +31,14 @@ Your code never leaves your machine. Paseo is local-first.
|
|||||||
└───────────┘ └────────┘ └──────────┘
|
└───────────┘ └────────┘ └──────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Components at a glance
|
||||||
|
|
||||||
|
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
|
||||||
|
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
|
||||||
|
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
|
||||||
|
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
|
||||||
|
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
### `packages/server` — The daemon
|
### `packages/server` — The daemon
|
||||||
@@ -90,9 +98,9 @@ Enables remote access when the daemon is behind a firewall.
|
|||||||
|
|
||||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||||
|
|
||||||
### `packages/desktop` — Desktop app (Tauri)
|
### `packages/desktop` — Desktop app (Electron)
|
||||||
|
|
||||||
Tauri wrapper for macOS, Linux, and Windows.
|
Electron wrapper for macOS, Linux, and Windows.
|
||||||
|
|
||||||
- Can spawn the daemon as a managed subprocess
|
- Can spawn the daemon as a managed subprocess
|
||||||
- Native file access for workspace integration
|
- Native file access for workspace integration
|
||||||
@@ -180,5 +188,5 @@ $PASEO_HOME/
|
|||||||
## Deployment models
|
## Deployment models
|
||||||
|
|
||||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||||
2. **Managed desktop**: Tauri app spawns daemon as subprocess
|
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# File Icons
|
||||||
|
|
||||||
|
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
|
||||||
|
|
||||||
|
Icons are inlined as SVG strings in:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/app/src/components/material-file-icons.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is auto-generated. Do not edit it by hand.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
|
||||||
|
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
|
||||||
|
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
|
||||||
|
|
||||||
|
## Adding a new icon
|
||||||
|
|
||||||
|
1. Find the icon name in the material-icon-theme manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "
|
||||||
|
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
|
||||||
|
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
|
||||||
|
console.log('languageIds:', m.languageIds['YOUR_LANG']);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify the SVG exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add two things to `material-file-icons.ts`:
|
||||||
|
|
||||||
|
- The SVG string in `SVG_ICONS`:
|
||||||
|
```ts
|
||||||
|
"icon_name": `<svg ...>...</svg>`,
|
||||||
|
```
|
||||||
|
|
||||||
|
- The extension mapping in `EXTENSION_TO_ICON`:
|
||||||
|
```ts
|
||||||
|
"ext": "icon_name",
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run `npm run typecheck` to verify.
|
||||||
|
|
||||||
|
## Currently included icons
|
||||||
|
|
||||||
|
53 unique icons covering these extensions:
|
||||||
|
|
||||||
|
| Extension(s) | Icon |
|
||||||
|
|---|---|
|
||||||
|
| `ts` | typescript |
|
||||||
|
| `tsx` | react_ts |
|
||||||
|
| `js` | javascript |
|
||||||
|
| `jsx` | react |
|
||||||
|
| `py` | python |
|
||||||
|
| `go` | go |
|
||||||
|
| `rs` | rust |
|
||||||
|
| `rb` | ruby |
|
||||||
|
| `java` | java |
|
||||||
|
| `kt` | kotlin |
|
||||||
|
| `c` | c |
|
||||||
|
| `cpp` | cpp |
|
||||||
|
| `h` | h |
|
||||||
|
| `hpp` | hpp |
|
||||||
|
| `cs` | csharp |
|
||||||
|
| `swift` | swift |
|
||||||
|
| `dart` | dart |
|
||||||
|
| `ex`, `exs` | elixir |
|
||||||
|
| `erl` | erlang |
|
||||||
|
| `hs` | haskell |
|
||||||
|
| `clj` | clojure |
|
||||||
|
| `scala` | scala |
|
||||||
|
| `ml` | ocaml |
|
||||||
|
| `r` | r |
|
||||||
|
| `lua` | lua |
|
||||||
|
| `zig` | zig |
|
||||||
|
| `nix` | nix |
|
||||||
|
| `php` | php |
|
||||||
|
| `html` | html |
|
||||||
|
| `css` | css |
|
||||||
|
| `scss` | sass |
|
||||||
|
| `less` | less |
|
||||||
|
| `json` | json |
|
||||||
|
| `yml`, `yaml` | yaml |
|
||||||
|
| `xml` | xml |
|
||||||
|
| `toml` | toml |
|
||||||
|
| `md`, `markdown` | markdown |
|
||||||
|
| `sql` | database |
|
||||||
|
| `graphql`, `gql` | graphql |
|
||||||
|
| `sh`, `bash` | console |
|
||||||
|
| `tf` | terraform |
|
||||||
|
| `hcl` | hcl |
|
||||||
|
| `vue` | vue |
|
||||||
|
| `svelte` | svelte |
|
||||||
|
| `astro` | astro |
|
||||||
|
| `wasm` | webassembly |
|
||||||
|
| `svg` | svg |
|
||||||
|
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
|
||||||
|
| `txt` | document |
|
||||||
|
| `conf`, `cfg`, `ini` | settings |
|
||||||
|
| `lock` | lock |
|
||||||
|
| `groovy` | groovy |
|
||||||
|
| `gradle` | gradle |
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Mobile Testing
|
||||||
|
|
||||||
|
## Maestro
|
||||||
|
|
||||||
|
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
|
||||||
|
|
||||||
|
Run a flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
maestro test packages/app/maestro/my-flow.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
|
||||||
|
mkdir -p /tmp/maestro-out
|
||||||
|
cd /tmp/maestro-out && maestro test "$FLOW"
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
|
||||||
|
|
||||||
|
### Element targeting
|
||||||
|
|
||||||
|
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Component
|
||||||
|
<Pressable testID="sidebar-sessions" onPress={onPress}>
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Flow
|
||||||
|
- tapOn:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional steps
|
||||||
|
|
||||||
|
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
|
||||||
|
|
||||||
|
### Don't use launchApp against a running dev app
|
||||||
|
|
||||||
|
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
|
||||||
|
|
||||||
|
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
|
||||||
|
|
||||||
|
### Swipe gestures
|
||||||
|
|
||||||
|
Use `start`/`end` with percentage coordinates for precise control:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Edge swipe from left to open sidebar
|
||||||
|
- swipe:
|
||||||
|
start: "5%,50%"
|
||||||
|
end: "80%,50%"
|
||||||
|
duration: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
|
||||||
|
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
|
||||||
|
|
||||||
|
For async elements, use `extendedWaitUntil`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Online.*"
|
||||||
|
timeout: 90000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev client handling
|
||||||
|
|
||||||
|
Two reusable flows handle Expo dev client screens after launch:
|
||||||
|
|
||||||
|
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
||||||
|
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
||||||
|
|
||||||
|
## Self-verification loops
|
||||||
|
|
||||||
|
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
||||||
|
|
||||||
|
This pattern also lets agents self-verify fixes without manual user testing.
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
1. Run baseline Maestro flow (confirm feature works)
|
||||||
|
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
|
||||||
|
3. Re-run Maestro flow (confirm feature still works)
|
||||||
|
4. Repeat N iterations to catch intermittent failures
|
||||||
|
|
||||||
|
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
|
||||||
|
|
||||||
|
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||||
|
# Args: iterations=6, wait_seconds=1 between toggle and test
|
||||||
|
```
|
||||||
|
|
||||||
|
Key elements of the script pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -euo pipefail
|
||||||
|
ITERATIONS="${1:-3}"
|
||||||
|
|
||||||
|
for i in $(seq 1 "$ITERATIONS"); do
|
||||||
|
# Toggle system state
|
||||||
|
xcrun simctl ui booted appearance light
|
||||||
|
|
||||||
|
# Wait for change to propagate
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Run Maestro flow and capture result
|
||||||
|
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||||
|
echo "PASS"
|
||||||
|
else
|
||||||
|
echo "FAIL"
|
||||||
|
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unistyles + Reanimated
|
||||||
|
|
||||||
|
### The crash
|
||||||
|
|
||||||
|
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
|
||||||
|
|
||||||
|
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
|
||||||
|
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BAD: Unistyles dynamic style on Animated.View
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
sidebar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
<Animated.View style={[styles.sidebar, animatedStyle]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// GOOD: static stylesheet + inline theme values
|
||||||
|
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||||
|
|
||||||
|
const staticStyles = RNStyleSheet.create({
|
||||||
|
sidebar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||||
|
|
||||||
|
## iOS Simulator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Screenshot
|
||||||
|
xcrun simctl io booted screenshot /tmp/screenshot.png
|
||||||
|
|
||||||
|
# Dark/light mode
|
||||||
|
xcrun simctl ui booted appearance # check current
|
||||||
|
xcrun simctl ui booted appearance dark # set dark
|
||||||
|
xcrun simctl ui booted appearance light # set light
|
||||||
|
```
|
||||||
|
|
||||||
|
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
What Paseo is, who it's for, and where it's going.
|
||||||
|
|
||||||
|
## What is Paseo
|
||||||
|
|
||||||
|
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||||
|
|
||||||
|
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||||
|
|
||||||
|
## Core philosophy
|
||||||
|
|
||||||
|
Freedom and flexibility. Every design decision follows from this:
|
||||||
|
|
||||||
|
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
|
||||||
|
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||||
|
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||||
|
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||||
|
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
|
||||||
|
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### Projects and workspaces
|
||||||
|
|
||||||
|
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||||
|
|
||||||
|
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||||
|
|
||||||
|
### Inside a workspace
|
||||||
|
|
||||||
|
A workspace is a flexible canvas:
|
||||||
|
- Launch multiple agents side by side in split panes
|
||||||
|
- Open terminals alongside agents
|
||||||
|
- Mix and match providers within the same workspace
|
||||||
|
|
||||||
|
### The daemon
|
||||||
|
|
||||||
|
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||||
|
|
||||||
|
This architecture means:
|
||||||
|
- The daemon can run on any machine: laptop, VM, remote server
|
||||||
|
- Multiple clients can connect simultaneously
|
||||||
|
- Agents keep running when you close the app
|
||||||
|
|
||||||
|
## Target user
|
||||||
|
|
||||||
|
Anyone who builds software:
|
||||||
|
|
||||||
|
- Care about owning their tools and their data
|
||||||
|
- Use multiple AI providers and want to switch freely
|
||||||
|
- Run agents on real tasks across real projects
|
||||||
|
- Want to work from multiple devices
|
||||||
|
|
||||||
|
## What compounds over time
|
||||||
|
|
||||||
|
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||||
|
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||||
|
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||||
|
|
||||||
|
## Strategic bets
|
||||||
|
|
||||||
|
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||||
|
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||||
|
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||||
|
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||||
|
|
||||||
|
## Current state (March 2026)
|
||||||
|
|
||||||
|
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||||
|
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
|
||||||
|
- Daily releases
|
||||||
|
- Community contributions starting (packaging, bug fixes)
|
||||||
|
- Key UX: split panes, keybinding customization, workspace model
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# Adding a New Provider to Paseo
|
||||||
|
|
||||||
|
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||||
|
|
||||||
|
## Two Integration Patterns
|
||||||
|
|
||||||
|
### ACP (Agent Client Protocol) -- recommended
|
||||||
|
|
||||||
|
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||||
|
|
||||||
|
Existing ACP providers: `claude-acp`, `copilot`.
|
||||||
|
|
||||||
|
### Direct
|
||||||
|
|
||||||
|
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||||
|
|
||||||
|
Existing direct providers: `claude`, `codex`, `opencode`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACP Provider Checklist
|
||||||
|
|
||||||
|
### 1. Create the provider class
|
||||||
|
|
||||||
|
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
|
||||||
|
|
||||||
|
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||||
|
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
|
import { ACPAgentClient } from "./acp-agent.js";
|
||||||
|
|
||||||
|
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MY_PROVIDER_MODES: AgentMode[] = [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Standard agent mode",
|
||||||
|
},
|
||||||
|
// Add more modes as needed
|
||||||
|
];
|
||||||
|
|
||||||
|
type MyProviderClientOptions = {
|
||||||
|
logger: Logger;
|
||||||
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MyProviderACPAgentClient extends ACPAgentClient {
|
||||||
|
constructor(options: MyProviderClientOptions) {
|
||||||
|
super({
|
||||||
|
provider: "my-provider", // Must match the ID used everywhere else
|
||||||
|
logger: options.logger,
|
||||||
|
runtimeSettings: options.runtimeSettings,
|
||||||
|
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
|
||||||
|
defaultModes: MY_PROVIDER_MODES,
|
||||||
|
capabilities: MY_PROVIDER_CAPABILITIES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override isAvailable() if the provider needs specific auth/env vars
|
||||||
|
override async isAvailable(): Promise<boolean> {
|
||||||
|
if (!(await super.isAvailable())) {
|
||||||
|
return false; // Binary not found
|
||||||
|
}
|
||||||
|
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
|
||||||
|
|
||||||
|
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class CopilotACPAgentClient extends ACPAgentClient {
|
||||||
|
constructor(options: CopilotACPAgentClientOptions) {
|
||||||
|
super({
|
||||||
|
provider: "copilot",
|
||||||
|
logger: options.logger,
|
||||||
|
runtimeSettings: options.runtimeSettings,
|
||||||
|
defaultCommand: ["copilot", "--acp"],
|
||||||
|
defaultModes: COPILOT_MODES,
|
||||||
|
capabilities: COPILOT_CAPABILITIES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override async isAvailable(): Promise<boolean> {
|
||||||
|
return super.isAvailable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add to the provider manifest
|
||||||
|
|
||||||
|
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||||
|
|
||||||
|
First, define the modes with visual metadata:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Standard agent mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "autonomous",
|
||||||
|
label: "Autonomous",
|
||||||
|
description: "Runs without prompting",
|
||||||
|
icon: "ShieldOff",
|
||||||
|
colorTier: "dangerous",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
|
||||||
|
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
|
||||||
|
|
||||||
|
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||||
|
// ... existing providers ...
|
||||||
|
{
|
||||||
|
id: "my-provider",
|
||||||
|
label: "My Provider",
|
||||||
|
description: "Short description of the provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: MY_PROVIDER_MODES,
|
||||||
|
// Optional: enable voice
|
||||||
|
voice: {
|
||||||
|
enabled: true,
|
||||||
|
defaultModeId: "default",
|
||||||
|
defaultModel: "some-model",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add the factory to the provider registry
|
||||||
|
|
||||||
|
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
||||||
|
|
||||||
|
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||||
|
// ... existing factories ...
|
||||||
|
"my-provider": (logger, runtimeSettings) =>
|
||||||
|
new MyProviderACPAgentClient({
|
||||||
|
logger,
|
||||||
|
runtimeSettings: runtimeSettings?.["my-provider"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add a provider icon (app)
|
||||||
|
|
||||||
|
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Svg, { Path } from "react-native-svg";
|
||||||
|
|
||||||
|
interface MyProviderIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||||
|
<Path d="..." />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then register it in `packages/app/src/components/provider-icons.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
|
||||||
|
|
||||||
|
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||||
|
claude: ClaudeIcon as unknown as typeof Bot,
|
||||||
|
codex: CodexIcon as unknown as typeof Bot,
|
||||||
|
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
||||||
|
|
||||||
|
### 5. Add E2E test config
|
||||||
|
|
||||||
|
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const agentConfigs = {
|
||||||
|
// ... existing configs ...
|
||||||
|
"my-provider": {
|
||||||
|
provider: "my-provider",
|
||||||
|
model: "default-model-id",
|
||||||
|
modes: {
|
||||||
|
full: "autonomous", // Mode with no permission prompts
|
||||||
|
ask: "default", // Mode that requires permission approval
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, AgentTestConfig>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add an availability check in `isProviderAvailable()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
case "my-provider":
|
||||||
|
return (
|
||||||
|
isCommandAvailable("my-agent-binary") &&
|
||||||
|
Boolean(process.env.MY_PROVIDER_API_KEY)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the `allProviders` array:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const allProviders: AgentProvider[] = [
|
||||||
|
"claude",
|
||||||
|
"claude-acp",
|
||||||
|
"codex",
|
||||||
|
"copilot",
|
||||||
|
"opencode",
|
||||||
|
"my-provider",
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Run typecheck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
This is required after every change per project rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direct Provider Checklist
|
||||||
|
|
||||||
|
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
|
||||||
|
|
||||||
|
### Interfaces to implement
|
||||||
|
|
||||||
|
**`AgentClient`** -- factory for sessions and model listing:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AgentClient {
|
||||||
|
readonly provider: AgentProvider;
|
||||||
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
|
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
||||||
|
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
||||||
|
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
// Optional:
|
||||||
|
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`AgentSession`** -- a running agent conversation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AgentSession {
|
||||||
|
readonly provider: AgentProvider;
|
||||||
|
readonly id: string | null;
|
||||||
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
|
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||||
|
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||||
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||||
|
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||||
|
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||||
|
getAvailableModes(): Promise<AgentMode[]>;
|
||||||
|
getCurrentMode(): Promise<string | null>;
|
||||||
|
setMode(modeId: string): Promise<void>;
|
||||||
|
getPendingPermissions(): AgentPermissionRequest[];
|
||||||
|
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||||
|
describePersistence(): AgentPersistenceHandle | null;
|
||||||
|
interrupt(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
// Optional:
|
||||||
|
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||||
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
|
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
|
||||||
|
2. Add to the provider manifest (same as ACP step 2 above)
|
||||||
|
3. Add factory to the registry (same as ACP step 3 above)
|
||||||
|
4. Add icon (same as ACP step 4 above)
|
||||||
|
5. Add E2E config (same as ACP step 5 above)
|
||||||
|
6. Run typecheck
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual testing with the CLI
|
||||||
|
|
||||||
|
Start the daemon if not already running, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch an agent with your provider
|
||||||
|
paseo run --provider my-provider
|
||||||
|
|
||||||
|
# Launch with a specific model and mode
|
||||||
|
paseo run --provider my-provider --model some-model --mode default
|
||||||
|
|
||||||
|
# List running agents
|
||||||
|
paseo ls -a -g
|
||||||
|
|
||||||
|
# Check if the provider reports models
|
||||||
|
paseo models --provider my-provider
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E test patterns
|
||||||
|
|
||||||
|
The E2E configs in `agent-configs.ts` expose two helpers:
|
||||||
|
|
||||||
|
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
|
||||||
|
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
|
||||||
|
|
||||||
|
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
|
||||||
|
|
||||||
|
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
|
||||||
|
|
||||||
|
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
|
||||||
|
|
||||||
|
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
|
||||||
|
|
||||||
|
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
|
||||||
|
|
||||||
|
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
|
||||||
|
|
||||||
|
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
|
||||||
+138
-9
@@ -2,8 +2,21 @@
|
|||||||
|
|
||||||
All workspaces share one version and release together.
|
All workspaces share one version and release together.
|
||||||
|
|
||||||
|
## Two paths
|
||||||
|
|
||||||
|
There are two supported ways to ship from `main`:
|
||||||
|
|
||||||
|
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||||
|
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||||
|
|
||||||
## Standard release (patch)
|
## Standard release (patch)
|
||||||
|
|
||||||
|
Before running any stable patch release command:
|
||||||
|
|
||||||
|
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||||
|
- Make sure local `npm run typecheck` passes on that commit.
|
||||||
|
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run release:patch
|
npm run release:patch
|
||||||
```
|
```
|
||||||
@@ -12,37 +25,153 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
|
|||||||
|
|
||||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||||
|
|
||||||
|
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
|
||||||
|
|
||||||
## Manual step-by-step
|
## Manual step-by-step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run typecheck # Verify the exact commit you intend to release
|
||||||
|
npm run release:check # Typecheck, build, dry-run pack
|
||||||
npm run version:all:patch # Bump version, create commit + tag
|
npm run version:all:patch # Bump version, create commit + tag
|
||||||
npm run release:check # Validate release
|
|
||||||
npm run release:publish # Publish to npm
|
npm run release:publish # Publish to npm
|
||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Draft release flow
|
## Release candidate flow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
||||||
npm run release:finalize # Publish npm, promote draft to published
|
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||||
|
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
||||||
|
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
||||||
- `release:finalize` publishes npm and promotes the same draft release
|
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||||
- Use the same semver tag for both; don't cut a second tag
|
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
||||||
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
|
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
||||||
|
|
||||||
|
Use the RC path when you need to:
|
||||||
|
|
||||||
|
- test a build manually in a Linux or Windows VM
|
||||||
|
- send a build to a user who is hitting a specific problem
|
||||||
|
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
||||||
|
|
||||||
|
## Website behavior
|
||||||
|
|
||||||
|
- The website download page points to GitHub's latest published **stable** release.
|
||||||
|
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||||
|
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||||
|
|
||||||
|
## Fixing a failed release build
|
||||||
|
|
||||||
|
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||||
|
|
||||||
|
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
||||||
|
|
||||||
|
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
||||||
|
|
||||||
|
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
||||||
|
|
||||||
|
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desktop (all platforms)
|
||||||
|
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
|
||||||
|
|
||||||
|
# Desktop (single platform)
|
||||||
|
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
|
||||||
|
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
|
||||||
|
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
|
||||||
|
|
||||||
|
# Android APK
|
||||||
|
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||||
|
|
||||||
|
# RC
|
||||||
|
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||||
|
|
||||||
|
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
||||||
|
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||||
|
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||||
|
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
|
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
||||||
|
|
||||||
|
## Changelog format
|
||||||
|
|
||||||
|
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||||
|
|
||||||
|
```
|
||||||
|
## X.Y.Z - YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||||
|
|
||||||
|
## Changelog policy
|
||||||
|
|
||||||
|
- `CHANGELOG.md` is for **final stable releases only**.
|
||||||
|
- Do not add or edit changelog entries while iterating on RCs.
|
||||||
|
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
||||||
|
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
||||||
|
|
||||||
|
## Changelog ownership
|
||||||
|
|
||||||
|
- **Only Claude should write changelog entries.**
|
||||||
|
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||||
|
|
||||||
|
## Changelog voice
|
||||||
|
|
||||||
|
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||||
|
|
||||||
|
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||||
|
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
||||||
|
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||||
|
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||||
|
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||||
|
|
||||||
|
## Pre-release sanity check
|
||||||
|
|
||||||
|
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||||
|
|
||||||
|
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||||
|
|
||||||
|
> Review the diff between the latest release tag and HEAD. Focus on:
|
||||||
|
>
|
||||||
|
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||||
|
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||||
|
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||||
|
>
|
||||||
|
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||||
|
|
||||||
|
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
|
||||||
|
|
||||||
|
## Changelog scope
|
||||||
|
|
||||||
|
The changelog always covers **stable-to-HEAD**:
|
||||||
|
|
||||||
|
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
||||||
|
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
||||||
|
|
||||||
|
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
|
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||||
|
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||||
|
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||||
- [ ] `npm run release:patch` completes successfully
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
|
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Plan Approval Normalization
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
|
||||||
|
|
||||||
|
## Compatibility Constraints
|
||||||
|
|
||||||
|
- Older clients must remain compatible with newer daemons.
|
||||||
|
- All new wire fields must be optional.
|
||||||
|
- Existing plan permissions without action metadata must still render and work.
|
||||||
|
- Existing question permissions must keep their current behavior.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Shared abstraction
|
||||||
|
|
||||||
|
Add optional permission action definitions to the shared permission request/response types.
|
||||||
|
|
||||||
|
- Permission requests may include `actions`.
|
||||||
|
- Permission responses may include `selectedActionId`.
|
||||||
|
- `kind: "plan"` remains the normalized concept for plan approval.
|
||||||
|
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
|
||||||
|
|
||||||
|
### Claude
|
||||||
|
|
||||||
|
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
|
||||||
|
|
||||||
|
- Always expose `Reject`.
|
||||||
|
- Always expose `Implement`.
|
||||||
|
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
|
||||||
|
- Resolve the selected action entirely inside `respondToPermission()`.
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
|
||||||
|
|
||||||
|
- Emit a plan permission with `Reject` and `Implement` actions.
|
||||||
|
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
|
||||||
|
- On `Reject`, resolve without starting a follow-up turn.
|
||||||
|
- Keep the implementation prompt and state transitions inside the Codex provider.
|
||||||
|
|
||||||
|
### Manager and state sync
|
||||||
|
|
||||||
|
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
|
||||||
|
|
||||||
|
- Refresh current mode
|
||||||
|
- Refresh pending permissions
|
||||||
|
- Refresh runtime info
|
||||||
|
- Refresh features
|
||||||
|
- Persist refreshed state
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
|
||||||
|
|
||||||
|
- If `actions` are absent, fall back to legacy buttons.
|
||||||
|
- Plan cards should use `Implement` as the default primary label.
|
||||||
|
- Do not add provider-specific rendering branches.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Shared schema/type tests for optional `actions` and `selectedActionId`
|
||||||
|
2. App tests for generic plan-action rendering
|
||||||
|
3. Claude tests for third action when resuming from a more permissive mode
|
||||||
|
4. Codex tests for synthetic plan approval and automatic implementation follow-up
|
||||||
|
5. Manager tests for post-permission state refresh
|
||||||
|
6. `npm run typecheck`
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1772963539,
|
||||||
|
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
supportedSystems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||||
|
pkgsFor = system: import nixpkgs { inherit system; };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = paseo;
|
||||||
|
paseo = paseo;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
nixosModules.default = self.nixosModules.paseo;
|
||||||
|
nixosModules.paseo =
|
||||||
|
{ pkgs, lib, ... }:
|
||||||
|
{
|
||||||
|
imports = [ ./nix/module.nix ];
|
||||||
|
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.nodejs_22
|
||||||
|
pkgs.python3
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.paseo;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.paseo = {
|
||||||
|
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
|
package = lib.mkPackageOption pkgs "paseo" { };
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "paseo";
|
||||||
|
description = "User account under which Paseo runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "paseo";
|
||||||
|
description = "Group under which Paseo runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default =
|
||||||
|
if cfg.user == "paseo"
|
||||||
|
then "/var/lib/paseo"
|
||||||
|
else "/home/${cfg.user}/.paseo";
|
||||||
|
defaultText = lib.literalExpression ''
|
||||||
|
if cfg.user == "paseo"
|
||||||
|
then "/var/lib/paseo"
|
||||||
|
else "/home/''${cfg.user}/.paseo"
|
||||||
|
'';
|
||||||
|
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 6767;
|
||||||
|
description = "Port for the Paseo daemon to listen on.";
|
||||||
|
};
|
||||||
|
|
||||||
|
listenAddress = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address for the Paseo daemon to bind to.";
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowedHosts = lib.mkOption {
|
||||||
|
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||||
|
default = [ ];
|
||||||
|
example = [ ".example.com" "myhost.local" ];
|
||||||
|
description = ''
|
||||||
|
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||||
|
Localhost and IP addresses are always allowed by default.
|
||||||
|
|
||||||
|
Use a leading dot to match a domain and all its subdomains
|
||||||
|
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
|
||||||
|
|
||||||
|
Set to `true` to allow any host (not recommended).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
relay = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
inheritUserEnvironment = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = cfg.user != "paseo";
|
||||||
|
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
|
||||||
|
description = ''
|
||||||
|
Whether to include the user's profile PATH in the service environment.
|
||||||
|
|
||||||
|
When Paseo runs as a real user (not the default system user), AI agents
|
||||||
|
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||||
|
NixOS profile and system paths so agents can use them without manually
|
||||||
|
setting PATH.
|
||||||
|
|
||||||
|
Enabled by default when `user` is set to a non-default value.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
environment = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
{
|
||||||
|
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
description = "Extra environment variables for the Paseo daemon.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
home = cfg.dataDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.paseo = {
|
||||||
|
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
NODE_ENV = "production";
|
||||||
|
PASEO_HOME = cfg.dataDir;
|
||||||
|
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||||
|
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||||
|
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||||
|
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||||
|
# includes /run/current-system/sw/bin which is a superset of those.
|
||||||
|
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||||
|
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||||
|
"/run/current-system/sw/bin"
|
||||||
|
"/run/wrappers/bin"
|
||||||
|
"/nix/var/nix/profiles/default/bin"
|
||||||
|
]);
|
||||||
|
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||||
|
PASEO_ALLOWED_HOSTS = "true";
|
||||||
|
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||||
|
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||||
|
} // cfg.environment;
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
|
||||||
|
ExecStart =
|
||||||
|
"${cfg.package}/bin/paseo-server"
|
||||||
|
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
|
||||||
|
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
|
||||||
|
KillSignal = "SIGTERM";
|
||||||
|
TimeoutStopSec = 15;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.systemPackages = [ cfg.package ];
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||||
|
};
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
stdenv,
|
||||||
|
buildNpmPackage,
|
||||||
|
nodejs_22,
|
||||||
|
python3,
|
||||||
|
makeWrapper,
|
||||||
|
# node-pty needs libuv headers on Linux
|
||||||
|
libuv,
|
||||||
|
}:
|
||||||
|
|
||||||
|
buildNpmPackage rec {
|
||||||
|
pname = "paseo";
|
||||||
|
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||||
|
|
||||||
|
src = lib.cleanSourceWith {
|
||||||
|
src = ./..;
|
||||||
|
filter = path: type:
|
||||||
|
let
|
||||||
|
baseName = builtins.baseNameOf path;
|
||||||
|
relPath = lib.removePrefix (toString ./..) path;
|
||||||
|
in
|
||||||
|
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
|
||||||
|
!(lib.hasPrefix "/packages/app/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/assets" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/android" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/website/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/website/public" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
|
||||||
|
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
|
||||||
|
# Exclude test fixtures and debug files
|
||||||
|
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||||
|
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||||
|
&& baseName != "node_modules"
|
||||||
|
&& baseName != ".git"
|
||||||
|
&& baseName != ".paseo"
|
||||||
|
&& baseName != ".DS_Store";
|
||||||
|
};
|
||||||
|
|
||||||
|
nodejs = nodejs_22;
|
||||||
|
|
||||||
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
|
npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
|
||||||
|
|
||||||
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
# We manually rebuild only node-pty in buildPhase.
|
||||||
|
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
python3 # for node-gyp (node-pty compilation)
|
||||||
|
makeWrapper
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
|
libuv
|
||||||
|
];
|
||||||
|
|
||||||
|
# Don't use the default npm build hook — we need a custom build sequence
|
||||||
|
dontNpmBuild = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
# Rebuild only node-pty (native addon for terminal emulation).
|
||||||
|
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||||
|
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||||
|
# degrade when unavailable.
|
||||||
|
npm rebuild node-pty
|
||||||
|
|
||||||
|
# Build all daemon packages in dependency order (defined in package.json)
|
||||||
|
npm run build:daemon
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p $out/lib/paseo
|
||||||
|
|
||||||
|
# Copy root package metadata
|
||||||
|
cp package.json $out/lib/paseo/
|
||||||
|
|
||||||
|
# Copy node_modules (preserving workspace symlinks)
|
||||||
|
cp -a node_modules $out/lib/paseo/
|
||||||
|
|
||||||
|
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||||
|
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||||
|
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||||
|
name=$(basename "$link")
|
||||||
|
if [ -d "packages/$name/dist" ]; then
|
||||||
|
mkdir -p "$out/lib/paseo/packages/$name"
|
||||||
|
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||||
|
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||||
|
if [ -d "packages/$name/node_modules" ]; then
|
||||||
|
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Copy CLI bin entry
|
||||||
|
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||||
|
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||||
|
|
||||||
|
# Copy extra server files referenced at runtime
|
||||||
|
for f in agent-prompt.md .env.example; do
|
||||||
|
if [ -f packages/server/$f ]; then
|
||||||
|
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Copy server scripts (including supervisor-entrypoint) needed by CLI
|
||||||
|
if [ -d packages/server/dist/scripts ]; then
|
||||||
|
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||||
|
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create wrapper for the server entry point (for systemd / direct use)
|
||||||
|
mkdir -p $out/bin
|
||||||
|
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||||
|
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||||
|
--set NODE_ENV production
|
||||||
|
|
||||||
|
# Create wrapper for the CLI
|
||||||
|
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
|
||||||
|
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
|
||||||
|
--set NODE_PATH "$out/lib/paseo/node_modules"
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||||
|
homepage = "https://github.com/getpaseo/paseo";
|
||||||
|
license = lib.licenses.agpl3Plus;
|
||||||
|
mainProgram = "paseo";
|
||||||
|
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+7926
-580
File diff suppressed because it is too large
Load Diff
+42
-22
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.26",
|
"version": "0.1.51-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
"packages/expo-two-way-audio",
|
||||||
|
"packages/highlight",
|
||||||
"packages/server",
|
"packages/server",
|
||||||
"packages/app",
|
"packages/app",
|
||||||
"packages/relay",
|
"packages/relay",
|
||||||
@@ -12,22 +14,24 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"build:daemon": "npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||||
|
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"format": "prettier --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "biome format .",
|
||||||
"start": "npm run start --workspace=@getpaseo/server",
|
"start": "npm run start --workspace=@getpaseo/server",
|
||||||
"android": "npm run android --workspace=@getpaseo/app",
|
"android": "npm run android --workspace=@getpaseo/app",
|
||||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||||
|
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||||
"web": "npm run web --workspace=@getpaseo/app",
|
"web": "npm run web --workspace=@getpaseo/app",
|
||||||
@@ -37,31 +41,39 @@
|
|||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
||||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
||||||
|
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
||||||
|
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||||
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||||
|
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||||
|
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
||||||
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
|
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
||||||
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
|
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
||||||
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
|
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
||||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||||
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
|
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||||
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
|
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.8",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"prettier": "^3.5.3",
|
|
||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
|
"react": "19.1.4",
|
||||||
|
"react-dom": "19.1.4",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||||
|
"homepage": "https://paseo.sh",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"openai",
|
"openai",
|
||||||
"realtime",
|
"realtime",
|
||||||
@@ -70,7 +82,14 @@
|
|||||||
"development",
|
"development",
|
||||||
"mcp"
|
"mcp"
|
||||||
],
|
],
|
||||||
"author": "moboudra",
|
"author": {
|
||||||
|
"name": "Mohamed Boudra",
|
||||||
|
"email": "hello@moboudra.com"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/getpaseo/paseo.git"
|
||||||
|
},
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.1",
|
||||||
@@ -78,6 +97,7 @@
|
|||||||
"react-dom": "19.1.4"
|
"react-dom": "19.1.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
- "!v*-rc.*"
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ export default {
|
|||||||
ios: {
|
ios: {
|
||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
infoPlist: {
|
infoPlist: {
|
||||||
NSMicrophoneUsageDescription:
|
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
|
||||||
"This app needs access to the microphone for voice commands.",
|
|
||||||
ITSAppUsesNonExemptEncryption: false,
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
},
|
},
|
||||||
bundleIdentifier: variant.packageId,
|
bundleIdentifier: variant.packageId,
|
||||||
@@ -92,14 +91,15 @@ export default {
|
|||||||
"android.permission.CAMERA",
|
"android.permission.CAMERA",
|
||||||
],
|
],
|
||||||
package: variant.packageId,
|
package: variant.packageId,
|
||||||
...(variant.googleServicesFile
|
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||||
? { googleServicesFile: variant.googleServicesFile }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
output: "single",
|
output: "single",
|
||||||
favicon: "./assets/images/favicon.png",
|
favicon: "./assets/images/favicon.png",
|
||||||
},
|
},
|
||||||
|
autolinking: {
|
||||||
|
searchPaths: ["../../node_modules", "./node_modules"],
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
[
|
[
|
||||||
@@ -143,6 +143,7 @@ export default {
|
|||||||
experiments: {
|
experiments: {
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
autolinkingModuleResolution: true,
|
||||||
},
|
},
|
||||||
extra: {
|
extra: {
|
||||||
router: {},
|
router: {},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -1,183 +0,0 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
|
||||||
connectDaemonClient,
|
|
||||||
createReplyTurn,
|
|
||||||
expectDetachedFromBottom,
|
|
||||||
expectNearBottom,
|
|
||||||
getChatContainerKey,
|
|
||||||
readScrollMetrics,
|
|
||||||
scrollUpFromBottom,
|
|
||||||
seedBottomAnchorAgent,
|
|
||||||
waitForAgentReady,
|
|
||||||
waitForContentGrowth,
|
|
||||||
} from "./helpers/agent-bottom-anchor";
|
|
||||||
|
|
||||||
test.describe.configure({ timeout: 180000 });
|
|
||||||
|
|
||||||
async function openWorkspaceAgentTab(page: Page, agentId: string) {
|
|
||||||
const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
|
|
||||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
|
||||||
await tab.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("direct load and refresh land at the bottom for history-backed chats", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-");
|
|
||||||
const client = await connectDaemonClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await seedBottomAnchorAgent({
|
|
||||||
client,
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `bottom-anchor-direct-${Date.now()}`,
|
|
||||||
turnCount: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await page.reload({ waitUntil: "commit" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("revisiting a loaded chat restores bottom anchoring", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-");
|
|
||||||
const client = await connectDaemonClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await seedBottomAnchorAgent({
|
|
||||||
client,
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `bottom-anchor-switch-${Date.now()}`,
|
|
||||||
turnCount: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await page.getByTestId("sidebar-new-agent").first().click();
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-");
|
|
||||||
const client = await connectDaemonClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await seedBottomAnchorAgent({
|
|
||||||
client,
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `bottom-anchor-sticky-${Date.now()}`,
|
|
||||||
turnCount: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1320, height: 920 });
|
|
||||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
|
||||||
await composer.click();
|
|
||||||
for (let index = 0; index < 6; index += 1) {
|
|
||||||
await composer.pressSequentially(`composer growth line ${index + 1}`);
|
|
||||||
if (index < 5) {
|
|
||||||
await page.keyboard.press("Shift+Enter");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await expectNearBottom(page);
|
|
||||||
await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 820, height: 760 });
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await scrollUpFromBottom(page, 720);
|
|
||||||
await expectDetachedFromBottom(page);
|
|
||||||
const beforeExternalUpdate = await readScrollMetrics(page);
|
|
||||||
|
|
||||||
const externalTurn = createReplyTurn(`external-stream-${Date.now()}`);
|
|
||||||
await client.sendAgentMessage(agent.id, externalTurn.message);
|
|
||||||
await waitForContentGrowth(page, beforeExternalUpdate.contentHeight);
|
|
||||||
const finish = await client.waitForFinish(agent.id, 120000);
|
|
||||||
expect(finish.status).toBe("idle");
|
|
||||||
await expectDetachedFromBottom(page);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
(window as typeof window & {
|
|
||||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
|
||||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
|
||||||
}).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
|
||||||
(window as typeof window & {
|
|
||||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
|
||||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
|
||||||
}).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
|
||||||
});
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-");
|
|
||||||
const client = await connectDaemonClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agent = await seedBottomAnchorAgent({
|
|
||||||
client,
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `bottom-anchor-virtualized-${Date.now()}`,
|
|
||||||
turnCount: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expect
|
|
||||||
.poll(async () => await getChatContainerKey(page))
|
|
||||||
.toBe("web-partial-virtualized");
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await page.reload({ waitUntil: "commit" });
|
|
||||||
await openWorkspaceAgentTab(page, agent.id);
|
|
||||||
await waitForAgentReady(page, agent.expectedTailText);
|
|
||||||
await expect
|
|
||||||
.poll(async () => await getChatContainerKey(page))
|
|
||||||
.toBe("web-partial-virtualized");
|
|
||||||
await expectNearBottom(page);
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 780, height: 720 });
|
|
||||||
await expectNearBottom(page);
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
|
|
||||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const prompt = "Respond with exactly: Hello";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await createAgent(page, prompt);
|
|
||||||
|
|
||||||
await page.getByTestId("agent-overflow-menu").click();
|
|
||||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
|
|
||||||
"Not available"
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("agent-details-persistence-session-id-value")
|
|
||||||
).not.toHaveText("Not available", { timeout: 90_000 });
|
|
||||||
|
|
||||||
await page.getByTestId("agent-details-agent-id").click();
|
|
||||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
|
||||||
|
|
||||||
const SERVER_ID =
|
|
||||||
process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI";
|
|
||||||
const AGENT_ID =
|
|
||||||
process.env.PLAYWRIGHT_REPRO_AGENT_ID ??
|
|
||||||
"3533e6c3-c0b3-4310-b85c-eb07cbb501a0";
|
|
||||||
const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081";
|
|
||||||
const DAEMON_ENDPOINT =
|
|
||||||
process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767";
|
|
||||||
const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello";
|
|
||||||
const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`;
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 64;
|
|
||||||
|
|
||||||
type ScrollMetrics = {
|
|
||||||
offsetY: number;
|
|
||||||
contentHeight: number;
|
|
||||||
viewportHeight: number;
|
|
||||||
distanceFromBottom: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
test.use({ browserName: "firefox" });
|
|
||||||
|
|
||||||
function seedDaemonRegistryScript(params: {
|
|
||||||
serverId: string;
|
|
||||||
endpoint: string;
|
|
||||||
nowIso: string;
|
|
||||||
}) {
|
|
||||||
const daemon = {
|
|
||||||
serverId: params.serverId,
|
|
||||||
label: "localhost",
|
|
||||||
connections: [
|
|
||||||
{
|
|
||||||
id: `direct:${params.endpoint}`,
|
|
||||||
type: "direct",
|
|
||||||
endpoint: params.endpoint,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:${params.endpoint}`,
|
|
||||||
createdAt: params.nowIso,
|
|
||||||
updatedAt: params.nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
|
||||||
localStorage.setItem(
|
|
||||||
"@paseo:create-agent-preferences",
|
|
||||||
JSON.stringify({
|
|
||||||
serverId: params.serverId,
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
claude: { model: "haiku" },
|
|
||||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
|
||||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
|
||||||
const rootElement = root as HTMLElement;
|
|
||||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
|
||||||
const scrollElement =
|
|
||||||
candidates.find(
|
|
||||||
(element) =>
|
|
||||||
element instanceof HTMLElement &&
|
|
||||||
element.scrollHeight - element.clientHeight > 1
|
|
||||||
) ?? rootElement;
|
|
||||||
|
|
||||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
|
||||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
|
||||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
|
||||||
const distanceFromBottom = Math.max(
|
|
||||||
0,
|
|
||||||
contentHeight - (offsetY + viewportHeight)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
offsetY,
|
|
||||||
contentHeight,
|
|
||||||
viewportHeight,
|
|
||||||
distanceFromBottom,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scrollUpFromBottom(
|
|
||||||
page: Page,
|
|
||||||
pixels: number
|
|
||||||
): Promise<void> {
|
|
||||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
|
||||||
(root: Element, amount: number) => {
|
|
||||||
const rootElement = root as HTMLElement;
|
|
||||||
const candidates = [
|
|
||||||
rootElement,
|
|
||||||
...Array.from(rootElement.querySelectorAll("*")),
|
|
||||||
];
|
|
||||||
const scrollElement =
|
|
||||||
candidates.find(
|
|
||||||
(element) =>
|
|
||||||
element instanceof HTMLElement &&
|
|
||||||
element.scrollHeight - element.clientHeight > 1
|
|
||||||
) ?? rootElement;
|
|
||||||
|
|
||||||
const bottomOffset = Math.max(
|
|
||||||
0,
|
|
||||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
|
||||||
);
|
|
||||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
|
||||||
},
|
|
||||||
pixels
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
||||||
expect(userAgent).toContain("Firefox");
|
|
||||||
|
|
||||||
await page.addInitScript(seedDaemonRegistryScript, {
|
|
||||||
serverId: SERVER_ID,
|
|
||||||
endpoint: DAEMON_ENDPOINT,
|
|
||||||
nowIso: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" });
|
|
||||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Require enough history so the repro actually scrolls away from bottom.
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const metrics = await readScrollMetrics(page);
|
|
||||||
return Math.max(0, metrics.contentHeight - metrics.viewportHeight);
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(300);
|
|
||||||
|
|
||||||
await scrollUpFromBottom(page, 900);
|
|
||||||
await page.waitForTimeout(250);
|
|
||||||
const beforeSubmit = await readScrollMetrics(page);
|
|
||||||
const beforePath = testInfo.outputPath("before-submit.png");
|
|
||||||
await page.screenshot({ path: beforePath, fullPage: true });
|
|
||||||
await testInfo.attach("before-submit", {
|
|
||||||
path: beforePath,
|
|
||||||
contentType: "image/png",
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT);
|
|
||||||
await page.getByRole("textbox", { name: "Message agent..." }).press("Enter");
|
|
||||||
await page.waitForTimeout(1200);
|
|
||||||
|
|
||||||
const afterSubmit = await readScrollMetrics(page);
|
|
||||||
const afterPath = testInfo.outputPath("after-submit.png");
|
|
||||||
await page.screenshot({ path: afterPath, fullPage: true });
|
|
||||||
await testInfo.attach("after-submit", {
|
|
||||||
path: afterPath,
|
|
||||||
contentType: "image/png",
|
|
||||||
});
|
|
||||||
await testInfo.attach("firefox-scroll-metrics", {
|
|
||||||
body: JSON.stringify(
|
|
||||||
{
|
|
||||||
url: AGENT_URL,
|
|
||||||
submitText: SUBMIT_TEXT,
|
|
||||||
thresholdPx: NEAR_BOTTOM_THRESHOLD_PX,
|
|
||||||
beforeScreenshot: beforePath,
|
|
||||||
afterScreenshot: afterPath,
|
|
||||||
beforeSubmit,
|
|
||||||
afterSubmit,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[firefox-scroll-repro] ${JSON.stringify(
|
|
||||||
{
|
|
||||||
submitText: SUBMIT_TEXT,
|
|
||||||
beforeSubmit,
|
|
||||||
afterSubmit,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
|
||||||
expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual(
|
|
||||||
NEAR_BOTTOM_THRESHOLD_PX
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await createAgent(page, prompt);
|
|
||||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.reload({ waitUntil: 'commit' });
|
|
||||||
await expect(page).toHaveURL(/\/agent\//);
|
|
||||||
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
|
|
||||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { test } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
archiveAgentFromDaemon,
|
||||||
|
archiveAgentFromSessions,
|
||||||
|
connectArchiveTabDaemonClient,
|
||||||
|
createIdleAgent,
|
||||||
|
expectSessionRowVisible,
|
||||||
|
expectWorkspaceArchiveOutcome,
|
||||||
|
openSessions,
|
||||||
|
openWorkspaceWithAgents,
|
||||||
|
primeAdditionalPage,
|
||||||
|
resetSeededPageState,
|
||||||
|
reloadWorkspace,
|
||||||
|
} from "./helpers/archive-tab";
|
||||||
|
|
||||||
|
test.describe("Archive tab reconciliation", () => {
|
||||||
|
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("archive-tab-");
|
||||||
|
client = await connectArchiveTabDaemonClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await client?.close();
|
||||||
|
await tempRepo?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||||
|
const archived = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `cli-archive-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const surviving = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `cli-control-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const passivePage = await page.context().newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await primeAdditionalPage(passivePage);
|
||||||
|
await resetSeededPageState(page);
|
||||||
|
await resetSeededPageState(passivePage);
|
||||||
|
await openSessions(page);
|
||||||
|
await expectSessionRowVisible(page, archived.title);
|
||||||
|
await expectSessionRowVisible(page, surviving.title);
|
||||||
|
await openSessions(passivePage);
|
||||||
|
await expectSessionRowVisible(passivePage, archived.title);
|
||||||
|
await expectSessionRowVisible(passivePage, surviving.title);
|
||||||
|
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||||
|
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||||
|
await archiveAgentFromDaemon(client, archived.id);
|
||||||
|
await expectWorkspaceArchiveOutcome(page, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
await reloadWorkspace(passivePage, tempRepo.path);
|
||||||
|
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await passivePage.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
|
||||||
|
const archived = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `ui-archive-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const surviving = await createIdleAgent(client, {
|
||||||
|
cwd: tempRepo.path,
|
||||||
|
title: `ui-control-${randomUUID().slice(0, 8)}`,
|
||||||
|
});
|
||||||
|
const passivePage = await page.context().newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await primeAdditionalPage(passivePage);
|
||||||
|
await resetSeededPageState(page);
|
||||||
|
await resetSeededPageState(passivePage);
|
||||||
|
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||||
|
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||||
|
await openSessions(page);
|
||||||
|
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||||
|
await reloadWorkspace(page, tempRepo.path);
|
||||||
|
await expectWorkspaceArchiveOutcome(page, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||||
|
archivedAgentId: archived.id,
|
||||||
|
survivingAgentId: surviving.id,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await passivePage.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
|
||||||
import { execSync } from 'node:child_process';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { test, expect, type Page } from './fixtures';
|
|
||||||
import {
|
|
||||||
createAgent,
|
|
||||||
ensureHostSelected,
|
|
||||||
gotoHome,
|
|
||||||
setWorkingDirectory,
|
|
||||||
} from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChangesScope(page: Page) {
|
|
||||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChangesHeader(page: Page) {
|
|
||||||
return getChangesScope(page).getByTestId('changes-header');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureExplorerTabsVisible(page: Page) {
|
|
||||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
|
||||||
if (await changesTab.isVisible().catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = page
|
|
||||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
|
||||||
.first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
|
||||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
||||||
if (await changesTab.isVisible().catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await toggle.click();
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
|
||||||
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
|
||||||
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
|
||||||
if (await primaryBackdrop.isVisible().catch(() => false)) {
|
|
||||||
await primaryBackdrop.click({ force: true });
|
|
||||||
await expect(primaryBackdrop).toHaveCount(0);
|
|
||||||
}
|
|
||||||
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
|
|
||||||
if (await overflowBackdrop.isVisible().catch(() => false)) {
|
|
||||||
await overflowBackdrop.click({ force: true });
|
|
||||||
await expect(overflowBackdrop).toHaveCount(0);
|
|
||||||
}
|
|
||||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
|
||||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
|
||||||
await diffModeBackdrop.click({ force: true });
|
|
||||||
await expect(diffModeBackdrop).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scope = getChangesScope(page);
|
|
||||||
const modeToggle = scope.getByTestId('changes-diff-status').first();
|
|
||||||
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
|
|
||||||
if (!(await modeToggle.isVisible().catch(() => false))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
|
|
||||||
if (current !== expected) {
|
|
||||||
await modeToggle.click();
|
|
||||||
const menu = page.getByTestId('changes-diff-status-menu');
|
|
||||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
|
||||||
const optionTestId =
|
|
||||||
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
|
|
||||||
await page.getByTestId(optionTestId).click({ force: true });
|
|
||||||
}
|
|
||||||
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openChangesPrimaryMenu(page: Page) {
|
|
||||||
const scope = getChangesScope(page);
|
|
||||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
|
||||||
await expect(caret).toBeVisible();
|
|
||||||
await caret.click();
|
|
||||||
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
|
|
||||||
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
|
||||||
await ensureExplorerTabsVisible(page);
|
|
||||||
const changesHeader = getChangesHeader(page);
|
|
||||||
if (!(await changesHeader.isVisible())) {
|
|
||||||
await page.getByTestId('explorer-tab-changes').first().click();
|
|
||||||
}
|
|
||||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
|
||||||
if (options?.expectGit === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const changesScope = getChangesScope(page);
|
|
||||||
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
|
|
||||||
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
|
|
||||||
if (!(await stopButton.isVisible().catch(() => false))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await expect(stopButton).not.toBeVisible({ timeout });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAgentAndWait(page: Page, message: string) {
|
|
||||||
await createAgent(page, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectAttachWorktree(page: Page, branchName: string) {
|
|
||||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
|
||||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
|
||||||
await trigger.click();
|
|
||||||
|
|
||||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
|
||||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
|
||||||
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
|
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
|
||||||
await searchInput.fill(branchName);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredOption = menu
|
|
||||||
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
|
|
||||||
.first();
|
|
||||||
await expect(preferredOption).toBeVisible({ timeout: 10000 });
|
|
||||||
await preferredOption.click({ force: true });
|
|
||||||
await expect(menu).toHaveCount(0);
|
|
||||||
await expect(trigger).toContainText(branchName, { timeout: 30000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enableCreateWorktree(page: Page) {
|
|
||||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
|
||||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
|
|
||||||
if (/Create new worktree/i.test(currentValue)) {
|
|
||||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await trigger.click();
|
|
||||||
const menu = page.getByTestId('combobox-desktop-container').first();
|
|
||||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
|
||||||
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
|
|
||||||
await expect(createOption).toBeVisible({ timeout: 10000 });
|
|
||||||
await createOption.click({ force: true });
|
|
||||||
await expect(menu).toHaveCount(0);
|
|
||||||
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
|
|
||||||
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshUncommittedMode(page: Page) {
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await selectChangesView(page, 'working');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshChangesTab(page: Page) {
|
|
||||||
await ensureExplorerTabsVisible(page);
|
|
||||||
await page.getByTestId('explorer-tab-files').first().click();
|
|
||||||
await page.getByTestId('explorer-tab-changes').first().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTmpPath(value: string) {
|
|
||||||
if (value.startsWith('/var/')) {
|
|
||||||
return `/private${value}`;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GitWorktreeEntry = {
|
|
||||||
worktreePath: string;
|
|
||||||
branchRef: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
|
|
||||||
const blocks = raw
|
|
||||||
.split(/\n\s*\n/g)
|
|
||||||
.map((block) => block.trim())
|
|
||||||
.filter((block) => block.length > 0);
|
|
||||||
|
|
||||||
const entries: GitWorktreeEntry[] = [];
|
|
||||||
for (const block of blocks) {
|
|
||||||
const worktreeMatch = block.match(/^worktree (.+)$/m);
|
|
||||||
if (!worktreeMatch) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const branchMatch = block.match(/^branch (.+)$/m);
|
|
||||||
entries.push({
|
|
||||||
worktreePath: worktreeMatch[1].trim(),
|
|
||||||
branchRef: branchMatch ? branchMatch[1].trim() : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
const normalizedRepoPath = normalizeTmpPath(repoPath);
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const output = execSync('git worktree list --porcelain', {
|
|
||||||
cwd: repoPath,
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
const entries = parseGitWorktreeList(output);
|
|
||||||
const candidate = entries.find((entry) => {
|
|
||||||
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
|
|
||||||
if (normalizedWorktreePath === normalizedRepoPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!entry.branchRef) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !/\/main$/i.test(entry.branchRef);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (candidate) {
|
|
||||||
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
|
|
||||||
if (branchName) {
|
|
||||||
return {
|
|
||||||
worktreePath: candidate.worktreePath,
|
|
||||||
branchName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore transient git worktree read errors while polling.
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
|
|
||||||
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
await enableCreateWorktree(page);
|
|
||||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
|
||||||
await waitForAgentTurnToSettle(page);
|
|
||||||
|
|
||||||
await openChangesPanel(page);
|
|
||||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
|
|
||||||
.not.toBe('Unknown');
|
|
||||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
|
||||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
|
||||||
repo.path
|
|
||||||
);
|
|
||||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
|
||||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
|
||||||
realpath(firstCwd).catch(() => firstCwd),
|
|
||||||
realpath(repo.path).catch(() => repo.path),
|
|
||||||
]);
|
|
||||||
const normalizedRepo = normalizeTmpPath(resolvedRepo);
|
|
||||||
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
|
||||||
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
|
||||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
|
||||||
|
|
||||||
await page.getByTestId('sidebar-new-agent').click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await selectAttachWorktree(page, worktreeBranch);
|
|
||||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
|
||||||
await waitForAgentTurnToSettle(page);
|
|
||||||
await openChangesPanel(page);
|
|
||||||
|
|
||||||
const readmePath = path.join(firstCwd, 'README.md');
|
|
||||||
await appendFile(readmePath, '\nFirst change\n');
|
|
||||||
|
|
||||||
await refreshUncommittedMode(page);
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
|
||||||
await expect(page.getByText('First change')).toBeVisible();
|
|
||||||
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
|
||||||
await expect(primaryCta).toBeVisible();
|
|
||||||
await expect(primaryCta).toContainText('Commit');
|
|
||||||
|
|
||||||
await primaryCta.click();
|
|
||||||
await expect
|
|
||||||
.poll(() => {
|
|
||||||
try {
|
|
||||||
return execSync('git status --porcelain', {
|
|
||||||
cwd: firstCwd,
|
|
||||||
encoding: 'utf8',
|
|
||||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
||||||
}).trim();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, { timeout: 30000 })
|
|
||||||
.toBe('');
|
|
||||||
await openChangesPanel(page);
|
|
||||||
await selectChangesView(page, 'working');
|
|
||||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
|
||||||
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Push once from the menu so the branch has an origin/<branch> ref.
|
|
||||||
await openChangesPrimaryMenu(page);
|
|
||||||
await page.getByTestId('changes-menu-push').click();
|
|
||||||
await expect
|
|
||||||
.poll(() => {
|
|
||||||
try {
|
|
||||||
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, { timeout: 30000 })
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
const notesPath = path.join(firstCwd, 'notes.txt');
|
|
||||||
await writeFile(notesPath, 'Second change\n');
|
|
||||||
|
|
||||||
await refreshUncommittedMode(page);
|
|
||||||
await refreshChangesTab(page);
|
|
||||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
|
||||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
|
|
||||||
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await getChangesScope(page).getByTestId('changes-primary-cta').click();
|
|
||||||
await expect
|
|
||||||
.poll(() => {
|
|
||||||
try {
|
|
||||||
return execSync('git status --porcelain', {
|
|
||||||
cwd: firstCwd,
|
|
||||||
encoding: 'utf8',
|
|
||||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
||||||
}).trim();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, { timeout: 30000 })
|
|
||||||
.toBe('');
|
|
||||||
await openChangesPanel(page);
|
|
||||||
await selectChangesView(page, 'working');
|
|
||||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
|
||||||
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
|
|
||||||
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
|
||||||
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
|
|
||||||
await pushPrimary.click();
|
|
||||||
// Regression check: the primary CTA stays in place while pushing.
|
|
||||||
await expect(pushPrimary).toBeVisible();
|
|
||||||
await page.waitForTimeout(50);
|
|
||||||
await expect(pushPrimary).toBeVisible();
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => {
|
|
||||||
try {
|
|
||||||
const count = execSync(
|
|
||||||
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
|
|
||||||
{ cwd: firstCwd, encoding: 'utf8' }
|
|
||||||
).trim();
|
|
||||||
return Number.parseInt(count, 10);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, { timeout: 30000 })
|
|
||||||
.toBe(0);
|
|
||||||
|
|
||||||
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
|
|
||||||
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
|
|
||||||
execSync("git checkout main", { cwd: repo.path });
|
|
||||||
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
|
|
||||||
execSync("git push", { cwd: repo.path });
|
|
||||||
|
|
||||||
await selectChangesView(page, 'base');
|
|
||||||
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
|
||||||
'Committed',
|
|
||||||
{ timeout: 30000 }
|
|
||||||
);
|
|
||||||
await refreshChangesTab(page);
|
|
||||||
|
|
||||||
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
|
|
||||||
// primary flow or hidden behind menu variants), so continue from a fresh draft.
|
|
||||||
await page.getByTestId('sidebar-new-agent').click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
|
|
||||||
|
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
|
||||||
} finally {
|
|
||||||
await rm(nonGitDir, { recursive: true, force: true });
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { createAgentInRepo } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test('create agent in a temp repo', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const prompt = "Respond with exactly: Hello";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentInRepo(page, { directory: repo.path, prompt });
|
|
||||||
|
|
||||||
// Verify user message is shown in the stream
|
|
||||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
|
||||||
|
|
||||||
// Verify we used the seeded fast model (do not fall back to other defaults).
|
|
||||||
const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
|
|
||||||
await expect(modelPicker).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
|
|
||||||
|
|
||||||
// Verify the assistant response is rendered.
|
|
||||||
await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
test('daemon is connected in settings', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
|
|
||||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
|
||||||
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test('deleting an agent persists after reload', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const nonce = Math.random().toString(36).slice(2, 10);
|
|
||||||
const prompt = `respond-ready-${nonce}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
// Create agent (via message input) so it shows up in the sidebar list.
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable();
|
|
||||||
await input.fill(prompt);
|
|
||||||
await input.press('Enter');
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
|
||||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
|
||||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
|
||||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
|
||||||
|
|
||||||
const match =
|
|
||||||
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
|
|
||||||
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
|
||||||
}
|
|
||||||
const serverId = decodeURIComponent(match[1]);
|
|
||||||
const agentId = decodeURIComponent(match[2]);
|
|
||||||
|
|
||||||
// Return home and delete via long-press in the agent list.
|
|
||||||
await gotoHome(page);
|
|
||||||
const rowTestId = `agent-row-${serverId}-${agentId}`;
|
|
||||||
const agentRow = page.getByTestId(rowTestId).first();
|
|
||||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
|
|
||||||
await agentRow.hover();
|
|
||||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
|
||||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
|
||||||
await quickArchive.click({ force: true });
|
|
||||||
const confirmArchive = page
|
|
||||||
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
|
|
||||||
.first();
|
|
||||||
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
|
|
||||||
await confirmArchive.click({ force: true });
|
|
||||||
|
|
||||||
// Ensure deletion finished before reload (avoids races).
|
|
||||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
|
||||||
|
|
||||||
// A full reload should not bring the agent back.
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
|
||||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import {
|
|
||||||
createAgent,
|
|
||||||
createAgentInRepo,
|
|
||||||
ensureHostSelected,
|
|
||||||
gotoHome,
|
|
||||||
openSettings,
|
|
||||||
setWorkingDirectory,
|
|
||||||
} from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import {
|
|
||||||
buildHostWorkspaceAgentRoute,
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
} from '@/utils/host-routes';
|
|
||||||
import {
|
|
||||||
ensureWorkspaceAgentPaneVisible,
|
|
||||||
waitForWorkspaceTabsVisible,
|
|
||||||
} from './helpers/workspace-tabs';
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addFakeMicrophone(page: Page) {
|
|
||||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
|
|
||||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
|
||||||
const mimeType = 'audio/wav';
|
|
||||||
|
|
||||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
|
||||||
const mic = {
|
|
||||||
active: 0,
|
|
||||||
getUserMediaCalls: 0,
|
|
||||||
stopCalls: 0,
|
|
||||||
lastRecorder: null as null | { state: string },
|
|
||||||
};
|
|
||||||
(window as any).__mic = mic;
|
|
||||||
|
|
||||||
(window as any).isSecureContext = true;
|
|
||||||
|
|
||||||
const nav = navigator as any;
|
|
||||||
if (!nav.mediaDevices) {
|
|
||||||
nav.mediaDevices = {};
|
|
||||||
}
|
|
||||||
nav.mediaDevices.getUserMedia = async () => {
|
|
||||||
mic.getUserMediaCalls += 1;
|
|
||||||
mic.active += 1;
|
|
||||||
const track = {
|
|
||||||
stop: () => {
|
|
||||||
mic.stopCalls += 1;
|
|
||||||
mic.active = Math.max(0, mic.active - 1);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
getTracks: () => [track],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const AudioContextCtor =
|
|
||||||
(window as any).AudioContext ?? (window as any).webkitAudioContext;
|
|
||||||
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
|
|
||||||
const nativeCreateMediaStreamSource =
|
|
||||||
AudioContextCtor.prototype.createMediaStreamSource;
|
|
||||||
AudioContextCtor.prototype.createMediaStreamSource =
|
|
||||||
function patchedCreateMediaStreamSource(stream: unknown) {
|
|
||||||
const isFakeStream =
|
|
||||||
!!stream &&
|
|
||||||
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
|
|
||||||
typeof (stream as { id?: unknown }).id === 'undefined';
|
|
||||||
if (isFakeStream) {
|
|
||||||
throw new Error('Force recorder fallback for fake microphone stream');
|
|
||||||
}
|
|
||||||
return nativeCreateMediaStreamSource.call(this, stream);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
|
|
||||||
const binaryString = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return new Blob([bytes], { type: mimeType });
|
|
||||||
};
|
|
||||||
|
|
||||||
class FakeMediaRecorder extends EventTarget {
|
|
||||||
public static isTypeSupported() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public state: 'inactive' | 'recording' = 'inactive';
|
|
||||||
public mimeType: string;
|
|
||||||
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
|
||||||
public onerror: ((event: unknown) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(_stream: unknown, options?: MediaRecorderOptions) {
|
|
||||||
super();
|
|
||||||
this.mimeType = options?.mimeType ?? 'audio/webm';
|
|
||||||
mic.lastRecorder = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public start() {
|
|
||||||
this.state = 'recording';
|
|
||||||
}
|
|
||||||
|
|
||||||
public stop() {
|
|
||||||
if (this.state !== 'recording') {
|
|
||||||
throw new Error('Not recording');
|
|
||||||
}
|
|
||||||
this.state = 'inactive';
|
|
||||||
try {
|
|
||||||
this.ondataavailable?.({
|
|
||||||
data: blobFromBase64(base64Audio, mimeType),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.onerror?.(err);
|
|
||||||
}
|
|
||||||
this.dispatchEvent(new Event('stop'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(window as any).MediaRecorder = FakeMediaRecorder;
|
|
||||||
}, { base64Audio, mimeType });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectComposerReady(page: Page) {
|
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectDictationStarted(page: Page) {
|
|
||||||
await expect
|
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectDictationStopped(page: Page) {
|
|
||||||
await expect
|
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAgentInRepo(page, {
|
|
||||||
directory: repo.path,
|
|
||||||
prompt: 'Respond with exactly: Hello',
|
|
||||||
});
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
|
||||||
expect(calls).toBe(1);
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expectDictationStopped(page);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.');
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAgentInRepo(page, {
|
|
||||||
directory: repo.path,
|
|
||||||
prompt: 'Respond with exactly: Hello',
|
|
||||||
});
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
|
|
||||||
await page.getByTestId('workspace-new-agent-tab').first().click();
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
|
||||||
expect(calls).toBe(1);
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expectDictationStopped(page);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await openSettings(page);
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
|
|
||||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
|
||||||
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
|
||||||
|
|
||||||
expect(calls).toBe(0);
|
|
||||||
expect(active).toBe(0);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictation transcribes fixture via real STT', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
const initialCopyMessageCount = await page
|
|
||||||
.getByRole('button', { name: 'Copy message' })
|
|
||||||
.count();
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStopped(page);
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => page.getByRole('button', { name: 'Copy message' }).count(),
|
|
||||||
{ timeout: 60_000 }
|
|
||||||
)
|
|
||||||
.toBeGreaterThan(initialCopyMessageCount);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
|
||||||
if (mic.lastRecorder) {
|
|
||||||
mic.lastRecorder.state = 'inactive';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expectDictationStopped(page);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
|
|
||||||
await addFakeMicrophone(page);
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//);
|
|
||||||
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
|
|
||||||
}
|
|
||||||
const serverId = decodeURIComponent(match[1]!);
|
|
||||||
const agentId = decodeURIComponent(match[2]!);
|
|
||||||
await expectComposerReady(page);
|
|
||||||
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
await expectDictationStarted(page);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
|
||||||
|
|
||||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
|
||||||
await expect(newAgentButton).toBeVisible();
|
|
||||||
await newAgentButton.click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
|
|
||||||
|
|
||||||
await page.waitForTimeout(10_000);
|
|
||||||
|
|
||||||
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
|
|
||||||
await expect(page).toHaveURL(
|
|
||||||
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
|
|
||||||
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
|
|
||||||
test("draft enables explorer after selecting a working directory", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-draft-explorer-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
|
||||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
|
||||||
await newAgentButton.click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
|
|
||||||
const toggle = page
|
|
||||||
.getByRole("button", {
|
|
||||||
name: /open explorer|close explorer|toggle explorer/i,
|
|
||||||
})
|
|
||||||
.first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="explorer-header"]:visible').first()
|
|
||||||
).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const terminalsTab = page
|
|
||||||
.locator('[data-testid="explorer-tab-terminals"]:visible')
|
|
||||||
.first();
|
|
||||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
|
||||||
await terminalsTab.click();
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="terminal-surface"]:visible').first()
|
|
||||||
).toBeVisible({ timeout: 30000 });
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { test as base, expect, type Page } from '@playwright/test';
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
|
|
||||||
// Extend base test to provide dynamic baseURL from global-setup
|
// Extend base test to provide dynamic baseURL from global-setup
|
||||||
const test = base.extend({
|
const test = base.extend({
|
||||||
baseURL: async ({}, use) => {
|
baseURL: async ({}, use) => {
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
throw new Error('E2E_METRO_PORT not set - globalSetup must run first');
|
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
|
||||||
}
|
}
|
||||||
await use(`http://localhost:${metroPort}`);
|
await use(`http://localhost:${metroPort}`);
|
||||||
},
|
},
|
||||||
@@ -18,19 +19,19 @@ test.beforeEach(async ({ page }) => {
|
|||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!daemonPort) {
|
if (!daemonPort) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' +
|
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||||
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.'
|
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (daemonPort === '6767') {
|
if (daemonPort === "6767") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
|
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||||
'Fix Playwright globalSetup to start an isolated test daemon and export its port.'
|
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.'
|
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,17 +39,17 @@ test.beforeEach(async ({ page }) => {
|
|||||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries: string[] = [];
|
const entries: string[] = [];
|
||||||
consoleEntries.set(page, entries);
|
consoleEntries.set(page, entries);
|
||||||
|
|
||||||
page.on('console', (message) => {
|
page.on("console", (message) => {
|
||||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
page.on('pageerror', (error) => {
|
page.on("pageerror", (error) => {
|
||||||
entries.push(`[pageerror] ${error.message}`);
|
entries.push(`[pageerror] ${error.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,40 +57,21 @@ test.beforeEach(async ({ page }) => {
|
|||||||
const seedNonce = Math.random().toString(36).slice(2);
|
const seedNonce = Math.random().toString(36).slice(2);
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
|
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||||
}
|
}
|
||||||
const testDaemon = {
|
const testDaemon = buildSeededHost({
|
||||||
serverId,
|
serverId,
|
||||||
label: 'localhost',
|
endpoint: `127.0.0.1:${daemonPort}`,
|
||||||
connections: [
|
nowIso,
|
||||||
{
|
});
|
||||||
id: `direct:127.0.0.1:${daemonPort}`,
|
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||||
type: 'direct',
|
|
||||||
endpoint: `127.0.0.1:${daemonPort}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
const createAgentPreferences = {
|
|
||||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
|
||||||
serverId: testDaemon.serverId,
|
|
||||||
// Keep e2e fast/cheap by default.
|
|
||||||
provider: 'codex',
|
|
||||||
providerPreferences: {
|
|
||||||
claude: { model: 'haiku' },
|
|
||||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon, preferences, seedNonce }) => {
|
({ daemon, preferences, seedNonce }) => {
|
||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
// setting this flag before the reload.
|
// setting this flag before the reload.
|
||||||
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
|
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||||
const disableValue = localStorage.getItem(disableOnceKey);
|
const disableValue = localStorage.getItem(disableOnceKey);
|
||||||
if (disableValue) {
|
if (disableValue) {
|
||||||
localStorage.removeItem(disableOnceKey);
|
localStorage.removeItem(disableOnceKey);
|
||||||
@@ -98,15 +80,15 @@ test.beforeEach(async ({ page }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('@paseo:e2e', '1');
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
|
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||||
|
|
||||||
// Hard-reset anything that could point to a developer's real daemon.
|
// Hard-reset anything that could point to a developer's real daemon.
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
localStorage.removeItem('@paseo:settings');
|
localStorage.removeItem("@paseo:settings");
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
},
|
},
|
||||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce }
|
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await testInfo.attach('browser-console', {
|
await testInfo.attach("browser-console", {
|
||||||
body: entries.join('\n'),
|
body: entries.join("\n"),
|
||||||
contentType: 'text/plain',
|
contentType: "text/plain",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import { appendFile } from 'node:fs/promises';
|
|
||||||
import { test, expect, type Page } from './fixtures';
|
|
||||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test.describe.configure({ timeout: 90000 });
|
|
||||||
|
|
||||||
function getChangesScope(page: Page) {
|
|
||||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureExplorerTabsVisible(page: Page) {
|
|
||||||
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
|
||||||
if (await changesTab.isVisible().catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = page
|
|
||||||
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
|
||||||
.first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
|
||||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
||||||
if (await changesTab.isVisible().catch(() => false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await toggle.click();
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openChangesPanel(page: Page) {
|
|
||||||
await ensureExplorerTabsVisible(page);
|
|
||||||
const changesHeader = getChangesScope(page).getByTestId('changes-header');
|
|
||||||
if (!(await changesHeader.isVisible())) {
|
|
||||||
await page.getByTestId('explorer-tab-changes').first().click();
|
|
||||||
}
|
|
||||||
await expect(changesHeader).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshUncommittedMode(page: Page) {
|
|
||||||
const scope = getChangesScope(page);
|
|
||||||
const toggle = scope.getByTestId('changes-diff-status').first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
|
||||||
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
|
||||||
await diffModeBackdrop.click({ force: true });
|
|
||||||
await expect(diffModeBackdrop).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentLabel = (await toggle.innerText()).trim();
|
|
||||||
await toggle.click({ force: true });
|
|
||||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
|
||||||
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
|
||||||
await page.getByTestId(firstTarget).click({ force: true });
|
|
||||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
|
|
||||||
|
|
||||||
const nextLabel = (await toggle.innerText()).trim();
|
|
||||||
await toggle.click({ force: true });
|
|
||||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
|
||||||
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
|
||||||
await page.getByTestId(secondTarget).click({ force: true });
|
|
||||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAgentAndWait(page: Page, message: string) {
|
|
||||||
await createAgent(page, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-sticky-');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
|
||||||
|
|
||||||
await openChangesPanel(page);
|
|
||||||
|
|
||||||
const readmePath = path.join(repo.path, 'README.md');
|
|
||||||
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
|
|
||||||
await appendFile(readmePath, `\n${lines}`);
|
|
||||||
|
|
||||||
await refreshUncommittedMode(page);
|
|
||||||
|
|
||||||
const scope = getChangesScope(page);
|
|
||||||
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
|
|
||||||
await fileToggle.click();
|
|
||||||
|
|
||||||
const markerLine = scope.getByText('Sticky header line 250').first();
|
|
||||||
await expect(markerLine).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const scroll = scope.getByTestId('git-diff-scroll').first();
|
|
||||||
await expect(scroll).toBeVisible();
|
|
||||||
|
|
||||||
await expect.poll(async () => {
|
|
||||||
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
|
|
||||||
}).toBe(true);
|
|
||||||
|
|
||||||
await scroll.hover();
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
await page.mouse.wheel(0, 700);
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect.poll(async () => {
|
|
||||||
return await scroll.evaluate((el) => el.scrollTop ?? 0);
|
|
||||||
}).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(fileToggle).toBeVisible();
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { test, expect } from './fixtures'
|
|
||||||
import { createTempGitRepo } from './helpers/workspace'
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
}
|
|
||||||
|
|
||||||
test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-global-draft-')
|
|
||||||
const prompt = `global draft prompt ${Date.now()}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page)
|
|
||||||
await ensureHostSelected(page)
|
|
||||||
await setWorkingDirectory(page, repo.path)
|
|
||||||
|
|
||||||
await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
|
||||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
|
||||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
|
||||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
|
||||||
|
|
||||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
|
||||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
|
||||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
|
||||||
|
|
||||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await composer.fill(prompt)
|
|
||||||
await composer.press('Enter')
|
|
||||||
|
|
||||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 })
|
|
||||||
|
|
||||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
|
||||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
|
||||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import { test, expect } from './fixtures'
|
|
||||||
import type { Page } from '@playwright/test'
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
|
||||||
import { createTempGitRepo } from './helpers/workspace'
|
|
||||||
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
|
|
||||||
|
|
||||||
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
|
|
||||||
const url = new URL(rawUrl)
|
|
||||||
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
|
|
||||||
const workspaceToken = pathMatch?.[1] ?? null
|
|
||||||
let agentId = pathMatch?.[2] ?? null
|
|
||||||
|
|
||||||
if (!agentId) {
|
|
||||||
const open = url.searchParams.get('open')
|
|
||||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
|
||||||
if (openMatch?.[1]) {
|
|
||||||
agentId = openMatch[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { workspaceToken, agentId }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
|
|
||||||
const url = new URL(rawUrl)
|
|
||||||
const open = url.searchParams.get('open')
|
|
||||||
const openMatch = open?.match(/^agent:(.+)$/) ?? null
|
|
||||||
return openMatch?.[1] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function preferSlowerThinkingOption(page: Page): Promise<void> {
|
|
||||||
await page.keyboard.press('Escape').catch(() => undefined)
|
|
||||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
|
|
||||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await thinkingTrigger.click({ force: true })
|
|
||||||
const menu = page.getByTestId('agent-thinking-menu').first()
|
|
||||||
if (!(await menu.isVisible().catch(() => false))) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferred = ['high', 'max', 'deep', 'long', 'medium']
|
|
||||||
for (const label of preferred) {
|
|
||||||
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
|
|
||||||
if (await option.isVisible().catch(() => false)) {
|
|
||||||
await option.click({ force: true })
|
|
||||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = menu.getByRole('button')
|
|
||||||
const count = await options.count()
|
|
||||||
if (count > 0) {
|
|
||||||
await options.last().click({ force: true })
|
|
||||||
}
|
|
||||||
await expect(menu).not.toBeVisible({ timeout: 5_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isAnyStopVisible(page: Page): Promise<boolean> {
|
|
||||||
const candidates = page.getByRole('button', { name: /stop|cancel/i })
|
|
||||||
const count = await candidates.count()
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
if (await candidates.nth(index).isVisible().catch(() => false)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isRunningControlVisible(page: Page): Promise<boolean> {
|
|
||||||
if (await isAnyStopVisible(page)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
|
|
||||||
const count = await interruptSendButton.count()
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
|
|
||||||
const locator = page.getByText(matcher).first()
|
|
||||||
const matches = page.getByText(matcher)
|
|
||||||
const count = await matches.count()
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
if (await matches.nth(index).isVisible().catch(() => false)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return await locator.isVisible().catch(() => false)
|
|
||||||
}
|
|
||||||
|
|
||||||
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(360_000)
|
|
||||||
const serverId = process.env.E2E_SERVER_ID
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
|
|
||||||
const id = `${Date.now()}`
|
|
||||||
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
|
|
||||||
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
|
|
||||||
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
|
|
||||||
const seedToken = `ACK_SEED_${id}`
|
|
||||||
const secondToken = `ACK_SECOND_${id}`
|
|
||||||
expect(seedPrompt).not.toBe(secondPrompt)
|
|
||||||
expect(seedToken).not.toBe(secondToken)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page)
|
|
||||||
await ensureHostSelected(page)
|
|
||||||
await page.goto(`/h/${serverId}/new-agent`)
|
|
||||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 1) Seed workspace with an existing agent.
|
|
||||||
await setWorkingDirectory(page, repo.path)
|
|
||||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await seedComposer.fill(seedPrompt)
|
|
||||||
await seedComposer.press('Enter')
|
|
||||||
|
|
||||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
|
||||||
const seededContext = parseWorkspaceContextFromUrl(page.url())
|
|
||||||
if (!seededContext.workspaceToken || !seededContext.agentId) {
|
|
||||||
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
|
|
||||||
}
|
|
||||||
const seededAgentId = seededContext.agentId
|
|
||||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
|
|
||||||
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
|
|
||||||
|
|
||||||
// 2) Use global New Agent entry and 3) select same workspace.
|
|
||||||
await page.getByText('New agent', { exact: true }).first().click()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
|
|
||||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path)
|
|
||||||
await preferSlowerThinkingOption(page)
|
|
||||||
await page.keyboard.press('Escape').catch(() => undefined)
|
|
||||||
|
|
||||||
// 4) Create second agent from global draft.
|
|
||||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await composer.fill(secondPrompt)
|
|
||||||
await composer.press('Enter')
|
|
||||||
await page.waitForTimeout(500)
|
|
||||||
if (page.url().includes('/new-agent')) {
|
|
||||||
const sendButton = page.getByRole('button', { name: /send message/i }).first()
|
|
||||||
await expect(sendButton).toBeVisible({ timeout: 10_000 })
|
|
||||||
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
|
|
||||||
await sendButton.click({ force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Assert redirect workspace context + created agent tab is active/open.
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
|
||||||
const createdContext = parseWorkspaceContextFromUrl(page.url())
|
|
||||||
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
|
|
||||||
if (!createdContext.agentId) {
|
|
||||||
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
|
|
||||||
}
|
|
||||||
const createdAgentId = createdContext.agentId
|
|
||||||
expect(createdAgentId).toBeTruthy()
|
|
||||||
expect(createdAgentId).not.toBe(seededAgentId)
|
|
||||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
|
||||||
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
|
|
||||||
|
|
||||||
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const finalTabIds = await getWorkspaceTabTestIds(page)
|
|
||||||
return finalTabIds.includes(createdTabTestId)
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
|
|
||||||
|
|
||||||
// 6) Response assertions are scoped to created-agent active context.
|
|
||||||
await expect
|
|
||||||
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
|
|
||||||
.toBe(true)
|
|
||||||
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
// 7) Lifecycle assertions on created agent only: running first, then idle.
|
|
||||||
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await lifecycleComposer.fill(lifecyclePrompt)
|
|
||||||
await lifecycleComposer.press('Enter')
|
|
||||||
await expect
|
|
||||||
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
let sawRunning = false
|
|
||||||
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
|
||||||
if (await isRunningControlVisible(page)) {
|
|
||||||
sawRunning = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(50)
|
|
||||||
}
|
|
||||||
expect(sawRunning).toBe(true)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
|
|
||||||
.toBe(true)
|
|
||||||
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+143
-103
@@ -1,11 +1,11 @@
|
|||||||
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from "node:fs";
|
||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
import net from 'node:net';
|
import net from "node:net";
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from "node:buffer";
|
||||||
import dotenv from 'dotenv';
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
type WaitForServerOptions = {
|
type WaitForServerOptions = {
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
|
|||||||
async function getAvailablePort(): Promise<number> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
server.once('error', reject);
|
server.once("error", reject);
|
||||||
server.listen(0, () => {
|
server.listen(0, () => {
|
||||||
const address = server.address();
|
const address = server.address();
|
||||||
if (!address || typeof address === 'string') {
|
if (!address || typeof address === "string") {
|
||||||
server.close(() => reject(new Error('Failed to acquire port')));
|
server.close(() => reject(new Error("Failed to acquire port")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
server.close(() => resolve(address.port));
|
server.close(() => resolve(address.port));
|
||||||
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
dump() {
|
dump() {
|
||||||
return lines.join('\n');
|
return lines.join("\n");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRecentOutput(getRecentOutput?: () => string): string {
|
function formatRecentOutput(getRecentOutput?: () => string): string {
|
||||||
if (!getRecentOutput) {
|
if (!getRecentOutput) {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
const output = getRecentOutput().trim();
|
const output = getRecentOutput().trim();
|
||||||
if (!output) {
|
if (!output) {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
return `\nRecent output:\n${output}`;
|
return `\nRecent output:\n${output}`;
|
||||||
}
|
}
|
||||||
@@ -61,21 +61,15 @@ function sleep(ms: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||||
const {
|
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||||
host = '127.0.0.1',
|
|
||||||
timeoutMs = 15000,
|
|
||||||
label,
|
|
||||||
childProcess,
|
|
||||||
getRecentOutput,
|
|
||||||
} = options;
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let lastConnectionError: unknown = null;
|
let lastConnectionError: unknown = null;
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
if (childProcess && childProcess.exitCode !== null) {
|
if (childProcess && childProcess.exitCode !== null) {
|
||||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
|
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
|
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +83,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
|||||||
socket.destroy();
|
socket.destroy();
|
||||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||||
});
|
});
|
||||||
socket.on('error', reject);
|
socket.on("error", reject);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reason =
|
const reason =
|
||||||
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
|
lastConnectionError instanceof Error
|
||||||
|
? ` Last connection error: ${lastConnectionError.message}`
|
||||||
|
: "";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
|
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,15 +122,15 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
|||||||
if (child.exitCode !== null || child.signalCode !== null) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
child.kill('SIGTERM');
|
child.kill("SIGTERM");
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (child.exitCode === null && child.signalCode === null) {
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
child.kill('SIGKILL');
|
child.kill("SIGKILL");
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
child.once('exit', () => {
|
child.once("exit", () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
@@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
|||||||
function summarizeOpenAiErrorBody(body: string): string {
|
function summarizeOpenAiErrorBody(body: string): string {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return 'empty response body';
|
return "empty response body";
|
||||||
}
|
}
|
||||||
if (trimmed.length <= 240) {
|
if (trimmed.length <= 240) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
@@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
|
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`,
|
Authorization: `Bearer ${key}`,
|
||||||
},
|
},
|
||||||
@@ -170,14 +166,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
|||||||
}
|
}
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
console.warn(
|
console.warn(
|
||||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
|
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[e2e] OPENAI_API_KEY probe request failed: ${
|
`[e2e] OPENAI_API_KEY probe request failed: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`
|
}`,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -196,28 +192,42 @@ type OfferPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function stripAnsi(input: string): string {
|
function stripAnsi(input: string): string {
|
||||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||||
|
const relayDistEntry = path.join(repoRoot, "packages/relay/dist/e2ee.js");
|
||||||
|
if (existsSync(relayDistEntry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[e2e] Building @getpaseo/relay for daemon startup");
|
||||||
|
execSync("npm run build --workspace=@getpaseo/relay", {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||||
const marker = '#offer=';
|
const marker = "#offer=";
|
||||||
const idx = url.indexOf(marker);
|
const idx = url.indexOf(marker);
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
throw new Error(`missing ${marker} fragment: ${url}`);
|
throw new Error(`missing ${marker} fragment: ${url}`);
|
||||||
}
|
}
|
||||||
const encoded = url.slice(idx + marker.length);
|
const encoded = url.slice(idx + marker.length);
|
||||||
const json = Buffer.from(encoded, 'base64url').toString('utf8');
|
const json = Buffer.from(encoded, "base64url").toString("utf8");
|
||||||
const offer = JSON.parse(json) as Partial<OfferPayload>;
|
const offer = JSON.parse(json) as Partial<OfferPayload>;
|
||||||
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
|
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
|
||||||
if (!offer.serverId) throw new Error('offer.serverId missing');
|
if (!offer.serverId) throw new Error("offer.serverId missing");
|
||||||
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
|
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
|
||||||
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
|
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
|
||||||
return offer as OfferPayload;
|
return offer as OfferPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function globalSetup() {
|
export default async function globalSetup() {
|
||||||
const repoRoot = path.resolve(__dirname, '../../..');
|
const repoRoot = path.resolve(__dirname, "../../..");
|
||||||
const envTestPath = path.join(repoRoot, '.env.test');
|
ensureRelayBuildArtifact(repoRoot);
|
||||||
|
const envTestPath = path.join(repoRoot, ".env.test");
|
||||||
if (existsSync(envTestPath)) {
|
if (existsSync(envTestPath)) {
|
||||||
dotenv.config({ path: envTestPath });
|
dotenv.config({ path: envTestPath });
|
||||||
}
|
}
|
||||||
@@ -225,13 +235,17 @@ export default async function globalSetup() {
|
|||||||
const port = await getAvailablePort();
|
const port = await getAvailablePort();
|
||||||
let relayPort = 0;
|
let relayPort = 0;
|
||||||
const metroPort = await getAvailablePort();
|
const metroPort = await getAvailablePort();
|
||||||
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
||||||
let relayLineBuffer = createLineBuffer();
|
let relayLineBuffer = createLineBuffer();
|
||||||
const metroLineBuffer = createLineBuffer();
|
const metroLineBuffer = createLineBuffer();
|
||||||
const daemonLineBuffer = createLineBuffer();
|
const daemonLineBuffer = createLineBuffer();
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
|
await Promise.all([
|
||||||
|
stopProcess(daemonProcess),
|
||||||
|
stopProcess(metroProcess),
|
||||||
|
stopProcess(relayProcess),
|
||||||
|
]);
|
||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
metroProcess = null;
|
metroProcess = null;
|
||||||
relayProcess = null;
|
relayProcess = null;
|
||||||
@@ -242,24 +256,30 @@ export default async function globalSetup() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||||
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
|
const defaultLocalModelsDir = path.join(
|
||||||
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
process.env.HOME ?? "",
|
||||||
const dictationProvider = openAiUsable ? 'openai' : 'local';
|
".paseo",
|
||||||
|
"models",
|
||||||
|
"local-speech",
|
||||||
|
);
|
||||||
|
const hasDefaultLocalModelsDir =
|
||||||
|
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||||
|
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||||
|
|
||||||
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
|
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
|
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
|
||||||
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
|
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
|
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||||
console.log(
|
console.log(
|
||||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
|
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||||
const maxRelayStartupAttempts = 5;
|
const maxRelayStartupAttempts = 5;
|
||||||
let relayStarted = false;
|
let relayStarted = false;
|
||||||
let lastRelayStartupError: unknown = null;
|
let lastRelayStartupError: unknown = null;
|
||||||
@@ -271,18 +291,21 @@ export default async function globalSetup() {
|
|||||||
let relayReadyForSelectedPort = false;
|
let relayReadyForSelectedPort = false;
|
||||||
|
|
||||||
relayProcess = spawn(
|
relayProcess = spawn(
|
||||||
'npx',
|
"npx",
|
||||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||||
{
|
{
|
||||||
cwd: relayDir,
|
cwd: relayDir,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
relayLineBuffer.add(`[stdout] ${line}`);
|
relayLineBuffer.add(`[stdout] ${line}`);
|
||||||
const failure = parseRelayStartupFailure(line);
|
const failure = parseRelayStartupFailure(line);
|
||||||
@@ -297,8 +320,11 @@ export default async function globalSetup() {
|
|||||||
console.log(`[relay] ${line}`);
|
console.log(`[relay] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
relayProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
relayLineBuffer.add(`[stderr] ${line}`);
|
relayLineBuffer.add(`[stderr] ${line}`);
|
||||||
const failure = parseRelayStartupFailure(line);
|
const failure = parseRelayStartupFailure(line);
|
||||||
@@ -316,7 +342,7 @@ export default async function globalSetup() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForServer(relayPort, {
|
await waitForServer(relayPort, {
|
||||||
label: 'Relay dev server',
|
label: "Relay dev server",
|
||||||
timeoutMs: 30000,
|
timeoutMs: 30000,
|
||||||
childProcess: relayProcess,
|
childProcess: relayProcess,
|
||||||
getRecentOutput: relayLineBuffer.dump,
|
getRecentOutput: relayLineBuffer.dump,
|
||||||
@@ -339,15 +365,15 @@ export default async function globalSetup() {
|
|||||||
if (!relayReadyForSelectedPort) {
|
if (!relayReadyForSelectedPort) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||||
relayLineBuffer.dump
|
relayLineBuffer.dump,
|
||||||
)}`
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
||||||
relayLineBuffer.dump
|
relayLineBuffer.dump,
|
||||||
)}`
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,40 +392,46 @@ export default async function globalSetup() {
|
|||||||
? lastRelayStartupError.message
|
? lastRelayStartupError.message
|
||||||
: String(lastRelayStartupError);
|
: String(lastRelayStartupError);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
|
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start Metro bundler on dynamic port
|
// Start Metro bundler on dynamic port
|
||||||
const appDir = path.resolve(__dirname, '..');
|
const appDir = path.resolve(__dirname, "..");
|
||||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
BROWSER: 'none', // Don't auto-open browser
|
BROWSER: "none", // Don't auto-open browser
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
metroProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
metroLineBuffer.add(`[stdout] ${line}`);
|
metroLineBuffer.add(`[stdout] ${line}`);
|
||||||
console.log(`[metro] ${line}`);
|
console.log(`[metro] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
metroProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
metroLineBuffer.add(`[stderr] ${line}`);
|
metroLineBuffer.add(`[stderr] ${line}`);
|
||||||
console.error(`[metro] ${line}`);
|
console.error(`[metro] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||||
const tsxBin = execSync('which tsx').toString().trim();
|
const tsxBin = execSync("which tsx").toString().trim();
|
||||||
|
|
||||||
let offerPayload: OfferPayload | null = null;
|
let offerPayload: OfferPayload | null = null;
|
||||||
let offerResolve: (() => void) | null = null;
|
let offerResolve: (() => void) | null = null;
|
||||||
@@ -407,33 +439,36 @@ export default async function globalSetup() {
|
|||||||
offerResolve = resolve;
|
offerResolve = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
PASEO_HOME: paseoHome,
|
PASEO_HOME: paseoHome,
|
||||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||||
PASEO_DICTATION_ENABLED: '1',
|
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||||
PASEO_VOICE_MODE_ENABLED: '1',
|
...(openAiUsable
|
||||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
? {
|
||||||
PASEO_VOICE_STT_PROVIDER: 'openai',
|
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||||
PASEO_VOICE_TTS_PROVIDER: 'openai',
|
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||||
|
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: "development",
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdoutBuffer = '';
|
let stdoutBuffer = "";
|
||||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
daemonProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
stdoutBuffer += data.toString('utf8');
|
stdoutBuffer += data.toString("utf8");
|
||||||
const lines = stdoutBuffer.split('\n');
|
const lines = stdoutBuffer.split("\n");
|
||||||
stdoutBuffer = lines.pop() ?? '';
|
stdoutBuffer = lines.pop() ?? "";
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
@@ -442,13 +477,13 @@ export default async function globalSetup() {
|
|||||||
const clean = stripAnsi(trimmed);
|
const clean = stripAnsi(trimmed);
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||||
offerResolve?.();
|
offerResolve?.();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||||
if (match && clean.includes('pairing_offer')) {
|
if (match && clean.includes("pairing_offer")) {
|
||||||
try {
|
try {
|
||||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||||
offerResolve?.();
|
offerResolve?.();
|
||||||
@@ -462,8 +497,11 @@ export default async function globalSetup() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
daemonProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
daemonLineBuffer.add(`[stderr] ${line}`);
|
daemonLineBuffer.add(`[stderr] ${line}`);
|
||||||
console.error(`[daemon] ${line}`);
|
console.error(`[daemon] ${line}`);
|
||||||
@@ -473,12 +511,12 @@ export default async function globalSetup() {
|
|||||||
// Wait for both daemon and Metro to be ready
|
// Wait for both daemon and Metro to be ready
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
waitForServer(port, {
|
waitForServer(port, {
|
||||||
label: 'Paseo daemon',
|
label: "Paseo daemon",
|
||||||
childProcess: daemonProcess,
|
childProcess: daemonProcess,
|
||||||
getRecentOutput: daemonLineBuffer.dump,
|
getRecentOutput: daemonLineBuffer.dump,
|
||||||
}),
|
}),
|
||||||
waitForServer(metroPort, {
|
waitForServer(metroPort, {
|
||||||
label: 'Metro web server',
|
label: "Metro web server",
|
||||||
timeoutMs: 120000, // Metro can take longer to start
|
timeoutMs: 120000, // Metro can take longer to start
|
||||||
childProcess: metroProcess,
|
childProcess: metroProcess,
|
||||||
getRecentOutput: metroLineBuffer.dump,
|
getRecentOutput: metroLineBuffer.dump,
|
||||||
@@ -489,11 +527,11 @@ export default async function globalSetup() {
|
|||||||
await Promise.race([
|
await Promise.race([
|
||||||
offerPromise,
|
offerPromise,
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
if (!offerPayload) {
|
if (!offerPayload) {
|
||||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
throw new Error("pairing_offer was not parsed from daemon logs");
|
||||||
}
|
}
|
||||||
const offer = offerPayload as OfferPayload;
|
const offer = offerPayload as OfferPayload;
|
||||||
|
|
||||||
@@ -502,11 +540,13 @@ export default async function globalSetup() {
|
|||||||
process.env.E2E_SERVER_ID = offer.serverId;
|
process.env.E2E_SERVER_ID = offer.serverId;
|
||||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||||
process.env.E2E_METRO_PORT = String(metroPort);
|
process.env.E2E_METRO_PORT = String(metroPort);
|
||||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
console.log(
|
||||||
|
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||||
|
);
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
console.log('[e2e] Test daemon stopped');
|
console.log("[e2e] Test daemon stopped");
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import {
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
buildHostWorkspaceAgentRoute,
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
} from "../../src/utils/host-routes";
|
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||||
|
|
||||||
@@ -37,10 +34,7 @@ export type DaemonClientInstance = {
|
|||||||
initialPrompt: string;
|
initialPrompt: string;
|
||||||
}): Promise<{ id: string }>;
|
}): Promise<{ id: string }>;
|
||||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||||
waitForFinish(
|
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||||
agentId: string,
|
|
||||||
timeout?: number
|
|
||||||
): Promise<{ status: string }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
function getDaemonWsUrl(): string {
|
||||||
@@ -66,17 +60,17 @@ function buildReplyBlock(label: string, lineCount = 14): string {
|
|||||||
}).join("\n");
|
}).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProtocolMessage(label: string): string {
|
function buildProtocolMessage(label: string, lineCount = 14): string {
|
||||||
return [
|
return [
|
||||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||||
"REPLY:",
|
"REPLY:",
|
||||||
buildReplyBlock(label),
|
buildReplyBlock(label, lineCount),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReplyMessage(label: string): string {
|
function buildReplyMessage(label: string, lineCount = 14): string {
|
||||||
return ["REPLY:", buildReplyBlock(label)].join("\n");
|
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReplyTurn(label: string): {
|
export function createReplyTurn(label: string): {
|
||||||
@@ -89,14 +83,16 @@ export function createReplyTurn(label: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<new (config: {
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
url: string;
|
new (config: {
|
||||||
clientId: string;
|
url: string;
|
||||||
clientType: "cli";
|
clientId: string;
|
||||||
}) => DaemonClientInstance> {
|
clientType: "cli";
|
||||||
|
}) => DaemonClientInstance
|
||||||
|
> {
|
||||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js")
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
).href;
|
).href;
|
||||||
const mod = (await import(moduleUrl)) as {
|
const mod = (await import(moduleUrl)) as {
|
||||||
DaemonClient: new (config: {
|
DaemonClient: new (config: {
|
||||||
@@ -124,9 +120,11 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
turnCount?: number;
|
turnCount?: number;
|
||||||
|
lineCount?: number;
|
||||||
}): Promise<SeededAgent> {
|
}): Promise<SeededAgent> {
|
||||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||||
|
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||||
const created = await input.client.createAgent({
|
const created = await input.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: "gpt-5.1-codex-mini",
|
model: "gpt-5.1-codex-mini",
|
||||||
@@ -134,24 +132,24 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
title,
|
title,
|
||||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`),
|
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
||||||
});
|
});
|
||||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||||
if (initialFinish.status !== "idle") {
|
if (initialFinish.status !== "idle") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`
|
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`);
|
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
||||||
for (let index = 1; index < turnCount; index += 1) {
|
for (let index = 1; index < turnCount; index += 1) {
|
||||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||||
expectedTailText = buildReplyBlock(label);
|
expectedTailText = buildReplyBlock(label, lineCount);
|
||||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label));
|
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
||||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||||
if (finish.status !== "idle") {
|
if (finish.status !== "idle") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`
|
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,29 +158,37 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
id: created.id,
|
id: created.id,
|
||||||
title,
|
title,
|
||||||
expectedTailText,
|
expectedTailText,
|
||||||
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
|
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
|
||||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibleChatScroll(page: Page) {
|
||||||
|
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||||
|
}
|
||||||
|
|
||||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||||
const rootElement = root as HTMLElement;
|
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||||||
|
.filter((element) => {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const isEditable =
|
||||||
|
tagName === "textarea" ||
|
||||||
|
tagName === "input" ||
|
||||||
|
element.getAttribute("contenteditable") === "true";
|
||||||
|
return !isEditable && element.scrollHeight - element.clientHeight > 1;
|
||||||
|
});
|
||||||
const scrollElement =
|
const scrollElement =
|
||||||
candidates.find(
|
candidates.sort(
|
||||||
(element) =>
|
(left, right) =>
|
||||||
element instanceof HTMLElement &&
|
right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
|
||||||
element.scrollHeight - element.clientHeight > 1
|
)[0] ?? (root as HTMLElement);
|
||||||
) ?? rootElement;
|
|
||||||
|
|
||||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||||
const distanceFromBottom = Math.max(
|
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
|
||||||
0,
|
|
||||||
contentHeight - (offsetY + viewportHeight)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
offsetY,
|
offsetY,
|
||||||
@@ -194,29 +200,33 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
const scrollViewport = getVisibleChatScroll(page);
|
||||||
(root: Element, amount: number) => {
|
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
||||||
const rootElement = root as HTMLElement;
|
let remaining = Math.max(0, pixels);
|
||||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
while (remaining > 0) {
|
||||||
const scrollElement =
|
const delta = Math.min(240, remaining);
|
||||||
candidates.find(
|
await scrollViewport.evaluate((element: Element, step: number) => {
|
||||||
(element) =>
|
const scrollContainer = element as HTMLElement;
|
||||||
element instanceof HTMLElement &&
|
scrollContainer.dispatchEvent(
|
||||||
element.scrollHeight - element.clientHeight > 1
|
new WheelEvent("wheel", {
|
||||||
) ?? rootElement;
|
deltaY: -step,
|
||||||
|
bubbles: true,
|
||||||
const bottomOffset = Math.max(
|
cancelable: true,
|
||||||
0,
|
}),
|
||||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
|
||||||
);
|
);
|
||||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||||
},
|
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||||
pixels
|
}, delta);
|
||||||
);
|
remaining -= delta;
|
||||||
|
|
||||||
|
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 });
|
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
});
|
});
|
||||||
@@ -251,7 +261,7 @@ export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function waitForContentGrowth(
|
export async function waitForContentGrowth(
|
||||||
page: Page,
|
page: Page,
|
||||||
previousContentHeight: number
|
previousContentHeight: number,
|
||||||
): Promise<ScrollMetrics> {
|
): Promise<ScrollMetrics> {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -263,11 +273,9 @@ export async function waitForContentGrowth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||||
return page
|
return getVisibleChatScroll(page).evaluate((element) => {
|
||||||
.getByTestId("agent-chat-scroll")
|
const nativeId = (element as HTMLElement).id;
|
||||||
.evaluate((element) => {
|
const prefix = "agent-chat-scroll-";
|
||||||
const nativeId = (element as HTMLElement).id;
|
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||||
const prefix = "agent-chat-scroll-";
|
});
|
||||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+214
-173
@@ -1,16 +1,19 @@
|
|||||||
import { expect, type Page } from '@playwright/test';
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getE2EDaemonPort(): string {
|
function getE2EDaemonPort(): string {
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
const port = process.env.E2E_DAEMON_PORT;
|
||||||
if (!port) {
|
if (!port) {
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).');
|
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||||
}
|
}
|
||||||
if (port === '6767') {
|
if (port === "6767") {
|
||||||
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
|
throw new Error(
|
||||||
|
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
@@ -20,67 +23,58 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||||
if (!expectedServerId) {
|
if (!expectedServerId) {
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => {
|
const needsReset = await page.evaluate(
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
({ expectedEndpoint, expectedServerId }) => {
|
||||||
if (!raw) return true;
|
const raw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
try {
|
if (!raw) return true;
|
||||||
const parsed = JSON.parse(raw);
|
try {
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
const parsed = JSON.parse(raw);
|
||||||
const entry = parsed[0] as any;
|
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||||
if (entry?.serverId !== expectedServerId) return true;
|
const entry = parsed[0] as any;
|
||||||
const connections = entry?.connections;
|
if (entry?.serverId !== expectedServerId) return true;
|
||||||
if (!Array.isArray(connections)) return true;
|
const connections = entry?.connections;
|
||||||
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
if (!Array.isArray(connections)) return true;
|
||||||
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
|
if (
|
||||||
} catch {
|
connections.some(
|
||||||
return true;
|
(c: any) =>
|
||||||
}
|
c?.type === "directTcp" &&
|
||||||
}, { expectedEndpoint, expectedServerId });
|
typeof c?.endpoint === "string" &&
|
||||||
|
/:6767\b/.test(c.endpoint),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return !connections.some(
|
||||||
|
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ expectedEndpoint, expectedServerId },
|
||||||
|
);
|
||||||
|
|
||||||
if (!needsReset) {
|
if (!needsReset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
|
const daemon = buildSeededHost({
|
||||||
|
serverId: expectedServerId,
|
||||||
|
endpoint: expectedEndpoint,
|
||||||
|
nowIso,
|
||||||
|
});
|
||||||
|
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ expectedEndpoint, nowIso, expectedServerId }) => {
|
({ daemon, preferences }) => {
|
||||||
localStorage.setItem('@paseo:e2e', '1');
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem(
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
'@paseo:daemon-registry',
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
JSON.stringify([
|
localStorage.removeItem("@paseo:settings");
|
||||||
{
|
|
||||||
serverId: expectedServerId,
|
|
||||||
label: 'localhost',
|
|
||||||
connections: [
|
|
||||||
{
|
|
||||||
id: `direct:${expectedEndpoint}`,
|
|
||||||
type: 'direct',
|
|
||||||
endpoint: expectedEndpoint,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:${expectedEndpoint}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
);
|
|
||||||
localStorage.setItem(
|
|
||||||
'@paseo:create-agent-preferences',
|
|
||||||
JSON.stringify({
|
|
||||||
serverId: expectedServerId,
|
|
||||||
provider: 'codex',
|
|
||||||
providerPreferences: {
|
|
||||||
claude: { model: 'haiku' },
|
|
||||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
},
|
},
|
||||||
{ expectedEndpoint, nowIso, expectedServerId }
|
{ daemon, preferences },
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -91,77 +85,116 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
|||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||||
if (!expectedServerId) {
|
if (!expectedServerId) {
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await page.evaluate(() => {
|
const snapshot = await page.evaluate(() => {
|
||||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||||
return { registryRaw, prefsRaw };
|
return { registryRaw, prefsRaw };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!snapshot.registryRaw) {
|
if (!snapshot.registryRaw) {
|
||||||
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.');
|
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let registry: any;
|
let registry: any;
|
||||||
try {
|
try {
|
||||||
registry = JSON.parse(snapshot.registryRaw);
|
registry = JSON.parse(snapshot.registryRaw);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
|
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
|
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const daemon = registry[0];
|
const daemon = registry[0];
|
||||||
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) {
|
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
||||||
throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`);
|
throw new Error(
|
||||||
|
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (daemon.serverId !== expectedServerId) {
|
if (daemon.serverId !== expectedServerId) {
|
||||||
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
|
throw new Error(
|
||||||
|
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const connections: unknown = daemon?.connections;
|
const connections: unknown = daemon?.connections;
|
||||||
if (
|
if (
|
||||||
!Array.isArray(connections) ||
|
!Array.isArray(connections) ||
|
||||||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
|
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
if (
|
||||||
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
|
Array.isArray(connections) &&
|
||||||
|
connections.some(
|
||||||
|
(c: any) =>
|
||||||
|
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!snapshot.prefsRaw) {
|
if (!snapshot.prefsRaw) {
|
||||||
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
|
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
||||||
if (prefs?.serverId !== daemon.serverId) {
|
if (prefs?.serverId !== daemon.serverId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`
|
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) throw error;
|
if (error instanceof Error) throw error;
|
||||||
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
|
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const gotoHome = async (page: Page) => {
|
export const gotoAppShell = async (page: Page) => {
|
||||||
await page.goto('/');
|
await page.goto("/");
|
||||||
await ensureE2EStorageSeeded(page);
|
await ensureE2EStorageSeeded(page);
|
||||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
};
|
||||||
const composer = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
if (!(await composer.first().isVisible().catch(() => false))) {
|
export const gotoHome = async (page: Page) => {
|
||||||
const newAgentButton = page.getByText('New agent', { exact: true }).first();
|
await gotoAppShell(page);
|
||||||
await newAgentButton.click();
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
if (
|
||||||
|
!(await composer
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false))
|
||||||
|
) {
|
||||||
|
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
|
||||||
|
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
|
||||||
|
const newAgentButton = page.getByText("New agent", { exact: true }).first();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||||
|
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||||
|
(await newAgentButton.isVisible().catch(() => false)),
|
||||||
|
{ timeout: 10000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||||
|
await addProjectCta.click();
|
||||||
|
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||||
|
await addProjectSidebar.click();
|
||||||
|
} else {
|
||||||
|
await newAgentButton.click();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||||
};
|
};
|
||||||
@@ -169,7 +202,7 @@ export const gotoHome = async (page: Page) => {
|
|||||||
export const openSettings = async (page: Page) => {
|
export const openSettings = async (page: Page) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
||||||
@@ -185,23 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
.first();
|
.first();
|
||||||
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
|
||||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
const worktreePicker = page.getByTestId("worktree-attach-picker");
|
||||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
|
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
|
||||||
const closeBottomSheet = async () => {
|
const closeBottomSheet = async () => {
|
||||||
const bottomSheetBackdrop = page
|
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||||
.getByRole('button', { name: 'Bottom sheet backdrop' })
|
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
|
||||||
.first();
|
|
||||||
const bottomSheetHandle = page
|
|
||||||
.getByRole('slider', { name: 'Bottom sheet handle' })
|
|
||||||
.first();
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
if (!(await bottomSheetBackdrop.isVisible())) {
|
if (!(await bottomSheetBackdrop.isVisible())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await bottomSheetBackdrop.click({ force: true });
|
await bottomSheetBackdrop.click({ force: true });
|
||||||
await page.keyboard.press('Escape').catch(() => undefined);
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
if (await bottomSheetBackdrop.isVisible()) {
|
if (await bottomSheetBackdrop.isVisible()) {
|
||||||
@@ -221,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const attachToggle = page.getByTestId('worktree-attach-toggle');
|
const attachToggle = page.getByTestId("worktree-attach-toggle");
|
||||||
if (await attachToggle.isVisible()) {
|
if (await attachToggle.isVisible()) {
|
||||||
await attachToggle.click({ force: true });
|
await attachToggle.click({ force: true });
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
@@ -242,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
await closeBottomSheet();
|
await closeBottomSheet();
|
||||||
await workingDirectorySelect.click({ force: true });
|
await workingDirectorySelect.click({ force: true });
|
||||||
}
|
}
|
||||||
await expect
|
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
|
||||||
.poll(async () => pickerInputVisible(), { timeout: 10000 })
|
|
||||||
.toBe(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedDirectory = directory.replace(/\/+$/, '');
|
const trimmedDirectory = directory.replace(/\/+$/, "");
|
||||||
const activeInput =
|
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
|
||||||
(await directorySearchInput.isVisible().catch(() => false))
|
? directorySearchInput
|
||||||
? directorySearchInput
|
: legacyInput;
|
||||||
: legacyInput;
|
|
||||||
|
|
||||||
await activeInput.fill(trimmedDirectory);
|
await activeInput.fill(trimmedDirectory);
|
||||||
|
|
||||||
if (activeInput === directorySearchInput) {
|
if (activeInput === directorySearchInput) {
|
||||||
// Combobox custom rows can be either plain path labels or prefixed labels.
|
// Combobox custom rows can be either plain path labels or prefixed labels.
|
||||||
const plainOption = page
|
const plainOption = page
|
||||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
|
||||||
.first();
|
.first();
|
||||||
const prefixedUseOption = page
|
const prefixedUseOption = page
|
||||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
|
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (await plainOption.isVisible().catch(() => false)) {
|
if (await plainOption.isVisible().catch(() => false)) {
|
||||||
@@ -270,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
await prefixedUseOption.click({ force: true });
|
await prefixedUseOption.click({ force: true });
|
||||||
} else {
|
} else {
|
||||||
// Fallback: accept highlighted option (directory suggestion).
|
// Fallback: accept highlighted option (directory suggestion).
|
||||||
await activeInput.press('Enter');
|
await activeInput.press("Enter");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Legacy path picker fallback.
|
// Legacy path picker fallback.
|
||||||
await activeInput.press('Enter');
|
await activeInput.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for picker to close.
|
// Wait for picker to close.
|
||||||
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||||
if (trimmedDirectory.startsWith('/var/')) {
|
if (trimmedDirectory.startsWith("/var/")) {
|
||||||
directoryCandidates.add(`/private${trimmedDirectory}`);
|
directoryCandidates.add(`/private${trimmedDirectory}`);
|
||||||
}
|
}
|
||||||
if (trimmedDirectory.startsWith('/private/var/')) {
|
if (trimmedDirectory.startsWith("/private/var/")) {
|
||||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
|
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
|
||||||
}
|
}
|
||||||
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
|
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
|
||||||
|
|
||||||
await expect.poll(async () => {
|
await expect
|
||||||
const text = await workingDirectorySelect.innerText().catch(() => '');
|
.poll(
|
||||||
if (text.includes(basename)) return true;
|
async () => {
|
||||||
for (const candidate of directoryCandidates) {
|
const text = await workingDirectorySelect.innerText().catch(() => "");
|
||||||
if (text.includes(candidate)) return true;
|
if (text.includes(basename)) return true;
|
||||||
}
|
for (const candidate of directoryCandidates) {
|
||||||
return false;
|
if (text.includes(candidate)) return true;
|
||||||
}, { timeout: 30000 }).toBe(true);
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ensureHostSelected = async (page: Page) => {
|
export const ensureHostSelected = async (page: Page) => {
|
||||||
@@ -314,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fix = await page.evaluate(() => {
|
const fix = await page.evaluate(() => {
|
||||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
|
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
||||||
const registry = JSON.parse(registryRaw) as any[];
|
const registry = JSON.parse(registryRaw) as any[];
|
||||||
const prefs = JSON.parse(prefsRaw) as any;
|
const prefs = JSON.parse(prefsRaw) as any;
|
||||||
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
|
if (!Array.isArray(registry) || registry.length !== 1)
|
||||||
|
return { ok: false, reason: "registry shape" } as const;
|
||||||
const serverId = registry[0]?.serverId;
|
const serverId = registry[0]?.serverId;
|
||||||
if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const;
|
if (typeof serverId !== "string" || serverId.length === 0)
|
||||||
|
return { ok: false, reason: "missing serverId" } as const;
|
||||||
prefs.serverId = serverId;
|
prefs.serverId = serverId;
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
|
||||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||||
return { ok: true } as const;
|
return { ok: true } as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
await assertE2EUsesSeededTestDaemon(page);
|
await assertE2EUsesSeededTestDaemon(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
if (await input.isEditable()) {
|
if (await input.isEditable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectHostLabel = page.getByText('Select host', { exact: true });
|
const selectHostLabel = page.getByText("Select host", { exact: true });
|
||||||
if (await selectHostLabel.isVisible()) {
|
if (await selectHostLabel.isVisible()) {
|
||||||
await selectHostLabel.click();
|
await selectHostLabel.click();
|
||||||
|
|
||||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
||||||
const localhostOption = page.getByText('localhost', { exact: true }).first();
|
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||||
const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first();
|
const daemonIdOption = page
|
||||||
|
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||||
|
.first();
|
||||||
|
|
||||||
if (await localhostOption.isVisible()) {
|
if (await localhostOption.isVisible()) {
|
||||||
await localhostOption.click();
|
await localhostOption.click();
|
||||||
@@ -365,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createAgent = async (page: Page, message: string) => {
|
export const createAgent = async (page: Page, message: string) => {
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeEditable();
|
await expect(input).toBeEditable();
|
||||||
await preferFastThinkingOption(page);
|
await preferFastThinkingOption(page);
|
||||||
await input.fill(message);
|
await input.fill(message);
|
||||||
await input.press('Enter');
|
await input.press("Enter");
|
||||||
|
|
||||||
// The composer may remain on the draft screen briefly while the initial run starts,
|
// The composer may remain on the draft screen briefly while the initial run starts,
|
||||||
// so assert the user-visible result instead of forcing one route shape here.
|
// so assert the user-visible result instead of forcing one route shape here.
|
||||||
@@ -381,21 +416,23 @@ export const createAgent = async (page: Page, message: string) => {
|
|||||||
|
|
||||||
async function preferFastThinkingOption(page: Page): Promise<void> {
|
async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||||
const providerTrigger = page
|
const providerTrigger = page
|
||||||
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
|
.locator(
|
||||||
|
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||||
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
|
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
|
||||||
if (!/codex/i.test(providerText)) {
|
if (!/codex/i.test(providerText)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
|
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
|
||||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
|
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
||||||
@@ -403,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await thinkingTrigger.click();
|
await thinkingTrigger.click();
|
||||||
const menu = page.getByTestId('agent-thinking-menu').first();
|
const menu = page.getByTestId("agent-thinking-menu").first();
|
||||||
if (!(await menu.isVisible().catch(() => false))) {
|
if (!(await menu.isVisible().catch(() => false))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
|
const preferredLabels = ["low", "minimal", "off", "medium"];
|
||||||
let selected = false;
|
let selected = false;
|
||||||
for (const label of preferredLabels) {
|
for (const label of preferredLabels) {
|
||||||
const option = menu
|
const option = menu
|
||||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
|
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
|
||||||
.first();
|
.first();
|
||||||
if (await option.isVisible().catch(() => false)) {
|
if (await option.isVisible().catch(() => false)) {
|
||||||
await option.click({ force: true });
|
await option.click({ force: true });
|
||||||
@@ -422,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
const options = menu.getByRole('button');
|
const options = menu.getByRole("button");
|
||||||
const count = await options.count();
|
const count = await options.count();
|
||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
const option = options.nth(index);
|
const option = options.nth(index);
|
||||||
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
|
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
|
||||||
if (!label) {
|
if (!label) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -440,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
await page.keyboard.press('Escape').catch(() => undefined);
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,15 +495,17 @@ export interface AgentConfig {
|
|||||||
export const selectProvider = async (page: Page, provider: string) => {
|
export const selectProvider = async (page: Page, provider: string) => {
|
||||||
const normalizedProvider = provider.trim();
|
const normalizedProvider = provider.trim();
|
||||||
if (!normalizedProvider) {
|
if (!normalizedProvider) {
|
||||||
throw new Error('Provider must be a non-empty string.');
|
throw new Error("Provider must be a non-empty string.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerTrigger = page
|
const providerTrigger = page
|
||||||
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
|
.locator(
|
||||||
|
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
if (
|
if (
|
||||||
await providerTrigger
|
await providerTrigger
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
|
||||||
.first()
|
.first()
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
@@ -477,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => {
|
|||||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||||
await providerTrigger.click();
|
await providerTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
|
||||||
await expect(providerLabel).toBeVisible();
|
await expect(providerLabel).toBeVisible();
|
||||||
await providerLabel.click();
|
await providerLabel.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog').last();
|
const dialog = page.getByRole("dialog").last();
|
||||||
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
|
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
if (await searchInput.isVisible().catch(() => false)) {
|
||||||
await searchInput.fill(normalizedProvider);
|
await searchInput.fill(normalizedProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
const option = dialog
|
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
|
||||||
.first();
|
|
||||||
await expect(option).toBeVisible();
|
await expect(option).toBeVisible();
|
||||||
await option.click();
|
await option.click();
|
||||||
};
|
};
|
||||||
@@ -498,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => {
|
|||||||
export const selectModel = async (page: Page, model: string) => {
|
export const selectModel = async (page: Page, model: string) => {
|
||||||
const normalizedModel = model.trim();
|
const normalizedModel = model.trim();
|
||||||
if (!normalizedModel) {
|
if (!normalizedModel) {
|
||||||
throw new Error('Model must be a non-empty string.');
|
throw new Error("Model must be a non-empty string.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelTrigger = page
|
const modelTrigger = page
|
||||||
.locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible')
|
.locator(
|
||||||
|
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
if (
|
if (
|
||||||
await modelTrigger
|
await modelTrigger
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||||
.first()
|
.first()
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
@@ -517,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => {
|
|||||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||||
await modelTrigger.click();
|
await modelTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||||
await expect(modelLabel).toBeVisible();
|
await expect(modelLabel).toBeVisible();
|
||||||
await modelLabel.click();
|
await modelLabel.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the model dropdown to open
|
// Wait for the model dropdown to open
|
||||||
const searchInput = page.getByRole('textbox', { name: /search model/i });
|
const searchInput = page.getByRole("textbox", { name: /search model/i });
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Type to search/filter models
|
// Type to search/filter models
|
||||||
await searchInput.fill(normalizedModel);
|
await searchInput.fill(normalizedModel);
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole("dialog");
|
||||||
const exactOption = dialog
|
const exactOption = dialog
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||||
.first();
|
.first();
|
||||||
const exactVisible = await exactOption.isVisible().catch(() => false);
|
const exactVisible = await exactOption.isVisible().catch(() => false);
|
||||||
if (exactVisible) {
|
if (exactVisible) {
|
||||||
@@ -539,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => {
|
|||||||
} else {
|
} else {
|
||||||
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
||||||
// select the first filtered result using keyboard confirm.
|
// select the first filtered result using keyboard confirm.
|
||||||
await searchInput.press('Enter');
|
await searchInput.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for dropdown to close
|
// Wait for dropdown to close
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
if (await searchInput.isVisible().catch(() => false)) {
|
||||||
await page.keyboard.press('Escape').catch(() => undefined);
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
}
|
}
|
||||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectMode = async (page: Page, mode: string) => {
|
export const selectMode = async (page: Page, mode: string) => {
|
||||||
const modeTrigger = page
|
const modeTrigger = page
|
||||||
.locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible')
|
.locator(
|
||||||
|
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
if (await modeTrigger.isVisible().catch(() => false)) {
|
if (await modeTrigger.isVisible().catch(() => false)) {
|
||||||
await modeTrigger.click();
|
await modeTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
const modeLabel = page.getByText("MODE", { exact: true }).first();
|
||||||
await expect(modeLabel).toBeVisible();
|
await expect(modeLabel).toBeVisible();
|
||||||
await modeLabel.click();
|
await modeLabel.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the mode dropdown to open
|
// Wait for the mode dropdown to open
|
||||||
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Type to filter modes
|
// Type to filter modes
|
||||||
await searchInput.fill(mode);
|
await searchInput.fill(mode);
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole("dialog");
|
||||||
const option = dialog
|
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
|
||||||
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
|
|
||||||
.first();
|
|
||||||
await expect(option).toBeVisible();
|
await expect(option).toBeVisible();
|
||||||
await option.click({ force: true });
|
await option.click({ force: true });
|
||||||
|
|
||||||
@@ -601,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
|
|||||||
|
|
||||||
export const createAgentInRepo = async (
|
export const createAgentInRepo = async (
|
||||||
page: Page,
|
page: Page,
|
||||||
config: Pick<AgentConfig, 'directory' | 'prompt'>
|
config: Pick<AgentConfig, "directory" | "prompt">,
|
||||||
) => {
|
) => {
|
||||||
await gotoHome(page);
|
await gotoHome(page);
|
||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
@@ -610,25 +649,25 @@ export const createAgentInRepo = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||||
const promptText = page.getByTestId('permission-request-question').first();
|
const promptText = page.getByTestId("permission-request-question").first();
|
||||||
await expect(promptText).toBeVisible({ timeout });
|
await expect(promptText).toBeVisible({ timeout });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const allowPermission = async (page: Page) => {
|
export const allowPermission = async (page: Page) => {
|
||||||
const acceptButton = page.getByTestId('permission-request-accept').first();
|
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||||
await acceptButton.click();
|
await acceptButton.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const denyPermission = async (page: Page) => {
|
export const denyPermission = async (page: Page) => {
|
||||||
const denyButton = page.getByTestId('permission-request-deny').first();
|
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||||
await denyButton.click();
|
await denyButton.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||||
// Wait for the stop button to disappear
|
// Wait for the stop button to disappear
|
||||||
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
|
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||||
|
|
||||||
// First, let's debug what's happening - wait a bit to see the state
|
// First, let's debug what's happening - wait a bit to see the state
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
@@ -645,9 +684,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
|||||||
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
||||||
|
|
||||||
// Wait for the tool call result to appear
|
// Wait for the tool call result to appear
|
||||||
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
|
await expect(toolCallResult)
|
||||||
// If no specific message, just wait for the button to disappear
|
.toBeVisible({ timeout: 10000 })
|
||||||
});
|
.catch(() => {
|
||||||
|
// If no specific message, just wait for the button to disappear
|
||||||
|
});
|
||||||
|
|
||||||
// Now wait for the stop button to disappear
|
// Now wait for the stop button to disappear
|
||||||
await expect(stopButton).not.toBeVisible({ timeout });
|
await expect(stopButton).not.toBeVisible({ timeout });
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||||
|
import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
|
export type ArchiveTabAgent = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
cwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArchiveTabDaemonClient = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
createAgent(options: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
thinkingOptionId: string;
|
||||||
|
modeId: string;
|
||||||
|
cwd: string;
|
||||||
|
title: string;
|
||||||
|
initialPrompt: string;
|
||||||
|
}): Promise<{ id: string }>;
|
||||||
|
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||||
|
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaemonPort(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
if (daemonPort === "6767") {
|
||||||
|
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||||
|
}
|
||||||
|
return daemonPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSeededStoragePayload() {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
daemon: buildSeededHost({
|
||||||
|
serverId: getServerId(),
|
||||||
|
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
||||||
|
nowIso,
|
||||||
|
}),
|
||||||
|
preferences: buildCreateAgentPreferences(getServerId()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => ArchiveTabDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => ArchiveTabDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIdleAgent(
|
||||||
|
client: ArchiveTabDaemonClient,
|
||||||
|
input: { cwd: string; title: string },
|
||||||
|
): Promise<ArchiveTabAgent> {
|
||||||
|
const created = await client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
model: "gpt-5.1-codex-mini",
|
||||||
|
thinkingOptionId: "low",
|
||||||
|
modeId: "full-access",
|
||||||
|
cwd: input.cwd,
|
||||||
|
title: input.title,
|
||||||
|
initialPrompt: "Reply with exactly READY.",
|
||||||
|
});
|
||||||
|
const finished = await client.waitForFinish(created.id, 120_000);
|
||||||
|
if (finished.status !== "idle") {
|
||||||
|
throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
title: input.title,
|
||||||
|
cwd: input.cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveAgentFromDaemon(
|
||||||
|
client: ArchiveTabDaemonClient,
|
||||||
|
agentId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.archiveAgent(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||||
|
const seedNonce = randomUUID();
|
||||||
|
const { daemon, preferences } = buildSeededStoragePayload();
|
||||||
|
|
||||||
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||||
|
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||||
|
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||||
|
});
|
||||||
|
await page.addInitScript(
|
||||||
|
({ daemon, preferences, seedNonce }) => {
|
||||||
|
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||||
|
const disableValue = localStorage.getItem(disableOnceKey);
|
||||||
|
if (disableValue) {
|
||||||
|
localStorage.removeItem(disableOnceKey);
|
||||||
|
if (disableValue === seedNonce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
|
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||||
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
|
localStorage.removeItem("@paseo:settings");
|
||||||
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
|
},
|
||||||
|
{ daemon, preferences, seedNonce },
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetSeededPageState(page: Page): Promise<void> {
|
||||||
|
const { daemon, preferences } = buildSeededStoragePayload();
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(
|
||||||
|
({ daemon, preferences }) => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
|
localStorage.removeItem("@paseo:settings");
|
||||||
|
},
|
||||||
|
{ daemon, preferences },
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWorkspaceWithAgents(
|
||||||
|
page: Page,
|
||||||
|
agents: [ArchiveTabAgent, ArchiveTabAgent],
|
||||||
|
): Promise<void> {
|
||||||
|
const serverId = getServerId();
|
||||||
|
for (const agent of agents) {
|
||||||
|
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceArchiveOutcome(
|
||||||
|
page: Page,
|
||||||
|
input: { archivedAgentId: string; survivingAgentId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await expectWorkspaceTabHidden(page, input.archivedAgentId);
|
||||||
|
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const serverId = getServerId();
|
||||||
|
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSessions(page: Page): Promise<void> {
|
||||||
|
const sessionsButton = page.getByTestId("sidebar-sessions");
|
||||||
|
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await sessionsButton.click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionRowByTitle(page: Page, title: string) {
|
||||||
|
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
||||||
|
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
|
||||||
|
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveAgentFromSessions(
|
||||||
|
page: Page,
|
||||||
|
input: { agentId: string; title: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const row = getSessionRowByTitle(page, input.title);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
const box = await row.boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
const archiveButton = page.getByTestId("agent-action-archive").first();
|
||||||
|
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await archiveButton.click();
|
||||||
|
await expectSessionRowArchived(page, input.title);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export const TEST_HOST_LABEL = "localhost";
|
||||||
|
|
||||||
|
export const TEST_PROVIDER_PREFERENCES = {
|
||||||
|
claude: { model: "haiku" },
|
||||||
|
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function buildDirectTcpConnection(endpoint: string) {
|
||||||
|
return {
|
||||||
|
id: `direct:${endpoint}`,
|
||||||
|
type: "directTcp" as const,
|
||||||
|
endpoint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSeededHost(input: {
|
||||||
|
serverId: string;
|
||||||
|
endpoint: string;
|
||||||
|
label?: string;
|
||||||
|
nowIso: string;
|
||||||
|
}) {
|
||||||
|
const connection = buildDirectTcpConnection(input.endpoint);
|
||||||
|
return {
|
||||||
|
serverId: input.serverId,
|
||||||
|
label: input.label ?? TEST_HOST_LABEL,
|
||||||
|
connections: [connection],
|
||||||
|
preferredConnectionId: connection.id,
|
||||||
|
createdAt: input.nowIso,
|
||||||
|
updatedAt: input.nowIso,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCreateAgentPreferences(serverId: string) {
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
provider: "codex" as const,
|
||||||
|
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
|
export type TerminalPerfDaemonClient = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
createTerminal(
|
||||||
|
cwd: string,
|
||||||
|
name?: string,
|
||||||
|
): Promise<{
|
||||||
|
terminal: { id: string; name: string; cwd: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
subscribeTerminal(
|
||||||
|
terminalId: string,
|
||||||
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
|
sendTerminalInput(
|
||||||
|
terminalId: string,
|
||||||
|
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||||
|
): void;
|
||||||
|
onTerminalStreamEvent(
|
||||||
|
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||||
|
): () => void;
|
||||||
|
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => TerminalPerfDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `terminal-perf-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const route = buildHostWorkspaceRoute(serverId, cwd);
|
||||||
|
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const term = (window as any).__paseoTerminal;
|
||||||
|
if (!term) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const line = buf.getLine(i);
|
||||||
|
if (line) {
|
||||||
|
lines.push(line.translateToString(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForTerminalContent(
|
||||||
|
page: Page,
|
||||||
|
predicate: (text: string) => boolean,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeout;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const text = await getTerminalBufferText(page);
|
||||||
|
if (predicate(text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToTerminal(
|
||||||
|
page: Page,
|
||||||
|
input: { cwd: string; terminalId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
// Boot the app at the workspace route directly.
|
||||||
|
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||||
|
// so the daemon registry is already configured when the app starts.
|
||||||
|
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
|
||||||
|
await page.goto(workspaceRoute);
|
||||||
|
|
||||||
|
// Wait for daemon connection (sidebar shows host label)
|
||||||
|
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
|
// The workspace should now query listTerminals and discover our terminal.
|
||||||
|
// Click the terminal tab if it auto-appeared, or wait for it.
|
||||||
|
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!surfaceVisible) {
|
||||||
|
// Terminal tab might not be focused — look for it in the tab row and click it
|
||||||
|
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
|
||||||
|
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (tabExists) {
|
||||||
|
await terminalTab.click();
|
||||||
|
} else {
|
||||||
|
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
|
||||||
|
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
|
||||||
|
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
|
||||||
|
await newTerminalBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for terminal surface to be visible
|
||||||
|
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
|
// Wait for loading overlay to disappear (terminal attached)
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="terminal-attach-loading"]')
|
||||||
|
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||||
|
.catch(() => {
|
||||||
|
// overlay may never appear if attachment is instant
|
||||||
|
});
|
||||||
|
|
||||||
|
await terminalSurface.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||||
|
const tag = sentinel ?? `READY_${Date.now()}`;
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
|
||||||
|
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
|
||||||
|
|
||||||
|
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LatencySample = {
|
||||||
|
char: string;
|
||||||
|
latencyMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures keystroke echo round-trip latency.
|
||||||
|
*
|
||||||
|
* Starts a high-resolution timer on the browser keydown event (capture phase)
|
||||||
|
* and stops it when xterm.js finishes parsing the echoed write. This measures
|
||||||
|
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
|
||||||
|
*/
|
||||||
|
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const term = (window as any).__paseoTerminal;
|
||||||
|
if (!term) {
|
||||||
|
throw new Error("__paseoTerminal not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = ((window as any).__perfKeystroke = {
|
||||||
|
promise: null as Promise<number> | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.promise = new Promise<number>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
reject(new Error("keystroke echo timeout (5s)"));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
function onKeyDown() {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
const start = performance.now();
|
||||||
|
const disposable = term.onWriteParsed(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
disposable.dispose();
|
||||||
|
resolve(performance.now() - start);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press(char);
|
||||||
|
|
||||||
|
return page.evaluate(() => (window as any).__perfKeystroke.promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePercentile(samples: number[], p: number): number {
|
||||||
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
return sorted[Math.max(0, index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
|
|||||||
|
|
||||||
export async function sampleWorkspaceTabIds(
|
export async function sampleWorkspaceTabIds(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { durationMs?: number; intervalMs?: number } = {}
|
options: { durationMs?: number; intervalMs?: number } = {},
|
||||||
): Promise<string[][]> {
|
): Promise<string[][]> {
|
||||||
const durationMs = options.durationMs ?? 2_500;
|
const durationMs = options.durationMs ?? 2_500;
|
||||||
const intervalMs = options.intervalMs ?? 50;
|
const intervalMs = options.intervalMs ?? 50;
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
import { expect, type Page } from '@playwright/test';
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { gotoHome } from './app';
|
import { gotoHome } from "./app";
|
||||||
|
|
||||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||||
await gotoHome(page);
|
await gotoHome(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workspaceLabelFromPath(value: string): string {
|
export function workspaceLabelFromPath(value: string): string {
|
||||||
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const parts = normalized.split('/').filter(Boolean);
|
const parts = normalized.split("/").filter(Boolean);
|
||||||
return parts[parts.length - 1] ?? normalized;
|
return parts[parts.length - 1] ?? normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||||
const trimmed = inputPath.replace(/\/+$/, '');
|
const trimmed = inputPath.replace(/\/+$/, "");
|
||||||
const candidates = new Set<string>([trimmed]);
|
const candidates = new Set<string>([trimmed]);
|
||||||
if (trimmed.startsWith('/var/')) {
|
if (trimmed.startsWith("/var/")) {
|
||||||
candidates.add(`/private${trimmed}`);
|
candidates.add(`/private${trimmed}`);
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith('/private/var/')) {
|
if (trimmed.startsWith("/private/var/")) {
|
||||||
candidates.add(trimmed.replace(/^\/private/, ''));
|
candidates.add(trimmed.replace(/^\/private/, ""));
|
||||||
}
|
}
|
||||||
return Array.from(candidates);
|
return Array.from(candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
||||||
const ids = candidateWorkspaceIds(workspacePath).map(
|
const ids = candidateWorkspaceIds(workspacePath).map(
|
||||||
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`
|
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
|
||||||
);
|
);
|
||||||
return page.locator(ids.join(',')).first();
|
return page.locator(ids.join(",")).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function switchWorkspaceViaSidebar(input: {
|
export async function switchWorkspaceViaSidebar(input: {
|
||||||
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
|
|||||||
|
|
||||||
export async function expectWorkspaceHeader(
|
export async function expectWorkspaceHeader(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { title: string; subtitle: string }
|
input: { title: string; subtitle: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const titleLocator = page.getByTestId('workspace-header-title');
|
const titleLocator = page.getByTestId("workspace-header-title");
|
||||||
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
|
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
||||||
|
|
||||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
await input.fill(marker);
|
await input.fill(marker);
|
||||||
await input.press('Enter');
|
await input.press("Enter");
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from "node:child_process";
|
||||||
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
|
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
|
|
||||||
type TempRepo = {
|
type TempRepo = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -9,29 +9,29 @@ type TempRepo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createTempGitRepo = async (
|
export const createTempGitRepo = async (
|
||||||
prefix = 'paseo-e2e-',
|
prefix = "paseo-e2e-",
|
||||||
options?: { withRemote?: boolean }
|
options?: { withRemote?: boolean },
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||||
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
|
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||||
const withRemote = options?.withRemote ?? false;
|
const withRemote = options?.withRemote ?? false;
|
||||||
|
|
||||||
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
|
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
|
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||||
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||||
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||||
|
|
||||||
if (withRemote) {
|
if (withRemote) {
|
||||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||||
const remoteDir = path.join(repoPath, 'remote.git');
|
const remoteDir = path.join(repoPath, "remote.git");
|
||||||
await mkdir(remoteDir, { recursive: true });
|
await mkdir(remoteDir, { recursive: true });
|
||||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
|
|
||||||
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
|
||||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByText('Direct connection', { exact: true }).click();
|
|
||||||
|
|
||||||
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
|
|
||||||
|
|
||||||
await page.getByText('Connect', { exact: true }).click();
|
|
||||||
|
|
||||||
// First-time connection prompts for an optional label.
|
|
||||||
const nameModal = page.getByTestId('name-host-modal');
|
|
||||||
const showedNameModal = await nameModal
|
|
||||||
.waitFor({ state: 'visible', timeout: 5000 })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
if (showedNameModal) {
|
|
||||||
await nameModal.getByTestId('name-host-skip').click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
|
||||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
|
|
||||||
timeout: 15000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
|
|
||||||
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const seededServerId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!seededServerId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraPort = Number(daemonPort) + 1;
|
|
||||||
const extraEndpoint = `127.0.0.1:${extraPort}`;
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
|
|
||||||
const extraDaemon = {
|
|
||||||
serverId: 'srv_e2e_extra_daemon',
|
|
||||||
label: 'extra',
|
|
||||||
connections: [
|
|
||||||
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:${extraEndpoint}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
const seededTestDaemon = {
|
|
||||||
serverId: seededServerId,
|
|
||||||
label: 'localhost',
|
|
||||||
connections: [
|
|
||||||
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
// Add a second host once (fixtures seed the primary host on every navigation).
|
|
||||||
await page.addInitScript(
|
|
||||||
({ daemon, seededTestDaemon, seedOnceKey }) => {
|
|
||||||
if (localStorage.getItem(seedOnceKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
|
||||||
let parsed: any[] = [];
|
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
parsed = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const list = Array.isArray(parsed) ? parsed : [];
|
|
||||||
localStorage.setItem(seedOnceKey, '1');
|
|
||||||
|
|
||||||
const next = [...list];
|
|
||||||
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
|
|
||||||
if (!hasSeeded) {
|
|
||||||
next.push(seededTestDaemon);
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
|
|
||||||
if (!alreadyPresent) {
|
|
||||||
next.push(daemon);
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
|
|
||||||
},
|
|
||||||
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.goto('/settings');
|
|
||||||
|
|
||||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
|
||||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
|
||||||
|
|
||||||
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
|
|
||||||
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
|
|
||||||
await hostSettingsButton.click();
|
|
||||||
|
|
||||||
const hostDetailModal = page.getByTestId('host-detail-modal');
|
|
||||||
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
|
|
||||||
await hostDetailModal.getByText('Advanced', { exact: true }).click();
|
|
||||||
await page.getByText('Remove host', { exact: true }).last().click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
|
||||||
await page.getByTestId('remove-host-confirm').click();
|
|
||||||
|
|
||||||
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
|
||||||
await page.waitForFunction(
|
|
||||||
(serverId) => {
|
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
|
||||||
if (!raw) return false;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
extraDaemon.serverId,
|
|
||||||
{ timeout: 10000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prevent the fixture from overwriting storage on reload; verify persistence.
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
});
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { ensureHostSelected, gotoHome } from './helpers/app';
|
|
||||||
|
|
||||||
test('new agent auto-selects the previous host', async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
await gotoHome(page);
|
|
||||||
|
|
||||||
// The selected host should be restored after a full reload without manual selection.
|
|
||||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable({ timeout: 30000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('new agent respects serverId in the URL', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure this test's storage is deterministic even under parallel load.
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const testDaemon = {
|
|
||||||
serverId,
|
|
||||||
label: 'localhost',
|
|
||||||
connections: [
|
|
||||||
{
|
|
||||||
id: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
type: 'direct',
|
|
||||||
endpoint: `127.0.0.1:${daemonPort}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
const createAgentPreferences = {
|
|
||||||
serverId: testDaemon.serverId,
|
|
||||||
provider: 'codex',
|
|
||||||
providerPreferences: {
|
|
||||||
claude: { model: 'haiku' },
|
|
||||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.goto('/settings');
|
|
||||||
await page.evaluate(
|
|
||||||
({ daemon, preferences }) => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
},
|
|
||||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
|
||||||
);
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
|
||||||
|
|
||||||
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
|
|
||||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
|
||||||
|
|
||||||
const newAgentButton = page.getByTestId('sidebar-new-agent').first();
|
|
||||||
if (await newAgentButton.isVisible().catch(() => false)) {
|
|
||||||
await newAgentButton.click();
|
|
||||||
} else {
|
|
||||||
await page.getByText('New agent', { exact: true }).first().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable({ timeout: 30000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const testDaemon = {
|
|
||||||
serverId,
|
|
||||||
label: 'localhost',
|
|
||||||
connections: [
|
|
||||||
{
|
|
||||||
id: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
type: 'direct',
|
|
||||||
endpoint: `127.0.0.1:${daemonPort}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
await gotoHome(page);
|
|
||||||
await page.evaluate(
|
|
||||||
({ daemon }) => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
|
||||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
},
|
|
||||||
{ daemon: testDaemon }
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
|
||||||
|
|
||||||
// Host should be auto-selected (no manual selection required).
|
|
||||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable({ timeout: 30000 });
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { gotoHome } from "./helpers/app";
|
|
||||||
|
|
||||||
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
await page.getByTestId("menu-button").first().focus();
|
|
||||||
|
|
||||||
await page.keyboard.press("Shift+/");
|
|
||||||
|
|
||||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
|
||||||
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
|
|
||||||
|
|
||||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(content).toBeVisible({ timeout: 10000 });
|
|
||||||
await expect(content).toContainText("Show keyboard shortcuts");
|
|
||||||
await expect(content).toContainText("Toggle left sidebar");
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
|
|
||||||
test('manual host add accepts host:port only and persists a direct connection', async ({ page }) => {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override the default fixture seeding for this navigation (must run before app boot).
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
});
|
|
||||||
await page.goto('/settings');
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
|
|
||||||
return 'welcome';
|
|
||||||
}
|
|
||||||
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
|
|
||||||
return 'settings';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
{ timeout: 15000 }
|
|
||||||
)
|
|
||||||
.not.toBe('');
|
|
||||||
|
|
||||||
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
|
|
||||||
if (isWelcome) {
|
|
||||||
await page.getByText('Direct connection', { exact: true }).first().click();
|
|
||||||
} else {
|
|
||||||
await page.getByText('+ Add connection', { exact: true }).click();
|
|
||||||
await page.getByText('Direct connection', { exact: true }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = page.getByPlaceholder('host:6767');
|
|
||||||
await expect(input).toBeVisible();
|
|
||||||
await input.fill(`127.0.0.1:${daemonPort}`);
|
|
||||||
|
|
||||||
await page.getByText('Connect', { exact: true }).click();
|
|
||||||
|
|
||||||
const nameModal = page.getByTestId('name-host-modal');
|
|
||||||
if (await nameModal.isVisible().catch(() => false)) {
|
|
||||||
await nameModal.getByTestId('name-host-skip').click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
|
||||||
await expect(settingsButton).toBeVisible({ timeout: 10000 });
|
|
||||||
await settingsButton.click();
|
|
||||||
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
|
|
||||||
timeout: 15000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ port, serverId }) => {
|
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
|
||||||
if (!raw) return false;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
|
||||||
const entry = parsed[0];
|
|
||||||
return (
|
|
||||||
entry?.serverId === serverId &&
|
|
||||||
Array.isArray(entry?.connections) &&
|
|
||||||
entry.connections.some(
|
|
||||||
(conn: any) => conn?.type === 'direct' && conn?.endpoint === `127.0.0.1:${port}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ port: daemonPort, serverId },
|
|
||||||
{ timeout: 10000 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { Buffer } from 'node:buffer';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
function encodeBase64Url(input: string): string {
|
|
||||||
return Buffer.from(input, 'utf8')
|
|
||||||
.toString('base64')
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
|
|
||||||
const relayPort = process.env.E2E_RELAY_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
|
||||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
|
||||||
throw new Error(
|
|
||||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override the default fixture seeding for this test.
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
});
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
|
||||||
|
|
||||||
const offer = {
|
|
||||||
v: 2 as const,
|
|
||||||
serverId,
|
|
||||||
daemonPublicKeyB64,
|
|
||||||
relay: { endpoint: relayEndpoint },
|
|
||||||
};
|
|
||||||
|
|
||||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
|
||||||
|
|
||||||
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
|
|
||||||
if (await welcomeTitle.isVisible().catch(() => false)) {
|
|
||||||
await page.getByTestId('welcome-paste-pairing-link').click();
|
|
||||||
} else {
|
|
||||||
await page.getByText('+ Add connection', { exact: true }).click();
|
|
||||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
|
||||||
await expect(input).toBeVisible();
|
|
||||||
await input.fill(offerUrl);
|
|
||||||
|
|
||||||
await page.getByTestId('pair-link-submit').click();
|
|
||||||
|
|
||||||
const nameHostModal = page.getByTestId('name-host-modal');
|
|
||||||
if (await nameHostModal.isVisible().catch(() => false)) {
|
|
||||||
await nameHostModal.getByTestId('name-host-skip').click();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ expected }) => {
|
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
|
||||||
if (!raw) return false;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
|
|
||||||
const entry = parsed[0];
|
|
||||||
const relayId = `relay:${expected.relay.endpoint}`;
|
|
||||||
return (
|
|
||||||
entry?.serverId === expected.serverId &&
|
|
||||||
Array.isArray(entry?.connections) &&
|
|
||||||
entry.connections.length === 1 &&
|
|
||||||
entry.connections[0]?.id === relayId &&
|
|
||||||
entry.connections[0]?.type === 'relay' &&
|
|
||||||
entry.connections[0]?.relayEndpoint === expected.relay.endpoint &&
|
|
||||||
entry.connections[0]?.daemonPublicKeyB64 === expected.daemonPublicKeyB64
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ expected: offer },
|
|
||||||
{ timeout: 10000 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
|
|
||||||
test("pastes clipboard image into prompt attachments", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-paste-image-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await expect(input).toBeEditable();
|
|
||||||
await input.focus();
|
|
||||||
|
|
||||||
const result = await page.evaluate(() => {
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (!(active instanceof HTMLTextAreaElement)) {
|
|
||||||
return {
|
|
||||||
pasted: false,
|
|
||||||
elementTag: active ? active.tagName : null,
|
|
||||||
defaultPrevented: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
|
|
||||||
type: "image/png",
|
|
||||||
});
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
dataTransfer.items.add(file);
|
|
||||||
|
|
||||||
const event = new ClipboardEvent("paste", {
|
|
||||||
clipboardData: dataTransfer,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
active.dispatchEvent(event);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pasted: true,
|
|
||||||
elementTag: active.tagName,
|
|
||||||
defaultPrevented: event.defaultPrevented,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.pasted).toBe(true);
|
|
||||||
expect(result.defaultPrevented).toBe(true);
|
|
||||||
await expect(page.getByTestId("message-input-image-pill")).toHaveCount(1);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { test, expect } from './fixtures';
|
|
||||||
import {
|
|
||||||
createAgentWithConfig,
|
|
||||||
waitForPermissionPrompt,
|
|
||||||
allowPermission,
|
|
||||||
denyPermission,
|
|
||||||
waitForAgentFinishUI,
|
|
||||||
} from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
const FILE_CONTENT = 'Hello from permission test';
|
|
||||||
|
|
||||||
function buildWriteCommand(filePath: string): string {
|
|
||||||
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('permission prompts', () => {
|
|
||||||
test('allow permission creates the file', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
|
||||||
const filePath = path.join(repo.path, uniqueFilename);
|
|
||||||
const shellCommand = buildWriteCommand(filePath);
|
|
||||||
const prompt = [
|
|
||||||
`Use your shell tool to run exactly this command:`,
|
|
||||||
shellCommand,
|
|
||||||
`Do not write outside this exact path.`,
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentWithConfig(page, {
|
|
||||||
directory: repo.path,
|
|
||||||
provider: 'claude',
|
|
||||||
mode: 'Always Ask',
|
|
||||||
prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForPermissionPrompt(page, 30000);
|
|
||||||
|
|
||||||
await allowPermission(page);
|
|
||||||
|
|
||||||
// Wait for file to be created
|
|
||||||
await expect
|
|
||||||
.poll(() => existsSync(filePath), {
|
|
||||||
message: `File ${filePath} should exist after allowing permission`,
|
|
||||||
timeout: 30000,
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
// After allowing, the file should be created successfully
|
|
||||||
// The tool call count might still be 1 if the UI updates quickly
|
|
||||||
const fileContent = await readFile(filePath, 'utf-8');
|
|
||||||
expect(fileContent.trim()).toBe(FILE_CONTENT);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deny permission does not create the file', async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo();
|
|
||||||
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
|
||||||
const filePath = path.join(repo.path, uniqueFilename);
|
|
||||||
const shellCommand = buildWriteCommand(filePath);
|
|
||||||
const prompt = [
|
|
||||||
`Use your shell tool to run exactly this command:`,
|
|
||||||
shellCommand,
|
|
||||||
`Do not write outside this exact path.`,
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentWithConfig(page, {
|
|
||||||
directory: repo.path,
|
|
||||||
provider: 'claude',
|
|
||||||
mode: 'Always Ask',
|
|
||||||
prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForPermissionPrompt(page, 30000);
|
|
||||||
|
|
||||||
await denyPermission(page);
|
|
||||||
await waitForAgentFinishUI(page, 30000);
|
|
||||||
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
|
|
||||||
|
|
||||||
expect(existsSync(filePath)).toBe(false);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app';
|
|
||||||
|
|
||||||
test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => {
|
|
||||||
const nonExistentDir = '/non/existent/directory/that/does/not/exist';
|
|
||||||
const promptText = `Test prompt that should be preserved ${Date.now()}`;
|
|
||||||
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
await setWorkingDirectory(page, nonExistentDir);
|
|
||||||
|
|
||||||
// Enter prompt text
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
||||||
await expect(input).toBeEditable();
|
|
||||||
await input.fill(promptText);
|
|
||||||
|
|
||||||
// Try to submit - this should fail with an error about the directory not existing
|
|
||||||
await input.press('Enter');
|
|
||||||
|
|
||||||
// Verify error message is displayed (error includes the path)
|
|
||||||
await expect(page.getByText(/Working directory does not exist/)).toBeVisible();
|
|
||||||
|
|
||||||
// Verify the prompt text is still preserved in the input
|
|
||||||
await expect(input).toHaveValue(promptText);
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
test('connects via relay when direct endpoints fail', async ({ page }) => {
|
|
||||||
const relayPort = process.env.E2E_RELAY_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
|
||||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
|
||||||
throw new Error(
|
|
||||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
|
||||||
|
|
||||||
const host = {
|
|
||||||
serverId,
|
|
||||||
label: 'relay-daemon',
|
|
||||||
connections: [
|
|
||||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
|
||||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
|
||||||
],
|
|
||||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Override the default fixture seeding for this test.
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
await page.evaluate((daemon) => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
}, host);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Should eventually connect through the relay connection.
|
|
||||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
|
||||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
test('relay connection stays stable across multiple tabs', async ({ page }) => {
|
|
||||||
const relayPort = process.env.E2E_RELAY_PORT;
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
|
||||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
|
||||||
throw new Error(
|
|
||||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
|
||||||
|
|
||||||
const host = {
|
|
||||||
serverId,
|
|
||||||
label: 'relay-daemon',
|
|
||||||
connections: [
|
|
||||||
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
|
|
||||||
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
|
|
||||||
],
|
|
||||||
preferredConnectionId: 'direct:127.0.0.1:9',
|
|
||||||
createdAt: nowIso,
|
|
||||||
updatedAt: nowIso,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use relay by making the direct endpoint intentionally fail.
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
await page.evaluate((daemon) => {
|
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
|
||||||
localStorage.removeItem('@paseo:settings');
|
|
||||||
}, host);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
const card = page.getByTestId(`daemon-card-${serverId}`);
|
|
||||||
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
|
|
||||||
// Open a second tab. It should be able to connect independently without forcing disconnect churn.
|
|
||||||
const page2 = await page.context().newPage();
|
|
||||||
await page2.route(/:(6767)\b/, (route) => route.abort());
|
|
||||||
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
|
|
||||||
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
|
|
||||||
});
|
|
||||||
await page2.goto('/');
|
|
||||||
const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first();
|
|
||||||
await expect(settingsButton2).toBeVisible({ timeout: 20000 });
|
|
||||||
await settingsButton2.click();
|
|
||||||
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
|
|
||||||
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
|
|
||||||
|
|
||||||
// Stability window: keep both tabs open and ensure they remain online.
|
|
||||||
await page.waitForTimeout(30_000);
|
|
||||||
await expect(card.getByText('Online', { exact: true })).toBeVisible();
|
|
||||||
await expect(card2.getByText('Online', { exact: true })).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
|
|
||||||
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
|
|
||||||
const repoA = await createTempGitRepo();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page);
|
|
||||||
await ensureHostSelected(page);
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repoA.path);
|
|
||||||
await createAgent(page, 'Agent A: respond with exactly A');
|
|
||||||
await expect(page).toHaveURL(/\/agent\//);
|
|
||||||
|
|
||||||
// Click sidebar New Agent and assert it re-opens the host draft route while
|
|
||||||
// preserving working directory context from the selected agent.
|
|
||||||
await page.getByTestId('sidebar-new-agent').click();
|
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
|
||||||
const searchWorkingDir = await page.evaluate(() => {
|
|
||||||
try {
|
|
||||||
return new URL(window.location.href).searchParams.get('workingDir');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const normalizedCandidates = new Set<string>([repoA.path]);
|
|
||||||
if (repoA.path.startsWith('/var/')) {
|
|
||||||
normalizedCandidates.add(`/private${repoA.path}`);
|
|
||||||
}
|
|
||||||
if (repoA.path.startsWith('/private/var/')) {
|
|
||||||
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
|
|
||||||
}
|
|
||||||
expect(searchWorkingDir).not.toBeNull();
|
|
||||||
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await repoA.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import { gotoHome } from "./helpers/app";
|
|
||||||
|
|
||||||
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
|
|
||||||
const trigger = page.getByText("Project", { exact: true }).first();
|
|
||||||
await expect(trigger).toBeVisible();
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
(window as any).__projectFilterFlashProbe = new Promise<{
|
|
||||||
targetFound: boolean;
|
|
||||||
visibleAtOrigin: boolean;
|
|
||||||
records: Array<{ left: number; top: number; opacity: number }>;
|
|
||||||
}>((resolve) => {
|
|
||||||
let target: HTMLElement | null = null;
|
|
||||||
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
|
||||||
|
|
||||||
const capture = () => {
|
|
||||||
if (!target) return;
|
|
||||||
const style = getComputedStyle(target);
|
|
||||||
records.push({
|
|
||||||
left: Number.parseFloat(style.left || "0"),
|
|
||||||
top: Number.parseFloat(style.top || "0"),
|
|
||||||
opacity: Number.parseFloat(style.opacity || "1"),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryResolveTarget = (root: HTMLElement) => {
|
|
||||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
|
||||||
for (const element of stack) {
|
|
||||||
if (element.dataset?.testid === "combobox-desktop-container") {
|
|
||||||
target = element;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const finish = () => {
|
|
||||||
observer.disconnect();
|
|
||||||
if (!target) {
|
|
||||||
resolve({
|
|
||||||
targetFound: false,
|
|
||||||
visibleAtOrigin: false,
|
|
||||||
records: [],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleAtOrigin = records.some(
|
|
||||||
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
targetFound: true,
|
|
||||||
visibleAtOrigin,
|
|
||||||
records,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sampleFrames = () => {
|
|
||||||
capture();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
capture();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
capture();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
capture();
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
capture();
|
|
||||||
finish();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
for (const added of Array.from(mutation.addedNodes)) {
|
|
||||||
if (!(added instanceof HTMLElement)) continue;
|
|
||||||
if (tryResolveTarget(added)) {
|
|
||||||
sampleFrames();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
mutation.type === "attributes" &&
|
|
||||||
mutation.target instanceof HTMLElement &&
|
|
||||||
!target &&
|
|
||||||
tryResolveTarget(mutation.target)
|
|
||||||
) {
|
|
||||||
sampleFrames();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["style"],
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => finish(), 2500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await trigger.click();
|
|
||||||
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
|
||||||
|
|
||||||
expect(probe.targetFound).toBe(true);
|
|
||||||
expect(probe.visibleAtOrigin).toBe(false);
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { test, expect } from './fixtures';
|
|
||||||
import { gotoHome, openSettings } from './helpers/app';
|
|
||||||
|
|
||||||
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
await openSettings(page);
|
|
||||||
|
|
||||||
const menuButton = page.getByRole('button', { name: /menu/i }).first();
|
|
||||||
await expect(menuButton).toBeVisible();
|
|
||||||
|
|
||||||
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
|
|
||||||
await menuButton.focus();
|
|
||||||
|
|
||||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
|
||||||
await expect(tooltip).toBeVisible();
|
|
||||||
await expect(tooltip).toContainText('Toggle sidebar');
|
|
||||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
|
|
||||||
await page.waitForTimeout(250);
|
|
||||||
await expect(tooltip).toBeVisible();
|
|
||||||
|
|
||||||
// Tooltip should also appear on hover.
|
|
||||||
await menuButton.blur();
|
|
||||||
await expect(tooltip).toHaveCount(0);
|
|
||||||
await menuButton.hover();
|
|
||||||
await expect(tooltip).toBeVisible();
|
|
||||||
|
|
||||||
const triggerBox = await menuButton.boundingBox();
|
|
||||||
const tooltipBox = await tooltip.boundingBox();
|
|
||||||
expect(triggerBox).not.toBeNull();
|
|
||||||
expect(tooltipBox).not.toBeNull();
|
|
||||||
if (!triggerBox || !tooltipBox) return;
|
|
||||||
|
|
||||||
// side=right => tooltip starts to the right of the trigger.
|
|
||||||
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
|
|
||||||
// Keep it reasonably close (should be ~trigger.right + offset).
|
|
||||||
const expectedX = triggerBox.x + triggerBox.width + 8;
|
|
||||||
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
|
|
||||||
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
|
|
||||||
expect(tooltipBox.width).toBeLessThanOrEqual(500);
|
|
||||||
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
|
|
||||||
expect(tooltipBox.height).toBeLessThanOrEqual(80);
|
|
||||||
|
|
||||||
// align=center => centers should be roughly aligned (allow some clamping tolerance).
|
|
||||||
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
|
|
||||||
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
|
|
||||||
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
navigateToTerminal,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
waitForTerminalContent,
|
||||||
|
measureKeystrokeLatency,
|
||||||
|
computePercentile,
|
||||||
|
round2,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
type LatencySample,
|
||||||
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
const LINE_COUNT = 50_000;
|
||||||
|
const THROUGHPUT_BUDGET_MS = 30_000;
|
||||||
|
const KEYSTROKE_SAMPLE_COUNT = 20;
|
||||||
|
const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||||
|
|
||||||
|
test.describe("Terminal wire performance", () => {
|
||||||
|
let client: TerminalPerfDaemonClient;
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("perf-");
|
||||||
|
client = await connectTerminalClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (client) {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
if (tempRepo) {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "throughput");
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const startMs = Date.now();
|
||||||
|
|
||||||
|
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||||
|
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - startMs;
|
||||||
|
|
||||||
|
// seq 1 N outputs each number on its own line
|
||||||
|
const estimatedBytes = Array.from(
|
||||||
|
{ length: LINE_COUNT },
|
||||||
|
(_, i) => String(i + 1).length + 1,
|
||||||
|
).reduce((a, b) => a + b, 0);
|
||||||
|
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
lineCount: LINE_COUNT,
|
||||||
|
estimatedBytes,
|
||||||
|
elapsedMs,
|
||||||
|
throughputMBps: round2(throughputMBps),
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInfo.attach("throughput-report", {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
|
||||||
|
THROUGHPUT_BUDGET_MS,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "latency");
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Ensure clean prompt state
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const samples: LatencySample[] = [];
|
||||||
|
const chars = "abcdefghijklmnopqrst";
|
||||||
|
|
||||||
|
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
|
||||||
|
const char = chars[i % chars.length];
|
||||||
|
const latencyMs = await measureKeystrokeLatency(page, char);
|
||||||
|
samples.push({ char, latencyMs });
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up typed characters
|
||||||
|
await terminal.press("Control+c");
|
||||||
|
|
||||||
|
const latencies = samples.map((s) => s.latencyMs);
|
||||||
|
const p50 = computePercentile(latencies, 50);
|
||||||
|
const p95 = computePercentile(latencies, 95);
|
||||||
|
const max = Math.max(...latencies);
|
||||||
|
const min = Math.min(...latencies);
|
||||||
|
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
sampleCount: KEYSTROKE_SAMPLE_COUNT,
|
||||||
|
p50Ms: round2(p50),
|
||||||
|
p95Ms: round2(p95),
|
||||||
|
maxMs: round2(max),
|
||||||
|
minMs: round2(min),
|
||||||
|
avgMs: round2(avg),
|
||||||
|
samples: samples.map((s) => ({
|
||||||
|
char: s.char,
|
||||||
|
latencyMs: round2(s.latencyMs),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
await testInfo.attach("latency-report", {
|
||||||
|
body: JSON.stringify(report, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
p95,
|
||||||
|
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
|
||||||
|
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { test, expect, type Page } from "./fixtures";
|
|
||||||
import { setWorkingDirectory } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
|
||||||
openNewAgentComposer,
|
|
||||||
seedWorkspaceActivity,
|
|
||||||
switchWorkspaceViaSidebar,
|
|
||||||
} from "./helpers/workspace-ui";
|
|
||||||
|
|
||||||
function percentile(values: number[], p: number): number {
|
|
||||||
if (values.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const rank = Math.ceil((p / 100) * sorted.length);
|
|
||||||
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
|
|
||||||
return sorted[index] ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(values: number[]) {
|
|
||||||
if (values.length === 0) {
|
|
||||||
return {
|
|
||||||
count: 0,
|
|
||||||
minMs: 0,
|
|
||||||
maxMs: 0,
|
|
||||||
avgMs: 0,
|
|
||||||
p95Ms: 0,
|
|
||||||
p99Ms: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sum = values.reduce((acc, value) => acc + value, 0);
|
|
||||||
return {
|
|
||||||
count: values.length,
|
|
||||||
minMs: Math.min(...values),
|
|
||||||
maxMs: Math.max(...values),
|
|
||||||
avgMs: Math.round((sum / values.length) * 100) / 100,
|
|
||||||
p95Ms: percentile(values, 95),
|
|
||||||
p99Ms: percentile(values, 99),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStressCommand(doneMarker: string): string {
|
|
||||||
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
|
|
||||||
const markerFile = ".paseo-terminal-benchmark-marker";
|
|
||||||
return [
|
|
||||||
"i=1",
|
|
||||||
"printf '\\033[?1049h\\033[2J'",
|
|
||||||
"while [ $i -le 240 ]; do",
|
|
||||||
"printf '\\033[H'",
|
|
||||||
"r=1",
|
|
||||||
"while [ $r -le 24 ]; do",
|
|
||||||
"printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"",
|
|
||||||
"r=$((r+1))",
|
|
||||||
"done",
|
|
||||||
"sleep 0.01",
|
|
||||||
"i=$((i+1))",
|
|
||||||
"done",
|
|
||||||
`printf '\\033[?1049l\\n${doneMarker}\\n'`,
|
|
||||||
`printf '${doneMarker}\\n' > '${markerFile}'`,
|
|
||||||
].join("; ");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const text = await readFile(filePath, "utf8");
|
|
||||||
return text.includes(marker);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
|
|
||||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
|
||||||
const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
|
||||||
const expected = currentlyExpanded ? "false" : "true";
|
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 });
|
|
||||||
return Date.now() - start;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
test.setTimeout(180_000);
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
|
|
||||||
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await openNewAgentComposer(page);
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
|
||||||
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`);
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
|
||||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
|
||||||
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 });
|
|
||||||
await newTerminalButton.click();
|
|
||||||
|
|
||||||
const surface = page.locator('[data-testid="terminal-surface"]:visible').first();
|
|
||||||
await expect(surface).toBeVisible({ timeout: 60_000 });
|
|
||||||
await surface.click({ force: true });
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const monitor = {
|
|
||||||
samples: [] as number[],
|
|
||||||
active: true,
|
|
||||||
rafId: 0,
|
|
||||||
lastTs: performance.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const tick = (ts: number) => {
|
|
||||||
if (!monitor.active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
monitor.samples.push(ts - monitor.lastTs);
|
|
||||||
monitor.lastTs = ts;
|
|
||||||
monitor.rafId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
|
|
||||||
monitor.rafId = requestAnimationFrame(tick);
|
|
||||||
|
|
||||||
(window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ =
|
|
||||||
{
|
|
||||||
stop: () => {
|
|
||||||
if (!monitor.active) {
|
|
||||||
return { samples: monitor.samples };
|
|
||||||
}
|
|
||||||
monitor.active = false;
|
|
||||||
cancelAnimationFrame(monitor.rafId);
|
|
||||||
return { samples: monitor.samples };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`;
|
|
||||||
const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`;
|
|
||||||
const stressCommand = buildStressCommand(doneMarker);
|
|
||||||
await page.keyboard.type(stressCommand, { delay: 0 });
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
|
|
||||||
const interactionLatenciesMs: number[] = [];
|
|
||||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
|
||||||
const latency = await toggleExplorerAndMeasureLatency(page);
|
|
||||||
interactionLatenciesMs.push(latency);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rafResult = await page.evaluate(() => {
|
|
||||||
const handle = (
|
|
||||||
window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }
|
|
||||||
).__PASEO_E2E_RAF_MONITOR__;
|
|
||||||
if (!handle || typeof handle.stop !== "function") {
|
|
||||||
return { samples: [] as number[] };
|
|
||||||
}
|
|
||||||
return handle.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
await surface.click({ force: true });
|
|
||||||
await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 });
|
|
||||||
await page.keyboard.press("Enter");
|
|
||||||
await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
|
|
||||||
timeout: 120_000,
|
|
||||||
}).toBe(true);
|
|
||||||
|
|
||||||
const frameGapsMs = (rafResult.samples ?? []).filter(
|
|
||||||
(sample) => Number.isFinite(sample) && sample > 0
|
|
||||||
);
|
|
||||||
const report = {
|
|
||||||
mode: "report-only",
|
|
||||||
profile: "single-stress",
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
workload: {
|
|
||||||
frames: 240,
|
|
||||||
rowsPerFrame: 24,
|
|
||||||
frameSleepMs: 10,
|
|
||||||
doneMarker,
|
|
||||||
postMarker,
|
|
||||||
doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
|
|
||||||
},
|
|
||||||
frameGapMs: {
|
|
||||||
...summarize(frameGapsMs),
|
|
||||||
over100Ms: frameGapsMs.filter((gap) => gap > 100).length,
|
|
||||||
over250Ms: frameGapsMs.filter((gap) => gap > 250).length,
|
|
||||||
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
|
|
||||||
},
|
|
||||||
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
|
|
||||||
};
|
|
||||||
|
|
||||||
await testInfo.attach("terminal-responsiveness-report", {
|
|
||||||
body: JSON.stringify(report, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { expect, test } from "./fixtures";
|
|
||||||
import { gotoHome } from "./helpers/app";
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
|
|
||||||
await gotoHome(page);
|
|
||||||
|
|
||||||
const workingDirectorySelect = page
|
|
||||||
.locator('[data-testid="working-directory-select"]:visible')
|
|
||||||
.first();
|
|
||||||
await expect(workingDirectorySelect).toBeVisible();
|
|
||||||
await workingDirectorySelect.click({ force: true });
|
|
||||||
|
|
||||||
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
|
||||||
await expect(searchInput).toBeVisible();
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const trigger = document.querySelector('[data-testid="working-directory-select"]');
|
|
||||||
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
|
|
||||||
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
|
|
||||||
if (!(trigger instanceof HTMLElement)) {
|
|
||||||
throw new Error("Missing working-directory-select trigger.");
|
|
||||||
}
|
|
||||||
if (!(searchInput instanceof HTMLInputElement)) {
|
|
||||||
throw new Error("Missing working directory search input.");
|
|
||||||
}
|
|
||||||
if (!(container instanceof HTMLElement)) {
|
|
||||||
throw new Error("Missing combobox desktop container.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
samples: 0,
|
|
||||||
underTriggerSamples: 0,
|
|
||||||
emptyWhileSearchingSamples: 0,
|
|
||||||
minSearchDelta: Number.POSITIVE_INFINITY,
|
|
||||||
maxSearchDelta: Number.NEGATIVE_INFINITY,
|
|
||||||
minContainerDelta: Number.POSITIVE_INFINITY,
|
|
||||||
maxContainerDelta: Number.NEGATIVE_INFINITY,
|
|
||||||
logs: [] as Array<{
|
|
||||||
reason: string;
|
|
||||||
query: string;
|
|
||||||
searchDelta: number;
|
|
||||||
containerDelta: number;
|
|
||||||
hasEmpty: boolean;
|
|
||||||
containerTop: number;
|
|
||||||
containerBottom: number;
|
|
||||||
triggerTop: number;
|
|
||||||
searchBottom: number;
|
|
||||||
}>,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sample = (reason: string) => {
|
|
||||||
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const query = searchInput.value.trim();
|
|
||||||
if (!query) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
|
||||||
const searchRect = searchInput.getBoundingClientRect();
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
|
|
||||||
const searchDelta = searchRect.bottom - triggerRect.top;
|
|
||||||
const containerDelta = containerRect.bottom - triggerRect.top;
|
|
||||||
|
|
||||||
state.samples += 1;
|
|
||||||
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
|
|
||||||
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
|
|
||||||
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
|
|
||||||
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
|
|
||||||
if (searchDelta > 2 || containerDelta > 2) {
|
|
||||||
state.underTriggerSamples += 1;
|
|
||||||
}
|
|
||||||
if (hasEmpty) {
|
|
||||||
state.emptyWhileSearchingSamples += 1;
|
|
||||||
}
|
|
||||||
if (state.logs.length < 80) {
|
|
||||||
state.logs.push({
|
|
||||||
reason,
|
|
||||||
query,
|
|
||||||
searchDelta,
|
|
||||||
containerDelta,
|
|
||||||
hasEmpty,
|
|
||||||
containerTop: containerRect.top,
|
|
||||||
containerBottom: containerRect.bottom,
|
|
||||||
triggerTop: triggerRect.top,
|
|
||||||
searchBottom: searchRect.bottom,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutationObserver = new MutationObserver(() => sample("mutation"));
|
|
||||||
mutationObserver.observe(container, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
characterData: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => sample("resize"));
|
|
||||||
resizeObserver.observe(container);
|
|
||||||
resizeObserver.observe(searchInput);
|
|
||||||
|
|
||||||
let rafId = 0;
|
|
||||||
const loop = () => {
|
|
||||||
sample("raf");
|
|
||||||
rafId = requestAnimationFrame(loop);
|
|
||||||
};
|
|
||||||
rafId = requestAnimationFrame(loop);
|
|
||||||
|
|
||||||
(window as any).__paseoComboboxObserver = {
|
|
||||||
stop: () => {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
mutationObserver.disconnect();
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
sample("stop");
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
|
|
||||||
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
|
|
||||||
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
|
|
||||||
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const queries = [
|
|
||||||
"/tmp/paseo-stability-a",
|
|
||||||
"/tmp/paseo-stability-ab",
|
|
||||||
"/tmp/paseo-stability-abc",
|
|
||||||
"/tmp/paseo-stability-longer-branch",
|
|
||||||
"/tmp/paseo-stability-z",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const query of queries) {
|
|
||||||
await searchInput.fill("");
|
|
||||||
await searchInput.type(query, { delay: 20 });
|
|
||||||
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
|
|
||||||
await expect(customOption).toBeVisible();
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
|
|
||||||
const debug = JSON.stringify(stats.logs.slice(-10));
|
|
||||||
|
|
||||||
expect(stats.samples, debug).toBeGreaterThan(20);
|
|
||||||
expect(stats.underTriggerSamples, debug).toBe(0);
|
|
||||||
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
|
|
||||||
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
|
|
||||||
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
|
|
||||||
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
|
|
||||||
});
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { test, expect } from './fixtures'
|
|
||||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
|
|
||||||
import { createTempGitRepo } from './helpers/workspace'
|
|
||||||
import {
|
|
||||||
ensureWorkspaceAgentPaneVisible,
|
|
||||||
getWorkspaceTabTestIds,
|
|
||||||
waitForWorkspaceTabsVisible,
|
|
||||||
} from './helpers/workspace-tabs'
|
|
||||||
import { switchWorkspaceViaSidebar } from './helpers/workspace-ui'
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
}
|
|
||||||
|
|
||||||
test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo('paseo-e2e-workspace-draft-')
|
|
||||||
const seedPrompt = `seed workspace ${Date.now()}`
|
|
||||||
const createPrompt = `workspace draft prompt ${Date.now()}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoHome(page)
|
|
||||||
await ensureHostSelected(page)
|
|
||||||
|
|
||||||
// Force the setup onto the global draft surface. In slow runs the app can
|
|
||||||
// still be on a previously opened agent/workspace view where the placement
|
|
||||||
// form is not rendered.
|
|
||||||
await page.goto(`/h/${serverId}/new-agent`)
|
|
||||||
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path)
|
|
||||||
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await seedComposer.fill(seedPrompt)
|
|
||||||
await page.getByRole('button', { name: /send message/i }).first().click()
|
|
||||||
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path })
|
|
||||||
const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null
|
|
||||||
expect(workspaceRouteToken).toBeTruthy()
|
|
||||||
await waitForWorkspaceTabsVisible(page)
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page)
|
|
||||||
|
|
||||||
const beforeDraftIds = await getWorkspaceTabTestIds(page)
|
|
||||||
await page.getByTestId('workspace-new-agent-tab').first().click()
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page)
|
|
||||||
|
|
||||||
const withDraftIds = await getWorkspaceTabTestIds(page)
|
|
||||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id))
|
|
||||||
if (!draftTabTestId) {
|
|
||||||
throw new Error('Expected a draft workspace tab to be created.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const draftId = draftTabTestId.replace('workspace-tab-', '')
|
|
||||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first()
|
|
||||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible()
|
|
||||||
await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible()
|
|
||||||
|
|
||||||
const providerSelector = page.getByTestId('agent-provider-selector').first()
|
|
||||||
const modeSelector = page.getByTestId('agent-mode-selector').first()
|
|
||||||
const modelSelector = page.getByTestId('agent-model-selector').first()
|
|
||||||
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
|
|
||||||
|
|
||||||
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
|
|
||||||
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
|
|
||||||
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
|
|
||||||
|
|
||||||
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
|
|
||||||
await composer.fill(createPrompt)
|
|
||||||
await composer.press('Enter')
|
|
||||||
|
|
||||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
|
|
||||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const finalIds = await getWorkspaceTabTestIds(page)
|
|
||||||
expect(finalIds).toContain(draftTabTestId)
|
|
||||||
|
|
||||||
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
|
|
||||||
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
|
|
||||||
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
|
|
||||||
|
|
||||||
const currentUrl = page.url()
|
|
||||||
if (!workspaceRouteToken) {
|
|
||||||
throw new Error('Expected workspace route token to be present.')
|
|
||||||
}
|
|
||||||
expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`)
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures";
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
import { createAgentInRepo } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
|
||||||
ensureWorkspaceAgentPaneVisible,
|
|
||||||
getWorkspaceTabTestIds,
|
|
||||||
sampleWorkspaceTabIds,
|
|
||||||
waitForWorkspaceTabsVisible,
|
|
||||||
} from "./helpers/workspace-tabs";
|
|
||||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
|
||||||
|
|
||||||
async function expectComposerFocused(page: Page) {
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
|
||||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await composer.evaluate(
|
|
||||||
(element) => document.activeElement === element
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-draft-retarget-");
|
|
||||||
const seedPrompt = `seed prompt ${Date.now()}`;
|
|
||||||
const createPrompt = `retarget prompt ${Date.now()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt });
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
|
|
||||||
const beforeDraftIds = await getWorkspaceTabTestIds(page);
|
|
||||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
|
|
||||||
|
|
||||||
const withDraftIds = await getWorkspaceTabTestIds(page);
|
|
||||||
expect(withDraftIds.length).toBe(beforeDraftIds.length + 1);
|
|
||||||
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id));
|
|
||||||
expect(draftTabTestId).toBeTruthy();
|
|
||||||
|
|
||||||
const draftId = draftTabTestId!.replace("workspace-tab-", "");
|
|
||||||
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first();
|
|
||||||
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 });
|
|
||||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await input.fill(createPrompt);
|
|
||||||
await input.press("Enter");
|
|
||||||
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshots = await samplingPromise;
|
|
||||||
const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0);
|
|
||||||
expect(maxObservedCount).toBe(withDraftIds.length);
|
|
||||||
|
|
||||||
const finalIds = await getWorkspaceTabTestIds(page);
|
|
||||||
expect(finalIds.length).toBe(withDraftIds.length);
|
|
||||||
expect(finalIds).toContain(draftTabTestId!);
|
|
||||||
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 });
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-tab-focus-");
|
|
||||||
const firstPrompt = `first tab prompt ${Date.now()}`;
|
|
||||||
const secondPrompt = `second tab prompt ${Date.now()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt });
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await ensureWorkspaceAgentPaneVisible(page);
|
|
||||||
|
|
||||||
const beforeSecondAgentIds = await getWorkspaceTabTestIds(page);
|
|
||||||
await page.getByTestId("workspace-new-agent-tab").first().click();
|
|
||||||
await expectComposerFocused(page);
|
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
|
||||||
await composer.fill(secondPrompt);
|
|
||||||
await composer.press("Enter");
|
|
||||||
await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const withSecondAgentIds = await getWorkspaceTabTestIds(page);
|
|
||||||
const secondAgentTabTestId = withSecondAgentIds.find(
|
|
||||||
(id) => !beforeSecondAgentIds.includes(id)
|
|
||||||
);
|
|
||||||
if (!secondAgentTabTestId) {
|
|
||||||
throw new Error("Expected second agent tab to be created.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstAgentTabTestId = beforeSecondAgentIds[0];
|
|
||||||
if (!firstAgentTabTestId) {
|
|
||||||
throw new Error("Expected first agent tab to exist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByTestId(firstAgentTabTestId).first().click();
|
|
||||||
await expectComposerFocused(page);
|
|
||||||
|
|
||||||
await page.getByTestId(secondAgentTabTestId).first().click();
|
|
||||||
await expectComposerFocused(page);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
|
||||||
import { createAgentInRepo } from "./helpers/app";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
|
||||||
|
|
||||||
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAgentInRepo(page, {
|
|
||||||
directory: workspacePath,
|
|
||||||
prompt: `workspace header restore ${Date.now()}`,
|
|
||||||
});
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
|
|
||||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await openWorkspaceWithAgent(page, repo.path);
|
|
||||||
|
|
||||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
|
||||||
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
|
||||||
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
|
|
||||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(terminalButton).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
|
|
||||||
const workspaceTabs = page.locator(
|
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
|
||||||
);
|
|
||||||
const initialTabCount = await workspaceTabs.count();
|
|
||||||
const targetTabCount = initialTabCount + 8;
|
|
||||||
|
|
||||||
for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) {
|
|
||||||
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
|
|
||||||
await terminalButton.click();
|
|
||||||
await expect
|
|
||||||
.poll(async () => await workspaceTabs.count(), { timeout: 30000 })
|
|
||||||
.toBeGreaterThanOrEqual(attempt + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentBoundsBefore = await agentButton.boundingBox();
|
|
||||||
const terminalBoundsBefore = await terminalButton.boundingBox();
|
|
||||||
const viewport = page.viewportSize();
|
|
||||||
|
|
||||||
expect(agentBoundsBefore).not.toBeNull();
|
|
||||||
expect(terminalBoundsBefore).not.toBeNull();
|
|
||||||
expect(viewport).not.toBeNull();
|
|
||||||
|
|
||||||
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
|
||||||
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
|
||||||
|
|
||||||
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
|
||||||
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
|
|
||||||
|
|
||||||
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
|
|
||||||
await tabsScroll.evaluate((el) => {
|
|
||||||
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
|
|
||||||
const agentBoundsAfter = await agentButton.boundingBox();
|
|
||||||
const terminalBoundsAfter = await terminalButton.boundingBox();
|
|
||||||
|
|
||||||
expect(agentBoundsAfter).not.toBeNull();
|
|
||||||
expect(terminalBoundsAfter).not.toBeNull();
|
|
||||||
|
|
||||||
if (!agentBoundsAfter || !terminalBoundsAfter) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
|
||||||
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
|
||||||
|
|
||||||
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
|
|
||||||
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("workspace new-tab buttons sit immediately after tabs before overflow", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-adjacent-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await openWorkspaceWithAgent(page, repo.path);
|
|
||||||
|
|
||||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
|
||||||
const workspaceTabs = page.locator(
|
|
||||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(agentButton).toBeVisible({ timeout: 30000 });
|
|
||||||
await expect(workspaceTabs).toHaveCount(1, { timeout: 30000 });
|
|
||||||
|
|
||||||
const lastTabBounds = await workspaceTabs.last().boundingBox();
|
|
||||||
const agentBounds = await agentButton.boundingBox();
|
|
||||||
|
|
||||||
expect(lastTabBounds).not.toBeNull();
|
|
||||||
expect(agentBounds).not.toBeNull();
|
|
||||||
|
|
||||||
if (!lastTabBounds || !agentBounds) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const horizontalGap = agentBounds.x - (lastTabBounds.x + lastTabBounds.width);
|
|
||||||
|
|
||||||
expect(horizontalGap).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(horizontalGap).toBeLessThanOrEqual(24);
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("workspace explorer toggle opens and closes explorer", async ({ page }) => {
|
|
||||||
const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await openWorkspaceWithAgent(page, repo.path);
|
|
||||||
|
|
||||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
|
||||||
const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first();
|
|
||||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
|
||||||
if (initiallyExpanded) {
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
||||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
||||||
await expect(explorerHeader).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
||||||
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
|
|
||||||
} finally {
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { execSync } from 'node:child_process';
|
|
||||||
import { test } from './fixtures';
|
|
||||||
import { setWorkingDirectory } from './helpers/app';
|
|
||||||
import { createTempGitRepo } from './helpers/workspace';
|
|
||||||
import {
|
|
||||||
expectWorkspaceHeader,
|
|
||||||
openNewAgentComposer,
|
|
||||||
seedWorkspaceActivity,
|
|
||||||
switchWorkspaceViaSidebar,
|
|
||||||
workspaceLabelFromPath,
|
|
||||||
} from './helpers/workspace-ui';
|
|
||||||
|
|
||||||
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error('E2E_SERVER_ID is not set.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoA = await createTempGitRepo('paseo-e2e-sync-a-');
|
|
||||||
const repoB = await createTempGitRepo('paseo-e2e-sync-b-');
|
|
||||||
|
|
||||||
const tokenA = `SYNC_A_${Date.now()}`;
|
|
||||||
const tokenB = `SYNC_B_${Date.now()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
execSync('git checkout -b sync-a-branch', { cwd: repoA.path, stdio: 'ignore' });
|
|
||||||
execSync('git checkout -b sync-b-branch', { cwd: repoB.path, stdio: 'ignore' });
|
|
||||||
|
|
||||||
await openNewAgentComposer(page);
|
|
||||||
await setWorkingDirectory(page, repoA.path);
|
|
||||||
await seedWorkspaceActivity(page, tokenA);
|
|
||||||
|
|
||||||
await openNewAgentComposer(page);
|
|
||||||
await setWorkingDirectory(page, repoB.path);
|
|
||||||
await seedWorkspaceActivity(page, tokenB);
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: 'sync-a-branch',
|
|
||||||
subtitle: workspaceLabelFromPath(repoA.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: 'sync-b-branch',
|
|
||||||
subtitle: workspaceLabelFromPath(repoB.path),
|
|
||||||
});
|
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
|
|
||||||
await expectWorkspaceHeader(page, {
|
|
||||||
title: 'sync-a-branch',
|
|
||||||
subtitle: workspaceLabelFromPath(repoA.path),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await repoA.cleanup();
|
|
||||||
await repoB.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// https://docs.expo.dev/guides/using-eslint/
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
const { defineConfig } = require('eslint/config');
|
const { defineConfig } = require("eslint/config");
|
||||||
const expoConfig = require('eslint-config-expo/flat');
|
const expoConfig = require("eslint-config-expo/flat");
|
||||||
|
|
||||||
module.exports = defineConfig([
|
module.exports = defineConfig([
|
||||||
expoConfig,
|
expoConfig,
|
||||||
{
|
{
|
||||||
ignores: ['dist/*'],
|
ignores: ["dist/*"],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,17 +2,10 @@
|
|||||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
// Polyfill screen.orientation for WebKitGTK (Tauri Linux) which lacks the API
|
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||||
polyfillScreenOrientation();
|
polyfillScreenOrientation();
|
||||||
|
|
||||||
// Bridge console.log/warn/error to Tauri's log plugin so JS output appears in app.log
|
|
||||||
if ((globalThis as { __TAURI__?: unknown }).__TAURI__) {
|
|
||||||
import("@tauri-apps/plugin-log").then(({ attachConsole }) => {
|
|
||||||
attachConsole();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||||
import "./src/styles/unistyles";
|
import "./src/styles/unistyles";
|
||||||
import "expo-router/entry";
|
import "expo-router/entry";
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Maestro takeScreenshot artifacts
|
||||||
|
*.png
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
appId: sh.paseo
|
||||||
|
---
|
||||||
|
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
|
||||||
|
# Small pause for close animation
|
||||||
|
- takeScreenshot: 00-sidebar-closed
|
||||||
|
|
||||||
|
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
|
||||||
|
- swipe:
|
||||||
|
start: "5%,50%"
|
||||||
|
end: "80%,50%"
|
||||||
|
duration: 300
|
||||||
|
|
||||||
|
# Verify sidebar opened
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
- takeScreenshot: 01-sidebar-opened
|
||||||
|
|
||||||
|
# Close sidebar via swipe left
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
- takeScreenshot: 02-sidebar-closed-again
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verification loop for sidebar theme bug.
|
||||||
|
#
|
||||||
|
# Maestro can't toggle iOS appearance, so this script bridges the gap:
|
||||||
|
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
|
||||||
|
# still works. Runs N iterations to catch intermittent failures.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
|
||||||
|
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||||
|
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
|
||||||
|
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
|
||||||
|
ITERATIONS="${1:-3}"
|
||||||
|
WAIT_SECS="${2:-1}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
echo "=== Sidebar Theme Bug Verification ==="
|
||||||
|
echo "Output dir: $OUT_DIR"
|
||||||
|
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
|
||||||
|
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
for i in $(seq 1 "$ITERATIONS"); do
|
||||||
|
echo ""
|
||||||
|
echo "========== Iteration $i / $ITERATIONS =========="
|
||||||
|
|
||||||
|
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
|
||||||
|
echo "Current appearance: $CURRENT"
|
||||||
|
|
||||||
|
if [ "$CURRENT" = "dark" ]; then
|
||||||
|
xcrun simctl ui booted appearance light
|
||||||
|
echo "Switched to light mode"
|
||||||
|
else
|
||||||
|
xcrun simctl ui booted appearance dark
|
||||||
|
echo "Switched to dark mode"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting ${WAIT_SECS}s..."
|
||||||
|
sleep "$WAIT_SECS"
|
||||||
|
|
||||||
|
ITER_DIR="$OUT_DIR/iter-$i"
|
||||||
|
mkdir -p "$ITER_DIR"
|
||||||
|
|
||||||
|
# Run maestro from the output dir so takeScreenshot artifacts land there
|
||||||
|
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||||
|
echo " -> PASS (iteration $i)"
|
||||||
|
else
|
||||||
|
echo " -> FAIL (iteration $i) — bug reproduced!"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Restore to dark mode
|
||||||
|
xcrun simctl ui booted appearance dark
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
|
||||||
|
echo "Output: $OUT_DIR"
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
const exclusionList =
|
|
||||||
require("@expo/metro/metro-config/defaults/exclusionList").default;
|
|
||||||
const { resolve } = require("metro-resolver");
|
const { resolve } = require("metro-resolver");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -18,8 +16,10 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
|||||||
const config = getDefaultConfig(projectRoot);
|
const config = getDefaultConfig(projectRoot);
|
||||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||||
const escapedAppSrcRoot = appSrcRoot
|
const escapedAppSrcRoot = appSrcRoot
|
||||||
.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&")
|
.split(path.sep)
|
||||||
.replace(/\//g, "[\\\\/]");
|
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
|
||||||
|
.join("[\\\\/]");
|
||||||
|
const pathSeparatorPattern = "[\\\\/]";
|
||||||
|
|
||||||
config.resolver.extraNodeModules = {
|
config.resolver.extraNodeModules = {
|
||||||
...(config.resolver.extraNodeModules ?? {}),
|
...(config.resolver.extraNodeModules ?? {}),
|
||||||
@@ -28,9 +28,9 @@ config.resolver.extraNodeModules = {
|
|||||||
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
||||||
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
|
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
|
||||||
};
|
};
|
||||||
config.resolver.blockList = exclusionList([
|
config.resolver.blockList = new RegExp(
|
||||||
new RegExp(`^${escapedAppSrcRoot}[\\\\/].*\\.(test|spec)\\.(ts|tsx)$`),
|
`(^${escapedAppSrcRoot}${pathSeparatorPattern}.*\\.(test|spec)\\.(ts|tsx)$|${pathSeparatorPattern}__tests__${pathSeparatorPattern}.*)$`,
|
||||||
]);
|
);
|
||||||
|
|
||||||
function isLocalModuleImport(moduleName) {
|
function isLocalModuleImport(moduleName) {
|
||||||
return (
|
return (
|
||||||
+17
-22
@@ -1,49 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.26",
|
"version": "0.1.51-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
|
"eas-build-post-install": "npm run build:workspace-deps",
|
||||||
"android": "npm run android:development",
|
"android": "npm run android:development",
|
||||||
"android:clear-autolinking-cache": "node -e \"require('node:fs').rmSync('android/build/generated/autolinking', { recursive: true, force: true })\"",
|
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||||
"android:development": "npm run android:clear-autolinking-cache && APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
||||||
"android:production": "npm run android:clear-autolinking-cache && APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
|
|
||||||
"android:release": "npm run android:production",
|
"android:release": "npm run android:production",
|
||||||
"android:clean": "expo prebuild --platform android --clean --non-interactive",
|
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"ios:release": "expo run:ios --configuration Release",
|
"ios:release": "expo run:ios --configuration Release",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"web:tauri": "PASEO_WEB_PLATFORM=tauri expo start --web",
|
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
"build:web": "expo export --platform web",
|
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
||||||
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
|
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/server": "0.1.26",
|
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||||
|
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||||
|
"@getpaseo/server": "0.1.51-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@lezer/common": "^1.5.0",
|
|
||||||
"@lezer/css": "^1.3.0",
|
|
||||||
"@lezer/highlight": "^1.2.3",
|
|
||||||
"@lezer/html": "^1.3.13",
|
|
||||||
"@lezer/javascript": "^1.5.4",
|
|
||||||
"@lezer/json": "^1.0.3",
|
|
||||||
"@lezer/markdown": "^1.6.2",
|
|
||||||
"@lezer/python": "^1.1.18",
|
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
"@react-native/normalize-colors": "^0.81.5",
|
"@react-native/normalize-colors": "^0.81.5",
|
||||||
@@ -52,10 +44,13 @@
|
|||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@tauri-apps/plugin-log": "^2.8.0",
|
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-image": "^0.9.0",
|
||||||
|
"@xterm/addon-ligatures": "^0.10.0",
|
||||||
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
@@ -84,7 +79,6 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
"lezer-elixir": "^1.1.2",
|
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.4",
|
"react": "19.1.4",
|
||||||
@@ -117,6 +111,7 @@
|
|||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"material-icon-theme": "^5.32.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
|
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
|
||||||
// This allows multiple test runs in parallel across different worktrees
|
// This allows multiple test runs in parallel across different worktrees
|
||||||
const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`;
|
const baseURL =
|
||||||
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: "./e2e",
|
||||||
globalSetup: './e2e/global-setup.ts',
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -16,17 +17,17 @@ export default defineConfig({
|
|||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
reporter: [['list']],
|
reporter: [["list"]],
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: 'retain-on-failure',
|
trace: "retain-on-failure",
|
||||||
screenshot: 'only-on-failure',
|
screenshot: "only-on-failure",
|
||||||
video: 'retain-on-failure',
|
video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'Desktop Chrome',
|
name: "Desktop Chrome",
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices["Desktop Chrome"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
|
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
process.env.E2E_BASE_URL ??
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
process.env.E2E_BASE_URL ??
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="%LANG_ISO_CODE%">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>%WEB_TITLE%</title>
|
||||||
|
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||||
|
<style id="expo-reset">
|
||||||
|
/* These styles make the body full-height */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Prevent white flash before React mounts */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html, body {
|
||||||
|
background-color: #181B1A;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* These styles make the root element full-height */
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
[role='button'],
|
||||||
|
[role='link'],
|
||||||
|
[role='textbox'],
|
||||||
|
[role='combobox'],
|
||||||
|
[role='tab'],
|
||||||
|
[role='switch'],
|
||||||
|
[role='checkbox'],
|
||||||
|
[role='slider'],
|
||||||
|
[role='menuitem'],
|
||||||
|
[tabindex],
|
||||||
|
[contenteditable='true'] {
|
||||||
|
-webkit-app-region: no-drag !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
|
|||||||
userInput === "y"
|
userInput === "y"
|
||||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||||
: ""
|
: ""
|
||||||
}`
|
}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Error during script execution: ${error.message}`);
|
console.error(`❌ Error during script execution: ${error.message}`);
|
||||||
@@ -108,5 +108,5 @@ rl.question(
|
|||||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||||
rl.close();
|
rl.close();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+444
-205
@@ -1,6 +1,12 @@
|
|||||||
import "@/styles/unistyles";
|
import "@/styles/unistyles";
|
||||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
import {
|
||||||
|
Stack,
|
||||||
|
useGlobalSearchParams,
|
||||||
|
useNavigationContainerRef,
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
} from "expo-router";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
@@ -9,20 +15,24 @@ import { PortalProvider } from "@gorhom/portal";
|
|||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { View, ActivityIndicator, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { darkTheme } from "@/styles/theme";
|
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
useHosts,
|
useHosts,
|
||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeSession,
|
useHostRuntimeClient,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||||
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -35,6 +45,7 @@ import * as Linking from "expo-linking";
|
|||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { DownloadToast } from "@/components/download-toast";
|
import { DownloadToast } from "@/components/download-toast";
|
||||||
|
import { UpdateBanner } from "@/desktop/updates/update-banner";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
||||||
@@ -46,7 +57,7 @@ import {
|
|||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsTauri } from "@/constants/layout";
|
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
@@ -57,30 +68,32 @@ import {
|
|||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
ensureOsNotificationPermission,
|
ensureOsNotificationPermission,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
|
mapPathnameToServer,
|
||||||
|
parseServerIdFromPathname,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
import { attachConsole } from "@/utils/tauri-attach-console";
|
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
attachConsole();
|
|
||||||
const HostRuntimeBootstrapContext = createContext(false);
|
|
||||||
|
|
||||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
export type HostRuntimeBootstrapState = {
|
||||||
|
phase: "starting-daemon" | "connecting" | "online" | "error";
|
||||||
|
error: string | null;
|
||||||
|
retry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
function logLeftSidebarOpenGesture(
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
event: string,
|
phase: "starting-daemon",
|
||||||
details: Record<string, unknown>
|
error: null,
|
||||||
): void {
|
retry: () => {},
|
||||||
if (!IS_DEV) {
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`[LeftSidebarOpenGesture] ${event}`, details);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -88,32 +101,52 @@ function PushNotificationRouter() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
if (getTauri()) {
|
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||||
void ensureOsNotificationPermission().then((granted) => {
|
let cancelled = false;
|
||||||
console.log(
|
|
||||||
"[OSNotifications][Tauri] Startup permission preflight result:",
|
if (getIsElectronRuntime()) {
|
||||||
granted ? "granted" : "not-granted"
|
void ensureOsNotificationPermission();
|
||||||
);
|
|
||||||
|
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||||
|
"notification-click",
|
||||||
|
(payload: unknown) => {
|
||||||
|
const data =
|
||||||
|
typeof payload === "object" &&
|
||||||
|
payload !== null &&
|
||||||
|
"data" in payload &&
|
||||||
|
typeof (payload as { data?: unknown }).data === "object" &&
|
||||||
|
(payload as { data?: unknown }).data !== null
|
||||||
|
? (payload as { data: Record<string, unknown> }).data
|
||||||
|
: undefined;
|
||||||
|
router.push(buildNotificationRoute(data) as any);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
void Promise.resolve(unlistenResult).then((unlisten) => {
|
||||||
|
if (typeof unlisten !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cancelled) {
|
||||||
|
unlisten();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeDesktopNotificationListener = unlisten;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = globalThis as unknown as EventTarget;
|
const target = globalThis as unknown as EventTarget;
|
||||||
const openFromWebClick = (event: Event) => {
|
const openFromWebClick = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||||
const route = buildNotificationRoute(customEvent.detail?.data);
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
router.push(route as any);
|
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
target.addEventListener(
|
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
|
||||||
openFromWebClick as EventListener
|
|
||||||
);
|
|
||||||
return () => {
|
return () => {
|
||||||
target.removeEventListener(
|
cancelled = true;
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
removeDesktopNotificationListener?.();
|
||||||
openFromWebClick as EventListener
|
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,8 +174,7 @@ function PushNotificationRouter() {
|
|||||||
router.push(buildNotificationRoute(data) as any);
|
router.push(buildNotificationRoute(data) as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscription =
|
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
||||||
Notifications.addNotificationResponseReceivedListener(openFromResponse);
|
|
||||||
|
|
||||||
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
void Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -159,18 +191,14 @@ function PushNotificationRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||||
const { client } = useHostRuntimeSession(daemon.serverId);
|
const client = useHostRuntimeClient(daemon.serverId);
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionProvider
|
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
|
||||||
key={daemon.serverId}
|
|
||||||
serverId={daemon.serverId}
|
|
||||||
client={client}
|
|
||||||
>
|
|
||||||
{null}
|
{null}
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
@@ -193,41 +221,126 @@ function HostSessionManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retryToken, setRetryToken] = useState(0);
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
setPhase("starting-daemon");
|
||||||
|
setError(null);
|
||||||
|
setRetryToken((current) => current + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let cancelAnyOnline: (() => void) | null = null;
|
||||||
|
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
|
|
||||||
void store
|
const init = async () => {
|
||||||
.loadFromStorage()
|
const settings = await loadSettingsFromStorage();
|
||||||
.then(() => {
|
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
|
||||||
if (cancelled) {
|
await store.loadFromStorage();
|
||||||
return;
|
if (isDesktopManaged) {
|
||||||
}
|
setPhase("starting-daemon");
|
||||||
setReady(true);
|
setError(null);
|
||||||
void store.bootstrap();
|
|
||||||
})
|
let raceSettled = false;
|
||||||
.catch((error) => {
|
|
||||||
console.error("[HostRuntime] Failed to initialize store", error);
|
const anyOnline = store.waitForAnyConnectionOnline();
|
||||||
|
cancelAnyOnline = anyOnline.cancel;
|
||||||
|
|
||||||
|
const bootstrapPromise = (async (): Promise<
|
||||||
|
{ type: "online" } | { type: "error"; error: string }
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
const bootstrapResult = await store.bootstrapDesktop();
|
||||||
|
if (!bootstrapResult.ok) {
|
||||||
|
return { type: "error", error: bootstrapResult.error };
|
||||||
|
}
|
||||||
|
if (!cancelled && !raceSettled) {
|
||||||
|
setPhase("connecting");
|
||||||
|
}
|
||||||
|
await store.addConnectionFromListenAndWaitForOnline({
|
||||||
|
listenAddress: bootstrapResult.listenAddress,
|
||||||
|
serverId: bootstrapResult.serverId,
|
||||||
|
hostname: bootstrapResult.hostname,
|
||||||
|
});
|
||||||
|
return { type: "online" };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
|
||||||
|
bootstrapPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
raceSettled = true;
|
||||||
|
anyOnline.cancel();
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setReady(true);
|
if (result.type === "online") {
|
||||||
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setPhase("error");
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
} else {
|
||||||
|
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
|
if (!cancelled) {
|
||||||
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void init().catch((bootstrapError) => {
|
||||||
|
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldManageDesktop) {
|
||||||
|
setPhase("error");
|
||||||
|
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
cancelAnyOnline?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [retryToken]);
|
||||||
|
|
||||||
|
const state = useMemo<HostRuntimeBootstrapState>(
|
||||||
|
() => ({
|
||||||
|
phase,
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
}),
|
||||||
|
[error, phase, retry],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HostRuntimeBootstrapContext.Provider value={ready}>
|
<HostRuntimeBootstrapContext.Provider value={state}>
|
||||||
{children}
|
{children}
|
||||||
</HostRuntimeBootstrapContext.Provider>
|
</HostRuntimeBootstrapContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useStoreReady(): boolean {
|
export function useStoreReady(): boolean {
|
||||||
|
return useContext(HostRuntimeBootstrapContext).phase === "online";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
|
||||||
return useContext(HostRuntimeBootstrapContext);
|
return useContext(HostRuntimeBootstrapContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +348,9 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
|||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||||
|
const flexStyle = { flex: 1 } as const;
|
||||||
|
|
||||||
interface AppContainerProps {
|
interface AppContainerProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
@@ -248,31 +364,95 @@ function AppContainer({
|
|||||||
}: AppContainerProps) {
|
}: AppContainerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||||
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
|
|
||||||
const isMobile =
|
const isCompactLayout = isCompactFormFactor();
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
const isOpen = chromeEnabled
|
|
||||||
? isMobile
|
useEffect(() => {
|
||||||
? mobileView === "agent-list"
|
const bp = UnistylesRuntime.breakpoint;
|
||||||
: desktopAgentListOpen
|
const screenW = UnistylesRuntime.screen.width;
|
||||||
: false;
|
const screenH = UnistylesRuntime.screen.height;
|
||||||
const openGestureEnabled =
|
const isElectron = getIsElectronRuntime();
|
||||||
chromeEnabled && isMobile && mobileView === "agent";
|
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
|
||||||
|
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
|
||||||
|
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
|
||||||
|
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[layout-debug]",
|
||||||
|
JSON.stringify({
|
||||||
|
breakpoint: bp,
|
||||||
|
isCompactLayout,
|
||||||
|
isElectron,
|
||||||
|
chromeEnabled,
|
||||||
|
isFocusModeEnabled,
|
||||||
|
agentListOpen,
|
||||||
|
sidebarWidth,
|
||||||
|
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
||||||
|
unistylesScreen: { w: screenW, h: screenH },
|
||||||
|
window: { w: windowW, h: windowH },
|
||||||
|
devicePixelRatio: dpr,
|
||||||
|
userAgent: ua,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
isMobile,
|
isMobile: isCompactLayout,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
|
toggleBothSidebars,
|
||||||
|
toggleFocusMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const containerStyle = useMemo(
|
||||||
|
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||||
|
[theme.colors.surface0],
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<View style={rowStyle}>
|
||||||
|
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||||
|
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||||
|
)}
|
||||||
|
<View style={flexStyle}>{children}</View>
|
||||||
|
</View>
|
||||||
|
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||||
|
<DownloadToast />
|
||||||
|
<UpdateBanner />
|
||||||
|
<CommandCenter />
|
||||||
|
<ProjectPickerModal />
|
||||||
|
<KeyboardShortcutsDialog />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isCompactLayout) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileGestureWrapper({
|
||||||
|
children,
|
||||||
|
chromeEnabled,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
chromeEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -280,19 +460,23 @@ function AppContainer({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
|
|
||||||
// Track initial touch position for manual activation
|
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
|
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||||
|
|
||||||
|
const handleGestureOpen = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
openAgentList();
|
||||||
|
}, [openAgentList, gestureAnimatingRef]);
|
||||||
|
|
||||||
// Open gesture: swipe right from anywhere to open sidebar (interactive drag)
|
|
||||||
// If any horizontal scroll is scrolled right, let the scroll view handle the gesture first
|
|
||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
|
.withRef(openGestureRef)
|
||||||
.enabled(openGestureEnabled)
|
.enabled(openGestureEnabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
.onTouchesDown((event) => {
|
.onTouchesDown((event) => {
|
||||||
const touch = event.changedTouches[0];
|
const touch = event.changedTouches[0];
|
||||||
@@ -306,49 +490,34 @@ function AppContainer({
|
|||||||
|
|
||||||
const deltaX = touch.absoluteX - touchStartX.value;
|
const deltaX = touch.absoluteX - touchStartX.value;
|
||||||
|
|
||||||
// If horizontal scroll is scrolled right, fail so ScrollView handles it
|
|
||||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate after 15px rightward movement
|
|
||||||
if (deltaX > 15) {
|
if (deltaX > 15) {
|
||||||
stateManager.activate();
|
stateManager.activate();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
isGesturing.value = true;
|
isGesturing.value = true;
|
||||||
runOnJS(logLeftSidebarOpenGesture)("start", {
|
|
||||||
mobileView,
|
|
||||||
openGestureEnabled,
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
// Start from closed position (-windowWidth) and move towards 0
|
|
||||||
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
||||||
translateX.value = newTranslateX;
|
translateX.value = newTranslateX;
|
||||||
backdropOpacity.value = interpolate(
|
backdropOpacity.value = interpolate(
|
||||||
newTranslateX,
|
newTranslateX,
|
||||||
[-windowWidth, 0],
|
[-windowWidth, 0],
|
||||||
[0, 1],
|
[0, 1],
|
||||||
Extrapolation.CLAMP
|
Extrapolation.CLAMP,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.onEnd((event) => {
|
.onEnd((event) => {
|
||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
// Open if dragged more than 1/3 of sidebar or fast swipe
|
|
||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
runOnJS(logLeftSidebarOpenGesture)("end", {
|
|
||||||
translationX: event.translationX,
|
|
||||||
velocityX: event.velocityX,
|
|
||||||
shouldOpen,
|
|
||||||
mobileView,
|
|
||||||
openGestureEnabled,
|
|
||||||
});
|
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(openAgentList)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
@@ -363,65 +532,57 @@ function AppContainer({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
openAgentList,
|
handleGestureOpen,
|
||||||
mobileView,
|
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
openGestureRef,
|
||||||
horizontalScroll?.isAnyScrolledRight,
|
horizontalScroll?.isAnyScrolledRight,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = (
|
|
||||||
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
|
||||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
|
||||||
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
|
||||||
<DownloadToast />
|
|
||||||
<CommandCenter />
|
|
||||||
<ProjectPickerModal />
|
|
||||||
<KeyboardShortcutsDialog />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isMobile) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
||||||
{content}
|
{children}
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||||
const storeReady = useStoreReady();
|
|
||||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||||
const isLoading = settingsLoading || !storeReady;
|
const systemColorScheme = useColorScheme();
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||||
|
|
||||||
// Apply theme setting on mount and when it changes
|
// Apply theme setting on mount and when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return;
|
if (settingsLoading) return;
|
||||||
if (settings.theme === "auto") {
|
if (settings.theme === "auto") {
|
||||||
UnistylesRuntime.setAdaptiveThemes(true);
|
UnistylesRuntime.setAdaptiveThemes(true);
|
||||||
} else {
|
} else {
|
||||||
UnistylesRuntime.setAdaptiveThemes(false);
|
UnistylesRuntime.setAdaptiveThemes(false);
|
||||||
UnistylesRuntime.setTheme(settings.theme);
|
UnistylesRuntime.setTheme(settings.theme);
|
||||||
}
|
}
|
||||||
}, [isLoading, settings.theme]);
|
}, [settingsLoading, settings.theme]);
|
||||||
|
|
||||||
if (isLoading) {
|
useEffect(() => {
|
||||||
return <LoadingView />;
|
if (settingsLoading || Platform.OS !== "web") {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateDesktopWindowControls({
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
foregroundColor: theme.colors.foreground,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||||
|
});
|
||||||
|
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||||
|
<HostSessionManager />
|
||||||
|
<FaviconStatusSync />
|
||||||
{children}
|
{children}
|
||||||
</VoiceProvider>
|
</VoiceProvider>
|
||||||
);
|
);
|
||||||
@@ -444,7 +605,7 @@ function OfferLinkListener({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const serverId = (profile as any)?.serverId;
|
const serverId = (profile as any)?.serverId;
|
||||||
if (typeof serverId !== "string" || !serverId) return;
|
if (typeof serverId !== "string" || !serverId) return;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -452,7 +613,9 @@ function OfferLinkListener({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
|
void Linking.getInitialURL()
|
||||||
|
.then(handleUrl)
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
const subscription = Linking.addEventListener("url", (event) => {
|
const subscription = Linking.addEventListener("url", (event) => {
|
||||||
handleUrl(event.url);
|
handleUrl(event.url);
|
||||||
@@ -467,11 +630,101 @@ function OfferLinkListener({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenProjectEventPayload {
|
||||||
|
path?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenProjectListener() {
|
||||||
|
const hosts = useHosts();
|
||||||
|
const serverId = hosts[0]?.serverId ?? null;
|
||||||
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
|
const openProject = useOpenProject(serverId);
|
||||||
|
const pendingPathRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let unlisten: (() => void) | null = null;
|
||||||
|
const maybeOpenProject = (inputPath: string) => {
|
||||||
|
const nextPath = inputPath.trim();
|
||||||
|
if (!nextPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPathRef.current = nextPath;
|
||||||
|
|
||||||
|
if (!serverId || !client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathToOpen = pendingPathRef.current;
|
||||||
|
pendingPathRef.current = null;
|
||||||
|
if (!pathToOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void openProject(pathToOpen).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pull any path that was passed on cold start (before the listener existed).
|
||||||
|
// Store in the ref even if this effect instance is disposed — the next
|
||||||
|
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
|
||||||
|
void getDesktopHost()
|
||||||
|
?.getPendingOpenProject?.()
|
||||||
|
?.then((pending) => {
|
||||||
|
if (pending) {
|
||||||
|
pendingPathRef.current = pending;
|
||||||
|
}
|
||||||
|
if (!disposed && pending) {
|
||||||
|
maybeOpenProject(pending);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
// Listen for hot-start paths relayed via the second-instance event.
|
||||||
|
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
|
||||||
|
maybeOpenProject(nextPath);
|
||||||
|
})
|
||||||
|
.then((dispose) => {
|
||||||
|
if (disposed) {
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unlisten = dispose;
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
maybeOpenProject(pendingPathRef.current ?? "");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [client, openProject, serverId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||||
useFaviconStatus();
|
const hosts = useHosts();
|
||||||
const shouldShowAppChrome = pathname !== "/" && pathname !== "";
|
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
|
const shouldShowAppChrome = activeServerId !== null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeServerId || hosts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
||||||
|
}, [activeServerId, hosts, pathname, router]);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||||
@@ -499,106 +752,92 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingView({ message }: { message?: string } = {}) {
|
function FaviconStatusSync() {
|
||||||
|
useFaviconStatus();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootStack() {
|
||||||
|
const storeReady = useStoreReady();
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<Stack
|
||||||
style={{
|
screenOptions={{
|
||||||
flex: 1,
|
headerShown: false,
|
||||||
justifyContent: "center",
|
animation: "none",
|
||||||
alignItems: "center",
|
contentStyle: {
|
||||||
backgroundColor: darkTheme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
|
<Stack.Protected guard={storeReady}>
|
||||||
{message ? (
|
<Stack.Screen name="welcome" />
|
||||||
<Text
|
<Stack.Screen name="settings" />
|
||||||
style={{
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||||
color: darkTheme.colors.foregroundMuted,
|
<Stack.Screen
|
||||||
marginTop: 16,
|
name="h/[serverId]/agent/[agentId]"
|
||||||
fontSize: 14,
|
options={{ gestureEnabled: false }}
|
||||||
}}
|
/>
|
||||||
>
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
{message}
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
</Text>
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
) : null}
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
</View>
|
<Stack.Screen name="pair-scan" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MissingDaemonView() {
|
function NavigationActiveWorkspaceObserver() {
|
||||||
return (
|
const navigationRef = useNavigationContainerRef();
|
||||||
<View
|
|
||||||
style={{
|
useEffect(() => {
|
||||||
flex: 1,
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
justifyContent: "center",
|
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||||
alignItems: "center",
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
padding: 24,
|
});
|
||||||
backgroundColor: darkTheme.colors.surface0,
|
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
|
||||||
}}
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
>
|
});
|
||||||
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
|
return () => {
|
||||||
<Text
|
unsubscribeState();
|
||||||
style={{
|
unsubscribeReady();
|
||||||
color: darkTheme.colors.foreground,
|
};
|
||||||
marginTop: 16,
|
}, [navigationRef]);
|
||||||
textAlign: "center",
|
|
||||||
}}
|
return null;
|
||||||
>
|
|
||||||
No host configured. Open Settings to add a server URL.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView
|
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||||
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
|
<NavigationActiveWorkspaceObserver />
|
||||||
>
|
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<KeyboardProvider>
|
<KeyboardProvider>
|
||||||
<BottomSheetModalProvider>
|
<QueryProvider>
|
||||||
<QueryProvider>
|
<BottomSheetModalProvider>
|
||||||
<HostRuntimeBootstrapProvider>
|
<HostRuntimeBootstrapProvider>
|
||||||
<PushNotificationRouter />
|
<PushNotificationRouter />
|
||||||
<HostSessionManager />
|
|
||||||
<ProvidersWrapper>
|
<ProvidersWrapper>
|
||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<OpenProjectListener />
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
<Stack
|
<RootStack />
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
animation: "none",
|
|
||||||
contentStyle: {
|
|
||||||
backgroundColor: darkTheme.colors.surface0,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Screen name="settings" />
|
|
||||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
|
||||||
<Stack.Screen
|
|
||||||
name="h/[serverId]/agent/[agentId]"
|
|
||||||
options={{ gestureEnabled: false }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
|
||||||
<Stack.Screen name="h/[serverId]/agents" />
|
|
||||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
|
||||||
<Stack.Screen name="pair-scan" />
|
|
||||||
</Stack>
|
|
||||||
</AppWithSidebar>
|
</AppWithSidebar>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
</SidebarAnimationProvider>
|
</SidebarAnimationProvider>
|
||||||
</ProvidersWrapper>
|
</ProvidersWrapper>
|
||||||
</HostRuntimeBootstrapProvider>
|
</HostRuntimeBootstrapProvider>
|
||||||
</QueryProvider>
|
</BottomSheetModalProvider>
|
||||||
</BottomSheetModalProvider>
|
</QueryProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import {
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
buildHostRootRoute,
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
buildHostWorkspaceAgentRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -16,7 +14,8 @@ export default function HostAgentReadyRoute() {
|
|||||||
const redirectedRef = useRef(false);
|
const redirectedRef = useRef(false);
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const agentCwd = useSessionStore((state) => {
|
const agentCwd = useSessionStore((state) => {
|
||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -38,7 +37,11 @@ export default function HostAgentReadyRoute() {
|
|||||||
if (normalizedCwd) {
|
if (normalizedCwd) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(
|
router.replace(
|
||||||
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
|
prepareWorkspaceTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId: normalizedCwd,
|
||||||
|
target: { kind: "agent", agentId },
|
||||||
|
}) as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, router, serverId]);
|
}, [agentCwd, agentId, router, serverId]);
|
||||||
@@ -55,7 +58,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
}
|
}
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||||
|
|
||||||
@@ -77,17 +80,23 @@ export default function HostAgentReadyRoute() {
|
|||||||
const cwd = result?.agent?.cwd?.trim();
|
const cwd = result?.agent?.cwd?.trim();
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
if (cwd) {
|
if (cwd) {
|
||||||
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
|
router.replace(
|
||||||
|
prepareWorkspaceTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId: cwd,
|
||||||
|
target: { kind: "agent", agentId },
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled || redirectedRef.current) {
|
if (cancelled || redirectedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user