chore(providers): integrate current main with provider artwork

This commit is contained in:
paseo-bot[bot]
2026-10-18 16:03:38 +02:00
1052 changed files with 61212 additions and 17946 deletions
@@ -23,13 +23,16 @@ jobs:
platform: android
profile: production
submit_ios:
name: Submit iOS
distribute_ios:
name: Distribute iOS to TestFlight
needs: [build_ios]
type: submit
type: testflight
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
external_groups:
- Paseo Beta
submit_beta_review: true
submit_android:
name: Submit Android
@@ -41,7 +44,7 @@ jobs:
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, submit_ios]
needs: [build_ios, distribute_ios]
environment: production
runs_on: macos-medium
steps:
+376
View File
@@ -0,0 +1,376 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1296.0)
aws-sdk-core (3.257.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
rexml (~> 3.4, >= 3.4.2)
aws-sdk-kms (1.132.0)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.233.2)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.3)
cgi (0.5.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.7.20261007)
dotenv (2.8.1)
emoji_regex (3.2.3)
erb (6.0.7)
excon (1.7.3)
logger
faraday (2.15.0)
faraday-net_http (>= 2.0, < 3.6)
json
logger
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.5.0)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
fastimage (2.4.1)
fastlane (2.241.1)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
cgi (~> 0.4)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
erb (>= 4.0, < 7.0)
excon (>= 1.0.0, < 2.0.0)
faraday (~> 2.7)
faraday-cookie_jar (~> 0.0.8)
faraday-follow_redirects (~> 0.3)
faraday-multipart (~> 1.0)
faraday-retry (~> 2.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.99)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.5.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 3.4.0, < 4.0.0)
security (~> 0.3)
simctl (~> 1.6.3)
terminal-notifier (>= 3.1.0, < 4.0.0)
terminal-table (~> 4)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.109.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (1.3.1)
addressable (~> 2.9)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
multi_json (~> 1.11)
representable (~> 3.0)
retriable (>= 3.1, < 5.0)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.69.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.4.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.1)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.1)
googleauth (1.17.5)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.1.6)
domain_name (~> 0.5)
io-console (0.9.4)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.21.2)
jwt (3.3.0)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.2)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
net-http (0.10.0)
net-protocol (>= 0.3.0)
uri (>= 0.11.1)
net-protocol (0.4.0)
timeout
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pp (0.6.4)
prettyprint
prettyprint (0.2.1)
prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
representable (3.3.1)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (4.2.0)
rexml (3.4.4)
rouge (3.28.0)
rubyzip (3.7.0)
security (0.3.0)
signet (0.22.1)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (3.1.0)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
timeout (0.6.1)
trailblazer-option (0.1.2)
tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (3.3.0)
unicode-emoji (~> 4.3)
unicode-emoji (4.3.0)
uri (1.1.2)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
x86_64-linux-gnu
DEPENDENCIES
fastlane (~> 2.234)
multi_json
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1296.0) sha256=70cc7a44fa4824a9df5652b853dc96aaaf09ec20642cd9d42ed7335a36cc3567
aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
aws-sdk-s3 (1.233.2) sha256=b138737a0de8b02f163ae47a9fc184ac98fc9b90926237c7d7af716a14ea1221
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
excon (1.7.3) sha256=ea13fabcab2b5f5bb64e2952a0ac018bf9882832fe7a58206630be18ba888fa9
faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.241.1) sha256=e8e97f3274d678c74d9e473b98d202621262c40dfce2b43b7a05afde7caa2ea5
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
google-apis-core (1.3.1) sha256=20ce9ee1fde0be87fe30a135dfdbd4b9ee33ba32172794c1f2770e7a45361b2f
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
google-apis-storage_v1 (0.69.0) sha256=ca5faad4a244c609bb434f81884432c449e34b31a87623f7c32b582e464ca7c8
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
google-logging-utils (0.2.1) sha256=73945c3ac6800eba802c26b1744765d5057cc4d89182c7255ee1347b0ca96dc7
googleauth (1.17.5) sha256=92498efc6883630a7e011ce4e667f887e314e588d584410ba18df49f1303f3d7
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
signet (0.22.1) sha256=6e73e9f2bdc2f038a5fdc8a89d248792b1b55e9ca50ee1ba458d8f1d9f0a2151
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
BUNDLED WITH
4.0.23
+2
View File
@@ -2,6 +2,7 @@ const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidOptionalCamera = require("./plugins/with-android-optional-camera");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
@@ -145,6 +146,7 @@ export default {
withAndroidScroll,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
withAndroidOptionalCamera,
[
"expo-splash-screen",
{
+61
View File
@@ -0,0 +1,61 @@
import { createRequire } from "node:module";
import { afterEach, describe, expect, it } from "vitest";
// Load the copies the Expo CLI uses for prebuild, not older hoisted ones.
const requireFromExpo = createRequire(require.resolve("expo/package.json"));
const requireFromExpoCli = createRequire(requireFromExpo.resolve("@expo/cli/package.json"));
const { getPrebuildConfigAsync } = requireFromExpoCli("@expo/prebuild-config");
const { compileModsAsync } = requireFromExpoCli(
"@expo/config-plugins/build/plugins/mod-compiler.js",
);
interface ManifestEntry {
$: Record<string, string>;
}
const originalFdroidFlag = process.env.PASEO_FDROID_BUILD;
async function resolveAndroidManifest() {
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["android"] });
await compileModsAsync(exp, {
projectRoot: __dirname,
introspect: true,
platforms: ["android"],
assertMissingModProviders: false,
});
return exp._internal.modResults.android.manifest.manifest;
}
describe("Android app config", () => {
afterEach(() => {
if (originalFdroidFlag === undefined) {
delete process.env.PASEO_FDROID_BUILD;
} else {
process.env.PASEO_FDROID_BUILD = originalFdroidFlag;
}
});
it.each([
["Google Play", "0"],
["F-Droid", "1"],
])(
"%s build installs on devices without a camera",
async (_build, fdroidFlag) => {
process.env.PASEO_FDROID_BUILD = fdroidFlag;
const manifest = await resolveAndroidManifest();
const permissions = (manifest["uses-permission"] ?? []).map(
(entry: ManifestEntry) => entry.$["android:name"],
);
const features = (manifest["uses-feature"] ?? []).map((entry: ManifestEntry) => entry.$);
expect(permissions).toContain("android.permission.CAMERA");
expect(features).toEqual(
expect.arrayContaining([
{ "android:name": "android.hardware.camera", "android:required": "false" },
{ "android:name": "android.hardware.camera.autofocus", "android:required": "false" },
]),
);
},
30_000,
);
});
@@ -1,13 +1,24 @@
import { test } from "../support/fixtures";
import {
closeModelPicker,
openModelPicker,
seedAgentProfiles,
} from "../support/helpers/agent-profiles";
import { expectComposerVisible } from "../support/helpers/composer";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { writeFile } from "node:fs/promises";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { getServerId } from "../support/helpers/server-id";
import {
expectProviderInstalledInSettings,
findAcpCatalogProvider,
installAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
seedSavedSettingsHosts,
} from "../support/helpers/settings";
const ACP_PROVIDER = {
@@ -16,8 +27,13 @@ const ACP_PROVIDER = {
};
interface ProviderCatalogDaemonClient {
getLastServerInfoMessage(): {
features?: { hopliteProvider?: boolean; plugins?: boolean };
} | null;
connect(): Promise<void>;
close(): Promise<void>;
getDaemonConfig(): Promise<{ config: { providers?: Record<string, unknown> } }>;
getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string }> }>;
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
}
@@ -41,3 +57,124 @@ test.describe("ACP provider catalog", () => {
}
});
});
for (const theme of ["light", "dark"] as const) {
test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "hoplite-catalog-e2e",
});
try {
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(true);
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
expect(
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
).toBe(false);
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value }));
}, theme);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await expect(
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
).toHaveCount(0);
await openAddProviderArea(page);
await findAcpCatalogProvider(page, "Hoplite");
await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) });
await installAcpCatalogProvider(page, "Hoplite");
await expectProviderInstalledInSettings(page, "Hoplite");
await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) });
expect((await client.getDaemonConfig()).config.providers?.hoplite).toEqual({ enabled: true });
await writeFile(
testInfo.outputPath(`hoplite-added-${theme}.json`),
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
);
await removeHoplite(page);
await expect(
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
).toHaveCount(0);
expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined();
expect(
(await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"),
).toBe(false);
await writeFile(
testInfo.outputPath(`hoplite-removed-${theme}.json`),
JSON.stringify(await client.getProvidersSnapshot(), null, 2),
);
await openAddProviderArea(page);
await findAcpCatalogProvider(page, "Hoplite");
await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) });
await installAcpCatalogProvider(page, "Hoplite");
await expectProviderInstalledInSettings(page, "Hoplite");
} finally {
await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined);
await client.close().catch(() => undefined);
}
});
}
async function removeHoplite(page: import("@playwright/test").Page): Promise<void> {
await page.getByTestId("provider-actions-hoplite").click();
page.once("dialog", (dialog) => {
void dialog.accept();
});
await page.getByTestId("provider-remove-hoplite").click();
}
test("unsupported host asks for an update without writing Hoplite config", async ({
page,
outdatedDaemon,
}, testInfo) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "hoplite-unsupported",
port: Number(new URL(`http://${outdatedDaemon.endpoint}`).port),
});
try {
expect(client.getLastServerInfoMessage()?.features?.plugins).toBe(true);
expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(false);
const before = await client.getDaemonConfig();
await seedSavedSettingsHosts(page, [outdatedDaemon]);
await page.reload();
await openSettings(page);
await openSettingsHost(page, outdatedDaemon.serverId);
await openSettingsHostSection(page, outdatedDaemon.serverId, "providers");
await openAddProviderArea(page);
await page.getByRole("button", { name: "Add Hoplite", exact: true }).click();
const errorToast = page.getByTestId("app-toast-message");
await expect(errorToast).toHaveText("Update the host to add Hoplite.");
await expect(errorToast).toBeInViewport();
expect((await client.getDaemonConfig()).config).toEqual(before.config);
await expect(
page.getByRole("button", { name: "Hoplite provider details", exact: true }),
).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("hoplite-update-host.png") });
} finally {
await client.close();
}
});
test("model selector excludes Hoplite before opt-in", async ({ page }, testInfo) => {
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
clientIdPrefix: "hoplite-picker",
});
const profiles = await seedAgentProfiles([
{ id: "hoplite-picker-anchor", name: "Picker anchor", provider: "mock" },
]);
const workspace = await seedWorkspace({ repoPrefix: "hoplite-picker-" });
try {
await client.patchDaemonConfig({ removeProviders: ["hoplite"] });
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await openModelPicker(page);
await expect(page.getByTestId("model-provider-mock")).toBeVisible();
await expect(page.getByTestId("model-provider-hoplite")).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("hoplite-model-selector-absent.png") });
await closeModelPicker(page);
} finally {
await workspace.cleanup();
await profiles.restore();
await client.close();
}
});
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "../support/fixtures";
import {
@@ -12,12 +12,15 @@ import {
chooseAddProjectMethod,
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
expectAddProjectResultVisible,
navigateAddProjectResults,
openAddProjectFlow,
openAddProjectHostSelection,
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
addConnectedHostAndReload,
addConnectedHostsAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "../support/helpers/hosts";
@@ -214,6 +217,60 @@ test.describe("Add Project command-center flow", () => {
});
});
test("filtered hosts keep the keyboard-selected row visible", async ({ page }) => {
const hosts: IsolatedHostDaemon[] = [];
try {
for (let index = 1; index <= 3; index += 1) {
hosts.push(await startIsolatedHostDaemon(`scroll-host-${index}`));
}
await page.setViewportSize({ width: 1280, height: 360 });
await gotoAppShell(page);
await addConnectedHostsAndReload(
page,
hosts.map((host, index) => ({
serverId: host.serverId,
port: host.port,
label: index < 2 ? `Fleet 0${index + 1}` : "Other host",
})),
{ primaryLabel: "Fleet 00" },
);
for (const host of hosts) {
await waitForConnectedHost(page, {
serverId: host.serverId,
endpoint: `localhost:${host.port}`,
});
}
await openAddProjectHostSelection(page);
await expect(
addProjectFlow(page).getByRole("button").filter({ hasText: "Other host" }),
).toBeVisible();
await addProjectFlowInput(page).fill("Fleet");
await expect(addProjectFlowHost(page, hosts[2]!.serverId)).toHaveCount(0);
await navigateAddProjectResults(page, "ArrowDown", 2);
await expectAddProjectResultVisible(page, "Fleet 02");
// Filtering out the first host moves retained rows to new positions.
await addProjectFlowInput(page).fill("scroll-host");
await expect(addProjectFlowHost(page, getServerId())).toHaveCount(0);
await expectAddProjectResultVisible(page, "Fleet 01");
await navigateAddProjectResults(page, "ArrowDown", 2);
await expectAddProjectResultVisible(page, "Other host");
await navigateAddProjectResults(page, "ArrowDown", 1);
await expectAddProjectResultVisible(page, "Fleet 01");
await addProjectFlowInput(page).fill("Fleet");
await expectAddProjectResultVisible(page, "Fleet 00");
await navigateAddProjectResults(page, "ArrowDown", 2);
await expectAddProjectResultVisible(page, "Fleet 02");
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "method");
await expect(addProjectFlow(page)).toContainText("Fleet 02");
} finally {
await Promise.all(hosts.map((host) => host.close()));
}
});
test("keyboard directory search adds the selected Project", async ({
page,
projectPickerFixture,
@@ -223,6 +280,7 @@ test.describe("Add Project command-center flow", () => {
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await expect(addProjectFlowInput(page)).toBeFocused();
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
timeout: 30_000,
@@ -240,6 +298,59 @@ test.describe("Add Project command-center flow", () => {
await expectProjectHasNoWorkspaces(projectId);
});
test("keyboard navigation keeps the selected row inside the visible results", async ({
page,
}) => {
const parentDirectory = await mkdtemp(path.join(homedir(), "paseo-e2e-scroll-"));
const rowPath = (index: number) =>
path.join(parentDirectory, `scroll-probe-${String(index).padStart(2, "0")}`);
let projectId: string | null = null;
try {
await Promise.all(Array.from({ length: 16 }, (_, index) => mkdir(rowPath(index))));
await gotoAppShell(page);
await openAddProjectFlow(page);
await chooseAddProjectMethod(page, "directory-search");
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
const results = page.getByTestId("add-project-flow-results");
await expect(results.getByRole("button", { name: /scroll-probe-/ })).toHaveCount(16, {
timeout: 30_000,
});
await navigateAddProjectResults(page, "ArrowDown", 12);
await expectAddProjectResultVisible(page, rowPath(11));
await navigateAddProjectResults(page, "ArrowUp", 12);
await expectAddProjectResultVisible(page, parentDirectory);
await navigateAddProjectResults(page, "ArrowUp", 1);
await expectAddProjectResultVisible(page, rowPath(15));
await navigateAddProjectResults(page, "ArrowDown", 1);
await expectAddProjectResultVisible(page, parentDirectory);
await navigateAddProjectResults(page, "ArrowDown", 12);
await expectAddProjectResultVisible(page, rowPath(11));
await addProjectFlowInput(page).fill(`${path.basename(parentDirectory)}probe`);
await expect(results.getByRole("button")).toHaveCount(16);
await expectAddProjectResultVisible(page, rowPath(0));
await navigateAddProjectResults(page, "ArrowDown", 12);
await expectAddProjectResultVisible(page, rowPath(12));
await addProjectFlowInput(page).fill(path.basename(parentDirectory));
await expect(results.getByRole("button")).toHaveCount(17);
await expectAddProjectResultVisible(page, parentDirectory);
await navigateAddProjectResults(page, "ArrowDown", 12);
await expectAddProjectResultVisible(page, rowPath(11));
await page.keyboard.press("Enter");
projectId = await expectOpenedProject(page, "scroll-probe-11");
await expectNewWorkspaceForAddedProject(page, {
serverId: getServerId(),
projectId,
projectName: "scroll-probe-11",
projectPath: rowPath(11),
});
} finally {
if (projectId) await removeCreatedProject(rowPath(11), projectId);
await rm(parentDirectory, { recursive: true, force: true });
}
});
test("a complete repository URL remains selectable without a GitHub search result", async ({
page,
}) => {
@@ -284,7 +284,6 @@ async function expectRejectedSubmissionRestored(
await expectComposerDraft(page, input.prompt);
await expectComposerEditable(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
if (input.preservesActiveTurn) {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
return;
@@ -295,13 +294,21 @@ async function expectRejectedSubmissionRestored(
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toHaveCount(1);
const attempts = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(attempts).toHaveCount(2);
const userMessage = attempts.last();
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
}
async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
await expect(messages).toHaveCount(2);
await expect(messages.nth(0)).toContainText(prompt);
await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`);
}
async function configureSteerInSettings(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
@@ -1147,17 +1154,15 @@ test.describe("Agent message submission", () => {
page,
page.getByTestId("assistant-message").last(),
);
const assistantMessageCount = await page.getByTestId("assistant-message").count();
const toolCallCount = await page.getByTestId("tool-call-badge").count();
await composer.press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last();
await expect(userMessage).toBeVisible();
await expect
.poll(async () => page.getByTestId("assistant-message").count())
.toBeGreaterThan(assistantMessageCount);
await expect
.poll(async () => page.getByTestId("tool-call-badge").count())
.toBeGreaterThan(toolCallCount);
await expect(
page.getByTestId("assistant-message").filter({
hasText: "I'll take a look at the scroll anchor behavior you described.",
}),
).toBeVisible();
await awaitToolCall(page, "Read");
await finishTimelineRowStabilityCheck();
});
@@ -1173,13 +1178,14 @@ test.describe("Agent message submission", () => {
await toasts.expectNeverShown("agent-updating-toast");
});
test("restores a rejected submission and accepts its retry", async ({
test("retains a rejected submission before its error and accepts its retry", async ({
page,
rejectionScenario,
}) => {
const prompt = "Restore this rejected submission.";
await submitMessageThatWillBeRejected(page, prompt);
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
await expectRejectedPromptBeforeError(page, prompt, rejectionScenario.errorMessage);
await retryRestoredSubmission(page, prompt);
});
@@ -1209,6 +1215,7 @@ test.describe("Agent message submission", () => {
errorMessage: "Requested mock steer transport failure",
preservesActiveTurn: true,
});
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
@@ -26,7 +26,7 @@ test("agent list age advances and command center says just now for a fresh agent
const agentResult = commandCenter.getByTestId(
`command-center-agent-${getServerId()}:${session.agentId}`,
);
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
"just now",
);
await page.keyboard.press("Escape");
@@ -1,6 +1,13 @@
import { test, expect } from "../support/fixtures";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import { startRunningMockAgent } from "../support/helpers/composer";
import { startRunningMockAgent, submitMessage } from "../support/helpers/composer";
import {
seedLongMockAgentTimeline,
openAgentTimeline,
scrollTimelineUntilOlderHistoryIsReachable,
scrollTimelineToNewestLoadedEdge,
expectTimelinePromptVisible,
} from "../support/helpers/timeline-pagination";
import {
sampleStreamFrames,
summarizeStreamSmoothness,
@@ -102,6 +109,40 @@ agentStreamPerfDescribe("Agent stream smoothness", () => {
await agent.cleanup();
}
});
test("keeps streaming work bounded after revealing older history", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await page.addInitScript(() => {
Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
});
const agent = await seedLongMockAgentTimeline({ turns: 80, liveTurns: "thirty-minute-stream" });
try {
await openAgentTimeline(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await submitMessage(page, "Stream a long answer after reading older messages.");
await awaitAssistantMessage(page);
for (let sample = 0; sample < 3; sample += 1) {
await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
const frames = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
const smoothness = summarizeStreamSmoothness(frames, SAMPLE_WINDOW_MS);
const react = summarizeReactCommits(await readStreamReactCommits(page));
await testInfo.attach(`revealed-history-stream-${sample}`, {
body: JSON.stringify({ smoothness, react }, null, 2),
contentType: "application/json",
});
console.log(`[perf] Revealed history stream: ${JSON.stringify({ smoothness, react })}`);
expect(smoothness.growthFrames).toBeGreaterThan(20);
expect(react.count).toBeGreaterThan(20);
expect(react.actualDurationP95Ms).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
}
} finally {
await agent.cleanup();
}
});
});
async function readStreamReactCommits(
@@ -1,3 +1,12 @@
import type { Page } from "@playwright/test";
import { createReadStream } from "node:fs";
import { createServer } from "node:http";
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
import {
expectCompletedDiagram,
openDiagramFullscreen,
expectFullscreenDiagram,
} from "../support/helpers/diagram";
import { expect, test as base } from "../support/fixtures";
import {
appendSettledTimelineTurns,
@@ -16,6 +25,11 @@ import {
} from "../support/helpers/assistant-images";
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import path from "node:path";
import {
rememberTimelineViewport,
scrollTimelinePromptIntoView,
} from "../support/helpers/timeline-pagination";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
@@ -28,6 +42,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
},
});
test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Angle destination image",
fileName: "My image Мои.png",
});
const agent = await createSettledMockAgent(workspace, "Encoded image paths");
await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`);
await openAssistantImageTimeline(page, agent);
await expectAssistantImageRendered(page, image);
const encodedImage = { ...image, alt: "Encoded destination image" };
await emitSettledAssistantImage(
workspace.client,
agent,
encodedImage,
encodeURI(path.join(workspace.repoPath, image.relativePath)),
);
await expectAssistantImageRendered(page, encodedImage);
const literalPercentImage = await createSmallAssistantPng(workspace, {
alt: "Literal percent filename image",
fileName: "literal%20name.png",
});
await emitSettledAssistantImage(
workspace.client,
agent,
literalPercentImage,
encodeURI(literalPercentImage.relativePath),
);
await expectAssistantImageRendered(page, literalPercentImage);
});
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
@@ -60,11 +110,11 @@ test("opens a timeline image in a zoomable lightbox", async ({
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.getByRole("button", { name: "Open image attachment" }).click();
await page.getByRole("button", { name: image.alt }).click();
const lightboxImage = page.getByTestId("attachment-lightbox-image");
const canvas = page.getByTestId("attachment-lightbox-canvas");
await expect(lightboxImage).toBeVisible();
await canvas.hover();
await page.getByTestId("zoomable-viewport-toolbar").hover();
const transformedContent = canvas.locator(":scope > div").first();
const initialBox = await transformedContent.boundingBox();
const canvasBox = await canvas.boundingBox();
@@ -96,7 +146,7 @@ test("opens a timeline image in a zoomable lightbox", async ({
await expectAgentReadyToInterrupt(page);
});
test("reloading a timeline anchors near-tail assistant image growth", async ({
test("reloading a timeline reserves image height without a load-time scroll jump", async ({
imageWorkspace: workspace,
page,
}) => {
@@ -106,119 +156,34 @@ test("reloading a timeline anchors near-tail assistant image growth", async ({
fileName: "reload-geometry-image.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Reload image geometry");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
try {
delivery.release();
await emitSettledAssistantImage(workspace.client, imageAgent, image, delivery.url);
await appendSettledTimelineTurns(workspace.client, imageAgent, 3);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.addInitScript(
({ accessibleName }) => {
interface ImageGeometryEvent {
atMs: number;
complete: boolean;
height: number;
naturalHeight: number;
naturalWidth: number;
scrollTop: number;
width: number;
}
const state = {
accessibleName,
events: [] as ImageGeometryEvent[],
mutationObserver: null as MutationObserver | null,
resizeObserver: null as ResizeObserver | null,
target: null as Element | null,
};
const record = () => {
const target = state.target;
if (!target) return;
const imageElement =
target instanceof HTMLImageElement ? target : target.querySelector("img");
const scroll = target.closest('[data-testid="agent-chat-scroll"]');
const rect = target.getBoundingClientRect();
const event: ImageGeometryEvent = {
atMs: performance.now(),
complete: imageElement?.complete ?? false,
height: rect.height,
naturalHeight: imageElement?.naturalHeight ?? 0,
naturalWidth: imageElement?.naturalWidth ?? 0,
scrollTop: scroll instanceof HTMLElement ? scroll.scrollTop : -1,
width: rect.width,
};
const previous = state.events.at(-1);
if (
!previous ||
previous.complete !== event.complete ||
previous.height !== event.height ||
previous.naturalHeight !== event.naturalHeight ||
previous.naturalWidth !== event.naturalWidth ||
previous.scrollTop !== event.scrollTop ||
previous.width !== event.width
) {
state.events.push(event);
}
};
const inspect = () => {
const target = Array.from(document.querySelectorAll('[role="img"]')).find(
(element) => element.getAttribute("aria-label") === accessibleName,
);
if (!target || target === state.target) {
record();
return;
}
state.target = target;
state.resizeObserver?.disconnect();
state.resizeObserver = new ResizeObserver(record);
state.resizeObserver.observe(target);
record();
};
const start = () => {
state.mutationObserver = new MutationObserver(inspect);
state.mutationObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["src", "style"],
childList: true,
subtree: true,
});
inspect();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
(
window as unknown as {
__paseoReloadImageGeometry?: typeof state;
}
).__paseoReloadImageGeometry = state;
},
{ accessibleName: image.alt },
);
delivery.holdNextRequest();
await page.reload();
await delivery.waitUntilRequested();
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
await scrollTimelinePromptIntoView(
page,
"image-history-turn-2: emit 1 coalesced agent stream updates",
);
const beforeLoad = await rememberTimelineViewport(page);
const reservedHeight = (await rendered.boundingBox())?.height;
expect(reservedHeight).toBe(320);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await page.reload();
await expectAssistantImageRendered(page, image);
await page.waitForTimeout(500);
const events = await page.evaluate(() => {
const state = (
window as unknown as {
__paseoReloadImageGeometry?: { events: Array<unknown> };
}
).__paseoReloadImageGeometry;
if (!state) throw new Error("Reload image geometry observer was not installed");
return state.events;
});
const geometryEvents = events as Array<{ height: number; scrollTop: number }>;
const initialGeometry = geometryEvents.find((event) => event.height > 0);
if (!initialGeometry)
throw new Error(`Image geometry was never measurable: ${JSON.stringify(events)}`);
const expandedGeometry = geometryEvents.find((event) => event.height > initialGeometry.height);
if (!expandedGeometry)
throw new Error(`Image geometry never expanded: ${JSON.stringify(events)}`);
expect(expandedGeometry.scrollTop, JSON.stringify(events)).toBeGreaterThan(
initialGeometry.scrollTop,
);
delivery.release();
await expectAssistantImageRendered(page, image);
expect((await rendered.boundingBox())?.height).toBe(reservedHeight);
await expect.poll(() => rememberTimelineViewport(page)).toEqual(beforeLoad);
} finally {
await delivery.close();
}
});
test("a real assistant PNG remains reachable through pagination and remount", async ({
@@ -259,3 +224,222 @@ test("a near-10 MiB real assistant PNG renders and the app remains responsive",
response: "stress-update-0",
});
});
test("a later reply reads new bytes at the same workspace image path", async ({
imageWorkspace: workspace,
page,
}) => {
const agent = await createSettledMockAgent(workspace, "Screenshot revisions");
const first = await createSmallAssistantPng(workspace, {
alt: "Earlier screenshot",
fileName: "screenshot.png",
});
await emitSettledAssistantImage(workspace.client, agent, first);
await openAssistantImageTimeline(page, agent);
await expectAssistantImageRendered(page, first);
const second = await createSmallAssistantPng(workspace, {
alt: "Updated screenshot",
fileName: "screenshot.png",
width: 2,
height: 3,
});
await emitSettledAssistantImage(workspace.client, agent, second);
await expectAssistantImageRendered(page, second);
await expectAssistantImageRendered(page, first);
});
test("pending assistant images reserve their decoded height", async ({
imageWorkspace: workspace,
page,
}, testInfo) => {
const image = await createSmallAssistantPng(workspace, {
alt: "Delayed remote image",
fileName: "delayed.png",
width: 32,
height: 24,
});
const agent = await createSettledMockAgent(workspace, "Pending image geometry");
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
const { url } = delivery;
try {
await emitSettledAssistantImage(workspace.client, agent, image, url);
await openAssistantImageTimeline(page, agent);
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toHaveAttribute("src", /^data:image\/png/);
await delivery.waitUntilRequested();
const pendingBox = await rendered.boundingBox();
expect(pendingBox?.height).toBe(320);
await rendered.click();
await expect(page.getByTestId("attachment-lightbox-image")).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("pending-image.png") });
delivery.release();
await expect(rendered).toHaveAttribute("src", url);
expect((await rendered.boundingBox())?.height).toBe(pendingBox?.height);
} finally {
await delivery.close();
}
});
/** Serve the fixture's real PNG bytes; release controls when the decoder can finish. */
async function serveDelayedImage(file: string) {
let release!: () => void;
let ready: Promise<void>;
let requested!: () => void;
let requestStarted: Promise<void>;
const holdNextRequest = () => {
ready = new Promise<void>((resolve) => {
release = resolve;
});
requestStarted = new Promise<void>((resolve) => {
requested = resolve;
});
};
holdNextRequest();
const server = createServer((_request, response) => {
requested();
void ready.then(() => {
response.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" });
return createReadStream(file).pipe(response);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Image server did not listen");
return {
url: `http://127.0.0.1:${address.port}/image.png`,
release: () => release(),
holdNextRequest,
waitUntilRequested: () => requestStarted,
close: async () => {
release();
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
};
}
test("late workspace image completion preserves selected prose", async ({
imageWorkspace,
page,
}) => {
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
const selected = await selectImageFixtureParagraphs(page);
release();
await expectAssistantImageRendered(page, image);
await expectSelectedParagraphsRetained(page, selected);
});
});
test("late workspace image completion keeps Mermaid fullscreen open", async ({
imageWorkspace,
page,
}) => {
await withPendingWorkspaceImage(page, imageWorkspace, async (image, release) => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Done"]);
release();
await expectAssistantImageRendered(page, image);
await expectFullscreenDiagram(page, ["Start", "Done"]);
});
});
async function withPendingWorkspaceImage(
page: Page,
workspace: SeededWorkspace,
run: (
image: Awaited<ReturnType<typeof createSmallAssistantPng>>,
release: () => void,
) => Promise<void>,
) {
const image = await createSmallAssistantPng(workspace, {
alt: "Late workspace image",
fileName: "late.png",
width: 32,
height: 24,
});
const source = [
"Paragraph one stays selected while the workspace image loads.",
"Paragraph two remains in this reply.",
"Paragraph three is the selection endpoint.",
"```mermaid\nflowchart LR\n Start --> Done\n```",
`![${image.alt}](${image.relativePath})`,
].join("\n\n");
const agent = await createSettledMockAgent(workspace, "Delayed workspace read", source);
await emitSettledAssistantImage(workspace.client, agent, image);
const gate = await delayFileReadResponse(page, image.relativePath);
let released = false;
const release = () => {
if (!released) {
released = true;
gate.release();
}
};
try {
await openAssistantImageTimeline(page, agent);
await gate.waitUntilHeld();
await expectCompletedDiagram(page, ["Start", "Done"]);
await run(image, release);
} finally {
release();
}
}
type SelectionProofWindow = Window & { __lateImageSelection?: { start: Node; end: Node } };
async function selectImageFixtureParagraphs(page: Page): Promise<string> {
const message = page.getByTestId("assistant-message");
await message
.getByText("Paragraph one stays selected while the workspace image loads.", { exact: true })
.scrollIntoViewIfNeeded();
const bounds = await message.evaluate((root) => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let start: Text | undefined;
let end: Text | undefined;
while (walker.nextNode()) {
const text = walker.currentNode as Text;
if (text.data.startsWith("Paragraph one stays")) start = text;
if (text.data.startsWith("Paragraph three is")) end = text;
}
if (!start || !end) throw new Error("Selection paragraphs are missing");
(window as SelectionProofWindow).__lateImageSelection = { start, end };
const first = document.createRange();
first.selectNodeContents(start);
const last = document.createRange();
last.selectNodeContents(end);
return {
first: first.getBoundingClientRect().toJSON(),
last: last.getBoundingClientRect().toJSON(),
};
});
await page.mouse.move(bounds.first.x + 1, bounds.first.y + 5);
await page.mouse.down();
await page.mouse.move(bounds.last.right - 1, bounds.last.bottom - 2, { steps: 20 });
await page.mouse.up();
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("Paragraph one");
expect(selected).toContain("Paragraph three");
return selected;
}
async function expectSelectedParagraphsRetained(page: Page, selected: string) {
await expect
.poll(() =>
page.evaluate(() => {
const proof = (window as SelectionProofWindow).__lateImageSelection;
return {
startConnected: proof?.start.isConnected,
endConnected: proof?.end.isConnected,
text: window.getSelection()?.toString(),
};
}),
)
.toEqual({ startConnected: true, endConnected: true, text: selected });
}
@@ -10,6 +10,8 @@ import {
reportScrollJumps,
scrollCadences,
withVariedTimeline,
withStreamingReader,
holdParagraphThroughCompletion,
} from "../support/helpers/timeline-scroll-smoothness";
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
@@ -133,6 +135,58 @@ test("scroll detector accounts for delayed wheel input and growth below the new
).toHaveLength(1);
});
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
for (const height of [225, 1632]) {
const before: ScrollFrame = {
at: 1000,
scrollTop: 2208,
scrollHeight: 5000,
viewportHeight: 516,
virtualized: true,
loading: false,
rows: [
{ id: "image", top: -704, height: 560 },
{ id: "caption", top: -144, height: 100 },
{ id: "old-reader", top: -44, height: 127 },
],
anchor: "old-reader",
wheelTotal: 0,
lastWheelAt: 0,
inputFinishedAt: null,
imageLoads: 0,
mounted: 3,
unmounted: 0,
};
const resize = height - 560;
const after: ScrollFrame = {
...before,
at: 1060,
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
scrollTop: 1938,
scrollHeight: 5000 + resize - 110,
wheelTotal: 160,
lastWheelAt: 1020,
imageLoads: 1,
rows: [
{ id: "image", top: -544, height },
{ id: "caption", top: 16 + resize, height: 100 },
{ id: "old-reader", top: 116 + resize, height: 127 },
],
};
expect(findScrollJumps([before, after])).toEqual([]);
// Resizing must not excuse additional viewport movement.
expect(
findScrollJumps([
before,
{
...after,
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
},
]),
).toHaveLength(1);
}
});
for (const cadence of scrollCadences) {
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
page,
@@ -150,3 +204,22 @@ for (const cadence of scrollCadences) {
test("reserves image space before its response arrives", async ({ page }, testInfo) => {
await expectImageSpaceReserved(page, testInfo);
});
test("holds a paragraph still while a mixed response streams and completes", async ({ page }) => {
await withStreamingReader(async (agent) => {
const reading = await holdParagraphThroughCompletion(page, agent);
expect(
reading.textGrowth,
"the held reader must observe an actively growing response",
).toBeGreaterThan(1000);
expect(
reading.maxFollowGap,
"follow-bottom must catch each streamed commit before the next frame",
).toBeLessThanOrEqual(1);
expect(
reading.maxDrift,
"streaming must preserve the held paragraph's viewport position",
).toBeLessThanOrEqual(1);
expect(reading.retained, "completion must retain the paragraph's DOM node").toBe(true);
});
});
@@ -0,0 +1,48 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openSettingsSection } from "../support/helpers/settings";
async function openAppearance(page: Page) {
await page.goto("/settings");
await openSettingsSection(page, "appearance");
}
async function setFontFamily(page: Page, name: string, family: string) {
const input = page.getByRole("textbox", { name });
await input.fill(family);
await input.press("Tab");
}
for (const name of ["Code font family", "Interface font family"]) {
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
await openAppearance(page);
await setFontFamily(page, name, "'Fira Code', monospace");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await setFontFamily(page, name, "'foo");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
await page.reload();
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await setFontFamily(page, name, "");
await expect(page.getByRole("textbox", { name })).toHaveValue("");
});
}
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
);
});
await openAppearance(page);
await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
});
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true,
@@ -2,7 +2,9 @@ import { expect, test } from "../support/fixtures";
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
test("caps a newline-heavy assistant message on web before rendering markdown", async ({
page,
}) => {
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
const agent = await seedMockAgentWorkspace({
@@ -73,9 +73,14 @@ const ASSISTANT_MARKDOWN = [
].join("\n");
const EXPECTED_WHOLE_SELECTION_MARKDOWN = ASSISTANT_MARKDOWN.replace(
`${CROSS_STRUCTURE_MARKDOWN}\n\n`,
"",
"www.example.com stays",
"[www.example.com](http://www.example.com) stays",
)
.replace(
"`https://example.com/generated` stays",
"[`https://example.com/generated`](https://example.com/generated) stays",
)
.replace(`${CROSS_STRUCTURE_MARKDOWN}\n\n`, "")
.replace(
"<https://autolink.example.com>",
"[https://autolink.example.com](https://autolink.example.com)",
@@ -98,8 +103,7 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
}
/**
* The rows of one assistant message. A message renders one row per Markdown block, so
* every locator and every text walk below covers the group, not a single block.
* Locate assistant rows by source-message identity.
*/
function assistantMessageBlocks(page: Page): Locator {
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
@@ -173,7 +177,7 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
const blocks = assistantMessageBlocks(page);
const target = blocks.getByText(text);
await target.scrollIntoViewIfNeeded();
const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox();
const frame = await blocks.locator("img").boundingBox();
const end = await target.evaluate((element, selectedText) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
@@ -192,7 +196,8 @@ async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
if (!frame) {
throw new Error("Expected a rendered image");
}
await page.mouse.move(end.left + 5, frame.y + frame.height + 4);
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await page.mouse.move(end.left + 1, end.y - 14);
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 10 });
await page.mouse.up();
@@ -210,18 +215,12 @@ async function selectAssistantListItemFromMarker(
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
const item = assistantMessage
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
.locator(listTag)
.filter({ hasText: itemText })
.locator(':scope > [data-paseo-markdown-tag="li"]')
.locator(":scope > li")
.filter({ hasText: itemText });
await item.evaluate((element, selectedEndText) => {
const marker = element.querySelector(':scope > [data-paseo-markdown-list-marker="true"]');
const markerText = marker?.firstChild;
if (!(markerText instanceof Text)) {
throw new Error("Expected rendered list marker text");
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let endNode: Text | null = null;
let endOffset = -1;
@@ -239,7 +238,7 @@ async function selectAssistantListItemFromMarker(
}
const range = document.createRange();
range.setStart(markerText, 0);
range.setStart(element, 0);
range.setEnd(endNode, endOffset);
const selection = window.getSelection();
selection?.removeAllRanges();
@@ -254,7 +253,7 @@ async function doubleClickAssistantMarkdownText(
): Promise<void> {
const assistantMessage = assistantMessageBlocks(page);
await assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.locator(tag)
.filter({ hasText: text })
.dblclick({ position: { x: 5, y: 5 } });
}
@@ -276,39 +275,23 @@ async function selectAssistantTextRange(
found = blockWalker.nextNode();
}
}
let cursor = 0;
const walker = { nextNode: () => nodes[cursor++] ?? null };
let startNode: Node | null = null;
let startOffset = -1;
let endNode: Node | null = null;
let endOffset = -1;
let textNode = walker.nextNode();
while (textNode) {
if (!startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.startText) ?? -1;
if (offset >= 0) {
startNode = textNode;
startOffset = offset;
}
}
if (startNode) {
const offset = textNode.textContent?.indexOf(selectedRange.endText) ?? -1;
if (offset >= 0) {
endNode = textNode;
endOffset = offset + selectedRange.endText.length;
break;
}
}
textNode = walker.nextNode();
}
if (!startNode || !endNode) {
const text = nodes.map((node) => node.data).join("");
const start = text.indexOf(selectedRange.startText);
const end = text.indexOf(selectedRange.endText, start);
if (start < 0 || end < 0)
throw new Error(
`Could not find assistant text range: ${selectedRange.startText} — ${selectedRange.endText}`,
);
}
const boundary = (offset: number, isEnd = false): [Text, number] => {
for (const node of nodes) {
if (offset < node.length || (isEnd && offset === node.length)) return [node, offset];
offset -= node.length;
}
throw new Error("Selection extends beyond message");
};
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
range.setStart(...boundary(start));
range.setEnd(...boundary(end + selectedRange.endText.length, true));
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
@@ -428,16 +411,12 @@ test("copying an assistant selection preserves Markdown structure and links", as
["em", "emphasized prose"],
["s", "struck prose"],
]) {
const formattedProse = assistantMessage
.locator(`[data-paseo-markdown-tag="${tag}"]`)
.filter({ hasText: text });
const formattedProse = assistantMessage.locator(tag).filter({ hasText: text });
await expect(formattedProse).toHaveCSS("font-family", "serif");
await expect(formattedProse).not.toHaveAttribute("data-pmono");
}
const inlineCode = assistantMessage
.locator('[data-paseo-markdown-tag="code"]')
.filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveAttribute("data-pmono", "");
const inlineCode = assistantMessage.locator("code").filter({ hasText: "apply_patch" });
await expect(inlineCode).toHaveCSS("font-family", /monospace|Mono/);
await selectStructuredClipboardFixture(page);
await copySelection(page);
@@ -466,7 +445,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
'<a href="file:///tmp/paseo%20notes.md#L4">workspace file</a>',
);
expect(clipboard.html).toContain("<code>https://example.com/generated</code>");
expect(clipboard.html).not.toContain(
expect(clipboard.html).toContain(
'<a href="https://example.com/generated"><code>https://example.com/generated</code></a>',
);
expect(clipboard.html).toContain('<code class="language-typescript">');
@@ -89,9 +89,11 @@ async function dismissStackedModelPickerWithBackdrop(page: Page) {
.getByTestId("agent-controls-settings-list")
.getByRole("button", { name: /Select model/ })
.click();
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeVisible();
await expect(page.getByTestId("agent-controls-model-browser-sheet")).toBeInViewport();
await page.mouse.click(MOBILE_VIEWPORT.width / 2, 24);
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
// In CI, Gorhom retains the dismissed sheet below the viewport.
// Dismissal is complete when it leaves the displayed surface.
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
await expect(page.getByTestId("agent-controls-model-sheet")).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
}
@@ -139,7 +141,7 @@ test.describe("mobile bottom sheet reopen", () => {
await expect(sheet).toBeVisible();
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeInViewport();
});
} finally {
await session.cleanup();
@@ -0,0 +1,122 @@
import { appendFile } from "node:fs/promises";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
function changesTree(page: Page) {
return page.getByTestId("changes-tree-panel").filter({ visible: true });
}
async function expectEmptyChanges(page: Page) {
await expect(changesTree(page).getByText("No changes to display", { exact: true })).toBeVisible();
}
async function editReadme(repoPath: string, text: string) {
await appendFile(path.join(repoPath, "README.md"), text);
}
async function expectReadmeChanges(page: Page, additions: number) {
await expect(changesTree(page).getByText("README.md", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(changesTree(page)).toContainText(`+${additions}`);
}
async function openEmptyDiff(page: Page) {
await changesTree(page).getByTestId("changes-options-menu").click();
await page.getByTestId("changes-open-tab").click();
await expect(
page
.getByTestId("working-diff-panel")
.filter({ visible: true })
.getByText("No changes to display", { exact: true }),
).toBeVisible();
}
async function expectReadmeDiff(page: Page, additions: number) {
await expect(
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
).toHaveAccessibleName(`README.md, +${additions}, -0`, { timeout: 30_000 });
}
async function openDiffFromNewTabMenu(page: Page) {
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first().click();
await page.getByTestId("workspace-new-tab-menu-diff").filter({ visible: true }).first().click();
await expect(
page
.getByTestId("working-diff-panel")
.filter({ visible: true })
.getByText("No changes to display", { exact: true }),
).toBeVisible();
}
async function refreshDiff(page: Page) {
await page
.getByTestId("working-diff-panel")
.filter({ visible: true })
.getByTestId("changes-refresh")
.click();
}
async function reloadWorkspace(page: Page) {
await page.reload();
}
test("Changes follows the first edit to a clean workspace without reloading", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace();
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openChangesTreePanel(page);
await expectEmptyChanges(page);
await editReadme(workspace.repoPath, "first edit\n");
await expectReadmeChanges(page, 1);
await editReadme(workspace.repoPath, "second edit\n");
await expectReadmeChanges(page, 2);
});
test("the full diff follows the first edit to a clean workspace without reloading", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace();
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openChangesTreePanel(page);
await expectEmptyChanges(page);
await openEmptyDiff(page);
await editReadme(workspace.repoPath, "first edit\n");
await expectReadmeDiff(page, 1);
await editReadme(workspace.repoPath, "second edit\n");
await expectReadmeDiff(page, 2);
});
test("a directly opened Diff tab follows the first edit without an active Changes tree", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace();
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openDiffFromNewTabMenu(page);
await editReadme(workspace.repoPath, "first edit\n");
await expectReadmeDiff(page, 1);
await editReadme(workspace.repoPath, "second edit\n");
await expectReadmeDiff(page, 2);
await refreshDiff(page);
await expectReadmeDiff(page, 2);
await reloadWorkspace(page);
await expectReadmeDiff(page, 2);
await editReadme(workspace.repoPath, "third edit\n");
await expectReadmeDiff(page, 3);
});
@@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
+90 -12
View File
@@ -5,6 +5,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
@@ -19,6 +20,10 @@ import {
} from "../support/helpers/composer";
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
import {
expectPromptsDrawnByPlugin,
withPromptTimelinePlugin,
} from "../support/helpers/plugin-timeline";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
@@ -33,9 +38,10 @@ const STREAMED_RESPONSE = [
const RESPONSE =
'İ😀hello **world**; hello *world*.\n\n[hello &amp; world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
// One message far taller than the viewport, so its first and last paragraph rows are
// never in the virtualizer's window at the same time.
// One message taller than the viewport, exercising navigation between distant
// matches inside the same rendered row.
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
const VIRTUALIZED_TURN_COUNT = 60;
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
@@ -119,6 +125,49 @@ async function closeFind(page: Page) {
.toEqual([]);
}
async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") {
// The scrolling surface has no accessible name; use its existing test id.
const viewport = page.getByTestId("agent-chat-scroll");
await waitForTimelineGeometryToSettle(page);
const before = await viewport.evaluate((element) => element.scrollTop);
await page.keyboard.press(key);
const direction = key === "PageUp" ? -1 : 1;
await expect
.poll(
async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before),
)
.toBeGreaterThan(100);
}
test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-scroll-focus-",
title: "Chat Find keyboard scrolling",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await closeFind(page);
await expectChatKeyboardScroll(page, "PageUp");
await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") });
await expectChatKeyboardScroll(page, "PageDown");
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await page.getByRole("button", { name: "Close Find", exact: true }).click();
await expect(query(page)).toHaveCount(0);
await expectChatKeyboardScroll(page, "PageUp");
} finally {
await agent.cleanup();
}
});
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
page,
}, testInfo) => {
@@ -173,7 +222,7 @@ async function highlightedMessage(page: Page) {
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
@@ -218,6 +267,34 @@ test("counts and steps through every match in the chat, not just the selected me
}
});
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-plugin-prompt-",
title: "Chat Find plugin prompt",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await withPromptTimelinePlugin(async () => {
await openAgentRoute(page, agent);
await expectPromptsDrawnByPlugin(page);
await openChatFind(page);
await query(page).fill("scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "plugin-prompt");
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
});
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
@@ -322,11 +399,11 @@ test("finds a hit in a message that streamed while the chat was open", async ({
const chat = await openChatStreamingMultiBlockReply(page);
try {
await openChatFind(page);
// The only hit is in the first paragraph, which is no longer the row that grows.
// The first-paragraph hit must remain searchable while the same message grows.
await expectFindStatus(page, "alpha-needle", "1 of 1");
await chat.replyFinished();
await expectFindStatus(page, "beta-needle", "1 of 1");
// One message, two Markdown blocks, one count across both.
// One message, two paragraphs, one count across both.
await expectFindStatus(page, "pair-needle", "1 of 2");
await expectNextMatch(page, "2 of 2", "pair-needle");
await closeFind(page);
@@ -346,12 +423,14 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
for (let index = 0; index < 6; index++) {
for (let index = 0; index < VIRTUALIZED_TURN_COUNT; index++) {
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await openAgentRoute(page, agent);
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
await expect(
page.getByText(`Prompt number ${VIRTUALIZED_TURN_COUNT - 1}`, { exact: true }),
).toBeVisible();
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
return agent;
} catch (error) {
@@ -360,9 +439,8 @@ async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgent
}
}
// Every block row of the selected message must be mounted for Find to see it, whatever
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
// from the count and Next walks off to the next message instead.
// Find must count distant paragraphs in one source message as well as messages
// outside the mounted history window. Next must not skip a same-message hit.
test("counts every occurrence of a virtualized message and steps between them", async ({
page,
}) => {
@@ -370,9 +448,9 @@ test("counts every occurrence of a virtualized message and steps between them",
const chat = await openChatWithVirtualizedTallMessage(page);
try {
await openChatFind(page);
await expectFindStatus(page, "span-needle", "1 of 12");
await expectFindStatus(page, "span-needle", `1 of ${VIRTUALIZED_TURN_COUNT * 2}`);
await expectHighlight(page, "span-needle");
await expectNextMatch(page, "2 of 12", "span-needle");
await expectNextMatch(page, `2 of ${VIRTUALIZED_TURN_COUNT * 2}`, "span-needle");
await closeFind(page);
} finally {
await chat.cleanup();
@@ -25,6 +25,12 @@ import {
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
attachPromptTimelineEvidence,
expectPromptsDrawnByPlugin,
expectVirtualizedPluginPromptVisible,
withPromptTimelinePlugin,
} from "../support/helpers/plugin-timeline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -105,6 +111,60 @@ test.describe("desktop chat outline", () => {
}
});
test("jumps to prompts a timeline plugin draws", async ({ page }) => {
test.setTimeout(180_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await withPromptTimelinePlugin(async () => {
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await expectPromptsDrawnByPlugin(page);
await expectTimelinePromptNotMounted(page, agent.prompts[39]);
// Unloaded, then loaded, then near the newest edge.
await clickChatOutlineRowEdge(page, 40);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
await attachPromptTimelineEvidence(page, test.info());
await clickChatOutlineRowEdge(page, 45);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[44]);
await clickChatOutlineRowEdge(page, 78);
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.prompts[77]);
await expectPromptsDrawnByPlugin(page);
});
} finally {
await agent.cleanup();
}
});
test("jumps to a loaded virtualized prompt a timeline plugin draws without fetching", async ({
page,
}) => {
test.setTimeout(180_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
const prompt = agent.prompts[29]!;
try {
await withPromptTimelinePlugin(async () => {
const history = await holdOlderHistoryPages(page, agent);
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await scrollThroughOlderHistoryPages(page, 2, history);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptNotMounted(page, prompt);
const requestsBeforeJump = history.requestCount();
await clickChatOutlineRowEdge(page, 30);
await expectTimelinePromptLandedBelowTop(page, prompt);
await expectVirtualizedPluginPromptVisible(page, prompt);
await attachPromptTimelineEvidence(page, test.info());
expect(history.requestCount()).toBe(requestsBeforeJump);
}, [prompt]);
} finally {
await agent.cleanup();
}
});
test.describe("with the whole conversation loaded", () => {
let agent: LongTimelineAgent;
@@ -45,7 +45,8 @@ test("opening the command center keeps its first result fully visible", async ({
await expectCommandCenterToRemainAtTheTop(page);
await expectPrimaryCommandCenterActions(panel);
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
// The empty query lists the most recent workspaces, so the last one seeded leads it.
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
} finally {
await seeded.cleanup();
@@ -0,0 +1,192 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
import { expectOpenTabCount } from "../support/helpers/sheet";
import {
closeCompactWorkspaceMenu,
goBackInMenu,
menuRow,
openCompactWorkspaceMenu,
openRunningScriptPage,
openScriptsPage,
runScriptFromScriptsPage,
scriptRow,
scriptsMenuRow,
} from "../support/helpers/compact-workspace-scripts";
const labels = {
long: {
title: "workspace-with-a-very-long-release-and-investigation-name",
project: "project-with-a-very-long-customer-integration-name",
},
short: { title: "main", project: "Paseo" },
};
async function openNamedWorkspace(page: Page, names: typeof labels.long) {
const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
try {
await workspace.client.renameProject(workspace.projectId, names.project);
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Header layout check",
modeId: "load-test",
model: "e2e-fast-stream",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
await expect(
page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
).toHaveCount(1);
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
const menu = page.getByTestId("menu-button").filter({ visible: true });
const title = page.getByTestId("workspace-header-title");
const project = page.getByTestId("workspace-header-subtitle");
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
const trigger = page.getByRole("button", { name: /Switch tabs/ });
const measure = async () => ({
menu: await menu.boundingBox(),
title: await title.boundingBox(),
project: await project.boundingBox(),
badge: await badge.boundingBox(),
icon: await badge.locator("svg").boundingBox(),
trigger: await trigger.boundingBox(),
titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
});
await page.screenshot({ path: info.outputPath("header.png") });
await expect(async () => {
const bounds = await measure();
expect(bounds.menu).not.toBeNull();
expect(bounds.title).not.toBeNull();
expect(bounds.project).not.toBeNull();
expect(bounds.badge).not.toBeNull();
expect(bounds.icon).not.toBeNull();
expect(bounds.trigger).not.toBeNull();
// The labels sit between the menu button and the actions without overlapping either.
expect(bounds.title!.x).toBeGreaterThan(bounds.menu!.x + bounds.menu!.width);
expect(bounds.project!.x).toBe(bounds.title!.x);
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
expect(box.x + box.width).toBeLessThan(bounds.trigger!.x);
}
expect(bounds.project!.width).toBeGreaterThan(20);
// Long labels truncate instead of squeezing the host icon.
expect(bounds.icon!.width).toBe(bounds.icon!.height);
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
expect(bounds.titleTruncated).toBe(long);
expect(bounds.projectTruncated).toBe(long);
}).toPass({ timeout: 5_000 });
await info.attach("header-bounds", {
body: JSON.stringify(await measure(), null, 2),
contentType: "application/json",
});
}
for (const width of [320, 390]) {
for (const kind of ["long", "short"] as const) {
test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 844 });
const workspace = await openNamedWorkspace(page, labels[kind]);
try {
await expectHeaderContentsFit(page, info, kind === "long");
} finally {
await workspace.cleanup();
}
});
}
}
const KEEP_ALIVE = 'node -e "setInterval(() => {}, 1000)"';
const WEB_SERVER =
"node -e \"require('http').createServer((q, r) => r.end('ok')).listen(process.env.PASEO_PORT)\"";
async function openWorkspaceWithScripts(page: Page) {
const workspace = await seedWorkspace({
repoPrefix: "compact-scripts-",
repo: {
paseoConfig: {
scripts: {
lint: { type: "script", command: KEEP_ALIVE },
web: { type: "service", command: WEB_SERVER },
},
},
},
});
try {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Scripts menu check",
modeId: "load-test",
model: "e2e-fast-stream",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
test("compact header menu runs, shows, and stops workspace scripts", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const workspace = await openWorkspaceWithScripts(page);
try {
await expectOpenTabCount(page, 1);
await test.step("nothing running: the Scripts row has no value", async () => {
await openCompactWorkspaceMenu(page);
await expect(scriptsMenuRow(page)).toHaveAccessibleName("Scripts");
await closeCompactWorkspaceMenu(page);
});
await test.step("a stopped script runs from the Scripts page into a terminal tab", async () => {
await runScriptFromScriptsPage(page, "lint");
await expectOpenTabCount(page, 2);
await openCompactWorkspaceMenu(page);
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
await closeCompactWorkspaceMenu(page);
});
await test.step("two running scripts are counted on the root row", async () => {
await runScriptFromScriptsPage(page, "web");
await expectOpenTabCount(page, 3);
await openCompactWorkspaceMenu(page);
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*2 running$/);
await closeCompactWorkspaceMenu(page);
});
await test.step("stopping a script keeps its page open in the stopped state", async () => {
await openRunningScriptPage(page, "web");
await expect(menuRow(page, "View service")).toBeVisible();
await menuRow(page, "Stop").click();
await expect(menuRow(page, "Run")).toBeVisible({ timeout: 15_000 });
await expect(menuRow(page, "Stop")).toHaveCount(0);
await goBackInMenu(page);
await expect(scriptRow(page, "web")).toHaveAccessibleName(/^web\s*exit \d+$/);
await expect(scriptRow(page, "lint")).toHaveAccessibleName(/^lint\s*Running$/);
await closeCompactWorkspaceMenu(page);
});
await test.step("the root row names the one script still running", async () => {
await openScriptsPage(page);
await goBackInMenu(page);
await expect(scriptsMenuRow(page)).toHaveAccessibleName(/^Scripts\s*lint$/);
});
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,162 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectSubagentRowVisible,
openSubagentFromCompactTrack,
seedSubagent,
} from "../support/helpers/subagents";
import {
closeSheet,
expectOpenTabCount,
openSheetAsTab,
expectDeclinedDiscardPrompt,
expectSheetAtSnap,
replaceSheetFileText,
selectTabFromSwitcher,
sheet,
sheetHeader,
tapSheetBackdrop,
} from "../support/helpers/sheet";
const FILE_LINK = "linked.ts:3";
function promptMentioning(target: string): string {
return [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n");
}
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const workspace = await seedMockAgentWorkspace({
repoPrefix: "compact-workspace-sheet-",
title: "Sheet parent",
initialPrompt: promptMentioning(FILE_LINK),
});
try {
const gate = await installDaemonWebSocketGate(page);
const linkedPath = path.join(workspace.cwd, "linked.ts");
await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
const child = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Sheet child" },
);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expectOpenTabCount(page, 1);
await test.step("a chat file link opens the file in a sheet", async () => {
const fileLink = page.getByText(FILE_LINK, { exact: true });
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.click();
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
await expect(fileSheet.getByText("three")).toBeVisible();
await expectSheetAtSnap(page, "linked.ts");
await expect(
sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }),
).toHaveCount(0);
await expectOpenTabCount(page, 1);
await closeSheet(page, "linked.ts");
});
await test.step("a subagent's row shows the facts its sheet shows", async () => {
await page.getByTestId("subagents-track-header").click();
await expect(page.getByTestId(`subagents-track-row-facts-${child.id}`)).toHaveText(
"Ten second stream · Low · Load test",
);
await tapSheetBackdrop(page);
});
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await expect(childSheet).toBeVisible();
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
// seeded agent has no context usage yet.
await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText(
"Ten second stream · Low · Load test",
);
await expectOpenTabCount(page, 1);
});
await test.step("closing the subagent's sheet uncovers the list", async () => {
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title);
await openSheetAsTab(page, child.title);
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
await expectOpenTabCount(page, 2);
await expectComposerVisible(page);
});
await test.step("the list the subagent was opened from closed with its tab", async () => {
await selectTabFromSwitcher(page, "Sheet parent");
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
});
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
await page
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
.click();
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
const menuBase = `workspace-tab-menu-agent_${child.id}`;
await page.getByTestId(`${menuBase}-trigger`).click();
await page.getByTestId(`${menuBase}-close`).click();
await expectOpenTabCount(page, 1);
// Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is
// tapped once the row menu's own sheet has gone.
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
});
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
await page.getByText(FILE_LINK, { exact: true }).click();
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
// Hold the autosave so the edit stays unsaved.
gate.holdNextClientRequest("fs.file.write.request");
await replaceSheetFileText(page, "linked.ts", "edited in the sheet");
await gate.waitForHeldClientRequest();
await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page));
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expectDeclinedDiscardPrompt(page, () =>
sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(),
);
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
gate.releaseHeldClientRequest();
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
});
} finally {
await workspace.cleanup();
}
});
@@ -9,7 +9,12 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
@@ -96,16 +101,6 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
return page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
);
}
async function installListCommandsStub(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
@@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => {
timeout: 30_000,
});
await page.getByRole("button", { name: "Open menu" }).click();
// The sidebar covers the composer, so the composer gives up its popover while the
// sidebar is open and brings it back once the sidebar closes.
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await expect(popover).toHaveCount(0);
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
const topTestId = await getTopTestIdAtPoint(
page,
popoverBox!.x + popoverBox!.width / 2,
popoverBox!.y + popoverBox!.height / 2,
);
expect(topTestId).not.toBe("composer-autocomplete-popover");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
} finally {
await agent.cleanup();
}
@@ -1,7 +1,14 @@
import type { Locator } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
fillComposerDraft,
expectComposerDraft,
attachFileFromMenu,
} from "../support/helpers/composer";
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -26,14 +33,19 @@ async function openComposerDiff(page: Page) {
await composerChangesPill(page).click();
}
async function seedChangedAgent(repoPrefix: string) {
async function seedChangedAgent(
repoPrefix: string,
featureValues?: Record<string, unknown>,
files: Array<{ path: string; content: string }> = [],
) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
featureValues,
title: "Composer diff stat",
repo: {
withRemote: true,
// Exclude the local remote before the daemon starts observing this checkout.
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files],
},
});
try {
@@ -156,3 +168,690 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan
await workspace.cleanup();
}
});
async function tapFirstAddedDiffLine(
page: Page,
surface: Locator = page.getByTestId("working-diff-panel"),
) {
const body = surface.getByTestId("diff-file-0-body");
await expect(body).toBeVisible({ timeout: 30000 });
const bounds = await body.boundingBox();
if (!bounds) throw new Error("Expanded diff body has no bounds");
const fontSize = await surface
.getByTestId("git-diff-canvas")
.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
await page.touchscreen.tap(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5);
}
async function openReviewDiff(page: Page, workspace: Awaited<ReturnType<typeof seedChangedAgent>>) {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expect(composerChangesPill(page)).toBeVisible({ timeout: 30000 });
if (await page.getByTestId("workspace-tab-working_diff").count()) {
await selectDiffTab(page);
} else {
await revealComposerChangesInExplorer(page);
await openComposerDiff(page);
}
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
}
async function saveReviewComment(page: Page, body: string) {
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
await page.getByRole("button", { name: "Save review comment", exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
async function cancelReviewComment(page: Page) {
await page.getByRole("button", { name: "Cancel review comment", exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
test.describe("review comments", () => {
test.use({ hasTouch: true });
test("compact review comments open a sheet and preserve saved comments through save, edit, cancel and reload", async ({
page,
}) => {
const workspace = await seedChangedAgent("compact-review-comment-");
try {
await test.step("Open a compact sheet with reachable input and actions", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await tapFirstAddedDiffLine(page);
await expectCompactCommentSheet(page);
});
await test.step("Save, discard an edit, then save an updated comment", async () => {
await saveReviewComment(page, " Please simplify this. ");
await expectSavedReview(page, "Please simplify this.");
await editSavedReview(page, "Please simplify this.");
await fillReviewComment(page, "Discard this edit");
await cancelReviewComment(page);
await expectSavedReview(page, "Please simplify this.");
await editSavedReview(page, "Please simplify this.");
await saveReviewComment(page, "Updated comment");
await expectSavedReview(page, "Updated comment");
});
await test.step("Discard a new comment and retain the saved one after reload", async () => {
await tapFirstAddedDiffLine(page);
await fillReviewComment(page, "Discard new comment");
await cancelReviewComment(page);
await expectReviewAbsent(page, "Discard new comment");
await reloadReview(page);
await expectSavedReview(page, "Updated comment");
});
} finally {
await workspace.cleanup();
}
});
test("wide review comments retain inline editing", async ({ page }) => {
const workspace = await seedChangedAgent("wide-review-comment-");
try {
await test.step("Save and cancel an inline edit on a wide screen", async () => {
await openReviewDiff(page, workspace);
await tapFirstAddedDiffLine(page);
await expectInlineCommentEditor(page);
await saveReviewComment(page, "Wide comment");
await expectSavedReview(page, "Wide comment");
await editSavedReview(page, "Wide comment");
await expectInlineCommentEditor(page);
await cancelReviewComment(page);
await expectSavedReview(page, "Wide comment");
});
await test.step("The existing composer still sends the review", async () => {
await sendReviewThroughComposer(page, workspace.agentId);
await selectDiffTab(page);
await expectReviewAbsent(page, "Wide comment");
});
} finally {
await workspace.cleanup();
}
});
});
async function sendReviewThroughComposer(page: Page, agentId: string) {
await page.getByTestId(`workspace-tab-agent_${agentId}`).click();
await expect(page.getByTestId("composer-review-attachment-pill")).toBeVisible();
await page.getByRole("textbox", { name: "Message agent..." }).fill("Address this review");
await page.getByRole("button", { name: "Send message", exact: true }).click();
await expect(page.getByText("Address this review", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
}
async function addSavedReview(
page: Page,
body: string,
surface: Locator = page.getByTestId("working-diff-panel"),
) {
await tapFirstAddedDiffLine(page, surface);
await saveReviewComment(page, body);
await expectSavedReview(page, body, surface);
}
async function expectSavedReview(
page: Page,
body: string,
surface: Locator = page.getByTestId("working-diff-panel"),
) {
await expect(surface.getByText(body, { exact: true })).toBeVisible();
}
async function editSavedReview(page: Page, expectedBody: string) {
await page
.getByTestId("working-diff-panel")
.getByRole("button", { name: "Edit review comment", exact: true })
.click();
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(expectedBody);
}
async function sendFeedback(page: Page, count: number) {
await page.getByRole("button", { name: `Send feedback (${count})`, exact: true }).click();
}
async function expectFeedbackSent(page: Page, recipient: string) {
await expect(page.getByTestId("app-toast")).toHaveText(`Feedback sent to ${recipient}`);
await expect(page.getByTestId("working-diff-panel")).toBeVisible();
}
test.describe("send review feedback", () => {
test.use({ hasTouch: true });
test("compact feedback sends directly to the only workspace agent and preserves its composer draft", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-feedback-single-");
try {
await test.step("Save a review without changing the chat draft", async () => {
await openReviewDiff(page, workspace);
await selectAgentTab(page, workspace.agentId);
await fillComposerDraft(page, "Keep this thought");
await attachFileFromMenu(page, {
name: "unrelated.txt",
mimeType: "text/plain",
buffer: Buffer.from("Unrelated draft attachment"),
});
await selectDiffTab(page);
await showCompactLayout(page);
await addSavedReview(page, "Please simplify this.");
});
await test.step("Send directly and stay on the diff", async () => {
await sendFeedback(page, 1);
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewAbsent(page, "Please simplify this.");
await captureFeedback(page, "single");
await expect(
page.getByText("Feedback sent to Composer diff stat", { exact: true }),
).toHaveCount(0);
});
await test.step("Return to the untouched composer draft", async () => {
await showWideLayout(page);
await selectAgentTab(page, workspace.agentId);
await expectComposerDraft(page, "Keep this thought");
await expect(page.getByText("unrelated.txt", { exact: true })).toBeVisible();
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
});
} finally {
await workspace.cleanup();
}
});
test("feedback pending ownership follows the draft across Diff and compact Changes", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-shared-pending-");
const gate = await gateNextAgentMessage(page);
try {
await test.step("Send feedback from the Diff tab and hold its real request", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await addSavedReview(page, "One draft across surfaces");
await sendFeedback(page, 1);
await gate.waitForRequest();
});
await test.step("The compact Changes surface observes the same pending operation", async () => {
await revealCompactChanges(page);
await expect(
compactReviewSurface(page).getByRole("button", {
name: "Sending feedback (1)",
exact: true,
}),
).toBeDisabled();
expect(gate.requestCount()).toBe(1);
gate.accept();
await expectFeedbackSent(page, "Composer diff stat");
});
} finally {
await workspace.cleanup();
}
});
test("an unsaved review survives presentation changes and acknowledgement before Save", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-editor-ownership-");
const gate = await gateNextAgentMessage(page);
try {
await test.step("Carry unsaved inline text into the compact sheet and back", async () => {
await openReviewDiff(page, workspace);
await tapFirstAddedDiffLine(page);
await fillReviewComment(page, "Body across presentations");
await showCompactLayout(page);
await expectReviewEditorBody(page, "Body across presentations");
await showWideLayout(page);
await expectReviewEditorBody(page, "Body across presentations");
await saveReviewComment(page, "Body across presentations");
});
await test.step("Keep an open edit saveable after acknowledgement removes its original", async () => {
await sendFeedback(page, 1);
await gate.waitForRequest();
await editSavedReview(page, "Body across presentations");
await fillReviewComment(page, "Keep this unsaved edit");
gate.accept();
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewEditorBody(page, "Keep this unsaved edit");
await showCompactLayout(page);
await expectReviewEditorBody(page, "Keep this unsaved edit");
await saveReviewComment(page, "Keep this unsaved edit");
await expectSavedReview(page, "Keep this unsaved edit");
});
} finally {
await workspace.cleanup();
}
});
test("saved feedback preserves navigation across multiple diff files", async ({ page }) => {
const workspace = await seedMultiFileReview();
try {
await test.step("Save compact feedback and retain both floating actions", async () => {
await openCompactReviewExplorer(page, workspace);
await addSavedReview(page, "Keep file navigation available", compactReviewSurface(page));
await expectReviewNavigationReachable(page);
});
await test.step("Jump to another file without covering the feedback action", async () => {
await jumpToReviewFile(page, "z-last.ts");
await scrollReviewToLastLine(page);
await expectReviewNavigationReachable(page);
await captureFeedback(page, "navigation-compact");
});
await test.step("Wide file navigation and feedback remain reachable", async () => {
await openReviewDiff(page, workspace);
await navigateWideReviewFile(page, "README.md");
await expectFeedbackReachable(page);
await expectSavedReview(page, "Keep file navigation available");
await captureFeedback(page, "navigation-wide");
});
} finally {
await workspace.cleanup();
}
});
test("feedback sends only matching comments and preserves orphaned comments", async ({
page,
}) => {
const workspace = await seedMultiFileReview();
try {
await test.step("A saved comment whose diff disappears has no sendable context", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await addSavedReview(page, "Retain this orphan");
await setReadmeDiff(workspace, false);
await expectUnmatchedFeedback(page);
});
await test.step("Send the matching subset with an accurate count", async () => {
await addSavedReview(page, "Send this matching comment");
await sendFeedback(page, 1);
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewAbsent(page, "Send this matching comment");
await expectUnmatchedFeedback(page);
});
await test.step("Restoring the missing diff reveals the unsent comment", async () => {
await setReadmeDiff(workspace, true);
await expectSavedReview(page, "Retain this orphan");
await expectFeedbackReachable(page);
});
} finally {
await workspace.cleanup();
}
});
test("zero open agent tabs retain feedback with a disabled reason", async ({ page }) => {
const workspace = await seedChangedAgent("review-feedback-zero-");
try {
await test.step("Close the agent tab while keeping the review", async () => {
await openReviewDiff(page, workspace);
await addSavedReview(page, "Keep until an agent is opened");
await closeAgentTab(page, workspace.agentId);
});
await test.step("The disabled action explains why in wide and compact layouts", async () => {
await expectFeedbackUnavailable(page);
await captureFeedback(page, "zero-wide");
await showCompactLayout(page);
await expectFeedbackUnavailable(page);
await expectSavedReview(page, "Keep until an agent is opened");
await captureFeedback(page, "zero-compact");
});
} finally {
await workspace.cleanup();
}
});
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-feedback-multiple-");
const other = await seedChangedAgent("review-feedback-other-");
try {
const second = await createReviewRecipient(workspace, "Second reviewer");
const historical = await createReviewRecipient(workspace, "Closed historical agent");
await test.step("Open two local tabs and a tab in another workspace", async () => {
await openReviewDiff(page, other);
await addSavedReview(page, "Other workspace review");
await openReviewDiff(page, workspace);
await closeAgentTab(page, historical.id);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: second.id });
await selectDiffTab(page);
await addSavedReview(page, "Review for second agent");
});
await test.step("The wide menu includes only local open agents", async () => {
await captureWideFeedbackAppearance(page);
});
await test.step("Escape returns keyboard focus to feedback so the chooser can reopen", async () => {
await openFeedbackWithKeyboard(page);
await dismissRecipientMenu(page);
await expectFeedbackFocused(page);
await reopenFeedbackWithKeyboard(page);
await dismissRecipientMenu(page);
await expectFeedbackFocused(page);
});
await test.step("Choose the second agent in the compact menu and stay on the diff", async () => {
await showCompactLayout(page);
await captureFeedback(page, "cta-compact");
await sendFeedback(page, 1);
await expectRecipientMenu(page);
await captureFeedback(page, "menu-compact");
await chooseFeedbackRecipient(page, "Second reviewer");
await expectFeedbackSent(page, "Second reviewer");
await expectReviewAbsent(page, "Review for second agent");
});
await test.step("The other workspace's saved review remains", async () => {
await openReviewDiff(page, other);
await expectSavedReview(page, "Other workspace review");
});
} finally {
await workspace.cleanup();
await other.cleanup();
}
});
test("failed feedback remains editable and retries through the real provider", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-feedback-retry-", { mockPromptRejections: 1 });
try {
await test.step("A provider rejection retains the saved review", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await addSavedReview(page, "Initial feedback");
await sendFeedback(page, 1);
await expectFeedbackFailure(page);
await expectSavedReview(page, "Initial feedback");
await captureFeedback(page, "failure");
});
await test.step("Edit the retained feedback and retry successfully", async () => {
await editSavedReview(page, "Initial feedback");
await saveReviewComment(page, "Corrected feedback");
await sendFeedback(page, 1);
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewAbsent(page, "Corrected feedback");
});
} finally {
await workspace.cleanup();
}
});
test("pending feedback blocks repeat submission and preserves edits and additions", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-feedback-pending-");
const gate = await gateNextAgentMessage(page);
try {
await test.step("Hold the real request while feedback is pending", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await addSavedReview(page, "Sent version");
await sendFeedback(page, 1);
const request = await gate.waitForRequest();
expect(request.agentId).toBe(workspace.agentId);
expect(request.text).toBe("Please address this code review.");
expect(request.attachments).toMatchObject([
{
type: "review",
cwd: workspace.cwd,
comments: [
{
filePath: "README.md",
lineNumber: 2,
body: "Sent version",
context: { targetLine: { content: "export const one = 1;", newLineNumber: 2 } },
},
],
},
]);
await expectFeedbackPending(page);
});
await test.step("Save an edit and another comment before acknowledgement", async () => {
await editSavedReview(page, "Sent version");
await saveReviewComment(page, "Edited during send");
await addSavedReview(page, "Added during send");
await expectFeedbackPending(page);
expect(gate.requestCount()).toBe(1);
await captureFeedback(page, "pending");
});
await test.step("A real acknowledgement preserves both new versions", async () => {
gate.accept();
await expectFeedbackSent(page, "Composer diff stat");
await expectSavedReview(page, "Edited during send");
await expectSavedReview(page, "Added during send");
await expectReviewAbsent(page, "Sent version");
});
} finally {
await workspace.cleanup();
}
});
});
async function showCompactLayout(page: Page) {
await page.setViewportSize({ width: 390, height: 844 });
}
async function showWideLayout(page: Page) {
await page.setViewportSize({ width: 1400, height: 900 });
}
async function selectAgentTab(page: Page, agentId: string) {
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ position: { x: 12, y: 13 } });
}
async function selectDiffTab(page: Page) {
await page.getByTestId("workspace-tab-working_diff").click({ position: { x: 12, y: 13 } });
}
async function fillReviewComment(page: Page, body: string) {
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
}
async function expectReviewAbsent(page: Page, body: string) {
await expect(page.getByTestId("working-diff-panel").getByText(body, { exact: true })).toHaveCount(
0,
);
}
async function reloadReview(page: Page) {
await page.reload();
await expect(page.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30000 });
}
async function expectInlineCommentEditor(page: Page) {
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
await expect(page.getByTestId("review-comment-sheet")).toHaveCount(0);
}
async function expectCompactCommentSheet(page: Page) {
await expect(page.getByTestId("review-comment-sheet")).toBeVisible();
await expect(page.getByText("README.md · +2", { exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Review comment" })).toBeInViewport({ ratio: 1 });
for (const action of ["Save", "Cancel"])
await expect(
page.getByRole("button", { name: `${action} review comment`, exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Save review comment", exact: true }),
).toBeDisabled();
}
function feedbackButton(page: Page) {
return page.getByRole("button", { name: "Send feedback (1)", exact: true });
}
async function openFeedbackWithKeyboard(page: Page) {
await feedbackButton(page).focus();
await expectFeedbackFocused(page);
await reopenFeedbackWithKeyboard(page);
}
async function reopenFeedbackWithKeyboard(page: Page) {
await page.keyboard.press("Enter");
await expectRecipientMenu(page);
const recipient = page.getByRole("menuitem", { name: "Composer diff stat", exact: true });
await recipient.focus();
await expect(recipient).toBeFocused();
}
async function expectFeedbackFocused(page: Page) {
await expect(feedbackButton(page)).toBeFocused();
}
async function captureWideFeedbackAppearance(page: Page) {
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await expectSavedReview(page, "Review for second agent");
await captureFeedback(page, `cta-wide-${colorScheme}-closed`);
await sendFeedback(page, 1);
await expectRecipientMenu(page);
await captureFeedback(page, `cta-wide-${colorScheme}-open`);
await dismissRecipientMenu(page);
}
await page.emulateMedia({ colorScheme: "light" });
}
async function captureFeedback(page: Page, name: string) {
await page.screenshot({ path: `/tmp/phase2-feedback-${name}.png` });
}
async function closeAgentTab(page: Page, agentId: string) {
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agentId}-close`).click();
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
}
async function expectFeedbackUnavailable(page: Page) {
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeDisabled();
await expect(
page.getByText("Open an agent tab in this workspace to send feedback.", { exact: true }),
).toBeVisible();
}
async function createReviewRecipient(
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
title: string,
) {
return workspace.client.createAgent({
provider: "mock",
cwd: workspace.cwd,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "e2e-fast-stream",
});
}
async function expectRecipientMenu(page: Page) {
for (const name of ["Composer diff stat", "Second reviewer"]) {
await expect(page.getByRole("menuitem", { name, exact: true })).toBeInViewport({ ratio: 1 });
}
await expect(page.getByRole("menuitem")).toHaveCount(2);
}
async function dismissRecipientMenu(page: Page) {
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem")).toHaveCount(0);
}
async function chooseFeedbackRecipient(page: Page, name: string) {
await page.getByRole("menuitem", { name, exact: true }).click();
}
async function expectFeedbackFailure(page: Page) {
await expect(
page.getByText("Requested mock prompt rejection", { exact: true }).first(),
).toBeVisible();
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeEnabled();
}
async function expectFeedbackPending(page: Page, count = 1) {
await expect(
page.getByRole("button", { name: `Sending feedback (${count})`, exact: true }),
).toBeDisabled();
}
async function seedMultiFileReview() {
const workspace = await seedChangedAgent("review-feedback-navigation-", undefined, [
{ path: "z-last.ts", content: "export const final = 0;\n" },
]);
await writeFile(
path.join(workspace.cwd, "z-last.ts"),
Array.from({ length: 120 }, (_, index) => `export const value${index} = ${index};`).join("\n") +
"\n",
);
await workspace.client.checkoutRefresh(workspace.cwd);
return workspace;
}
async function expectFeedbackReachable(page: Page) {
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeInViewport(
{ ratio: 1 },
);
}
async function expectReviewNavigationReachable(page: Page) {
await expectFeedbackReachable(page);
const jump = page.getByRole("button", { name: "Jump to file", exact: true });
await expect(jump).toBeInViewport({ ratio: 1 });
const navigationBounds = await jump.boundingBox();
const feedbackBounds = await page
.getByRole("button", { name: "Send feedback (1)", exact: true })
.boundingBox();
if (!navigationBounds || !feedbackBounds) throw new Error("Review actions have no bounds");
expect(feedbackBounds.y + feedbackBounds.height).toBeLessThanOrEqual(navigationBounds.y);
}
async function jumpToReviewFile(page: Page, name: string) {
await page.getByRole("button", { name: "Jump to file", exact: true }).click();
const sheet = page.getByTestId("changes-jump-to-file-sheet");
await page
.getByTestId("changes-file-tree")
.filter({ visible: true })
.getByText(name, { exact: true })
.click();
await expect(sheet).toHaveCount(0);
await expect(reviewFileHeader(compactReviewSurface(page), name)).toBeInViewport();
}
async function navigateWideReviewFile(page: Page, name: string) {
const explorer = await ensureExplorerSidebar(page);
await explorer.getByTestId("changes-tree-panel").getByText(name, { exact: true }).click();
await expect(reviewFileHeader(page.getByTestId("working-diff-panel"), name)).toBeInViewport();
}
async function setReadmeDiff(
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
changed: boolean,
) {
await writeFile(
path.join(workspace.cwd, "README.md"),
changed ? "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n" : "# Temp Repo\n",
);
await workspace.client.checkoutRefresh(workspace.cwd);
}
async function expectUnmatchedFeedback(page: Page) {
await expect(page.getByRole("button", { name: "Send feedback (0)", exact: true })).toBeDisabled();
await expect(
page.getByText("Saved comments no longer match this diff.", { exact: true }),
).toBeVisible();
}
function compactReviewSurface(page: Page) {
return page.getByTestId("explorer-content-area").filter({ visible: true });
}
async function openCompactReviewExplorer(
page: Page,
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
) {
await showWideLayout(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await selectAgentTab(page, workspace.agentId);
await showCompactLayout(page);
await composerChangesPill(page).click();
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
}
function reviewFileHeader(surface: Locator, name: string) {
return surface.locator(`[data-diff-header-path="${name}"]`).getByTestId(/^diff-file-\d+$/);
}
async function scrollReviewToLastLine(page: Page) {
const surface = compactReviewSurface(page);
const bounds = await surface.boundingBox();
if (!bounds) throw new Error("Compact review has no bounds");
await page.mouse.move(bounds.x + 120, bounds.y + 200);
await page.mouse.wheel(0, 10000);
await expect
.poll(async () => {
const body = await surface.getByTestId("diff-file-1-body").boundingBox();
const feedback = await page
.getByRole("button", { name: "Send feedback (1)", exact: true })
.boundingBox();
if (!body || !feedback) return false;
return body.y + body.height <= feedback.y;
})
.toBe(true);
}
async function revealCompactChanges(page: Page) {
await page.getByTestId("workspace-explorer-toggle").first().click();
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
}
async function expectReviewEditorBody(page: Page, body: string) {
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
body,
);
}
@@ -0,0 +1,104 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
selectComposerDraftRange,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// The popover has no semantic role; its rows are React Native Pressables.
function fileSuggestions(page: Page) {
return page.getByTestId("composer-autocomplete-popover");
}
async function selectReadmeMention(page: Page): Promise<void> {
await fillComposerDraft(page, "@README");
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0);
await composerLocator(page).press("Enter");
await expectComposerDraft(page, '"README.md"');
}
async function sendEmailIntact(page: Page, email: string): Promise<void> {
await fillComposerDraft(page, email);
await expect(fileSuggestions(page)).not.toBeVisible();
await composerLocator(page).press("Enter");
await expectComposerDraft(page, "");
await expect(page.getByText(email, { exact: true })).toBeVisible();
}
for (const email of [
"user <12345+user@noreply.example.com>",
"alice@example.com",
"noreply+@example.com",
]) {
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-email-",
title: "Email mention regression",
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("complete a workspace file mention with Enter", async () => {
await selectReadmeMention(page);
});
await test.step("send an email without offering or inserting workspace files", async () => {
await sendEmailIntact(page, email);
});
} finally {
await agent.cleanup();
}
});
}
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-mention-",
title: "Fullscreen mention keys",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const draft = "First line\nSecond line\n@README";
await fillComposerDraft(page, draft);
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
await enterComposerFullscreen(page);
await expect(fileSuggestions(page)).not.toBeVisible();
await selectComposerDraftRange(page, draft.length, draft.length);
await composerLocator(page).press("ArrowUp");
await expect
.poll(() =>
composerLocator(page).evaluate((element) => {
const input = element as HTMLTextAreaElement;
return input.selectionStart === input.selectionEnd && input.selectionStart < 23;
}),
)
.toBe(true);
await selectComposerDraftRange(page, draft.length, draft.length);
await composerLocator(page).press("Enter");
await expectComposerDraft(page, `${draft}\n`);
await exitComposerFullscreen(page);
await selectReadmeMention(page);
const commandDraft = "/cl\nSecond line\nThird line";
await fillComposerDraft(page, commandDraft);
await selectComposerDraftRange(page, 2, 2);
await composerLocator(page).press("ArrowRight");
await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible();
await enterComposerFullscreen(page);
await expect(fileSuggestions(page)).not.toBeVisible();
await composerLocator(page).press("Enter");
await expectComposerDraft(page, "/cl\n\nSecond line\nThird line");
} finally {
await agent.cleanup();
}
});
@@ -1,13 +1,127 @@
import { expect, test, type Page } from "../support/fixtures";
import {
blurComposerInput,
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
expectComposerDraft,
expectComposerFocused,
expectComposerFullscreenControls,
expectComposerNotFocused,
expectComposerVisible,
expectWorkspaceHeaderVisible,
resizeComposerViewport,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
import {
closeMobileAgentSidebar,
closeMobileExplorer,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
expectMobileExplorerHidden,
expectMobileExplorerVisible,
swipeOpenMobileAgentSidebar,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
import {
expectTimelinePromptPositionPreserved,
rememberTimelinePromptPosition,
seedLongMockAgentTimeline,
waitForTimelineGeometryToSettle,
} from "../support/helpers/timeline-pagination";
test("clicking chat text or background focuses the feed for keyboard paging", async ({
page,
}, testInfo) => {
const agent = await seedLongMockAgentTimeline({ turns: 8 });
try {
await openAgentRoute(page, agent);
const composer = composerLocator(page);
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
await composer.fill("Keep this draft");
await feed.getByText(agent.newestPrompt, { exact: true }).click();
await expect(feed).toBeFocused();
const beforePaging = await readScrollMetrics(page);
expect(beforePaging.offsetY).toBeGreaterThan(beforePaging.viewportHeight);
await page.keyboard.press("PageUp");
await expect
.poll(async () => (await readScrollMetrics(page)).offsetY)
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
await page.keyboard.press("PageDown");
await expect
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
.toBeLessThanOrEqual(2);
await composer.click();
await expectComposerFocused(page);
await feed.click({ position: { x: 2, y: 40 } });
await expect(feed).toBeFocused();
await page.keyboard.press("PageUp");
await expect
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
.toBeGreaterThan(beforePaging.viewportHeight / 2);
await waitForTimelineGeometryToSettle(page);
const readingPrompt = agent.prompts[4];
await expect(feed.getByText(readingPrompt, { exact: true })).toBeInViewport();
const readingPosition = await rememberTimelinePromptPosition(page, readingPrompt);
await agent.client.sendAgentMessage(agent.agentId, "emit 2 coalesced agent stream updates");
await agent.client.waitForFinish(agent.agentId, 15_000);
// The daemon finishing does not mean its response has reached the browser.
await expect(feed.getByText(/stress-update-1/)).toHaveCount(1);
await waitForTimelineGeometryToSettle(page);
await expectTimelinePromptPositionPreserved(page, readingPosition);
await expectComposerDraft(page, "Keep this draft");
} finally {
await agent.cleanup();
}
});
test("focusing the feed preserves assistant text selection and code copy controls", async ({
page,
context,
}) => {
const sentence = "Select this assistant sentence.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-focus-selection-",
title: "Chat focus selection",
initialPrompt: "Render text and code.",
featureValues: {
mockAssistantResponse: `${sentence}\n\n\`\`\`bash\necho example\n\`\`\``,
},
});
try {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await composerLocator(page).fill("Keep this draft");
const feed = page.getByTestId("agent-chat-scroll").filter({ visible: true });
await feed.getByText(sentence, { exact: true }).click({ clickCount: 3 });
await expect(feed).toBeFocused();
expect(await page.evaluate(() => window.getSelection()?.toString().trim())).toBe(sentence);
await page.keyboard.press("ControlOrMeta+c");
expect((await page.evaluate(() => navigator.clipboard.readText())).trim()).toBe(sentence);
const code = feed.locator('[data-paseo-markdown-language="bash"]');
await code.hover();
await code.locator("[data-paseo-markdown-ignore]").click();
await expect(feed).not.toBeFocused();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("echo example");
await expectComposerDraft(page, "Keep this draft");
} finally {
await agent.cleanup();
}
});
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
@@ -56,6 +170,59 @@ test.describe("touch screen wide enough for the desktop layout", () => {
// composer there raises the on-screen keyboard over the conversation.
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-touch-remount-",
title: "Touch remount",
});
try {
await openAgentRoute(page, agent);
await composerLocator(page).fill("Keep this draft through resize");
await expectComposerFocused(page);
await resizeComposerViewport(page, "portrait");
await expectComposerDraft(page, "Keep this draft through resize");
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
} finally {
await agent.cleanup();
}
});
test("returning to an inactive inline composer does not restore focus on touch", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "First touch chat",
});
const second = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Second touch chat",
});
await openWorkspaceWithAgents(page, [first, second]);
await composerLocator(page).fill("Keep this touch draft");
await expectComposerFocused(page);
// An external keyboard changes tabs without a pointer click blurring the
// editor first. The device still has a touch-primary focus policy.
await switchComposerTabWithKeyboard(page, "previous");
await expectComposerDraft(page, "");
await switchComposerTabWithKeyboard(page, "next");
await expectComposerDraft(page, "Keep this touch draft");
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, " after return");
await expectComposerDraft(page, "Keep this touch draft after return");
} finally {
await workspace.cleanup();
}
});
test("opening an agent leaves the composer unfocused", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-touch-",
@@ -76,3 +243,76 @@ test.describe("touch screen wide enough for the desktop layout", () => {
}
});
});
const COMPACT_PANELS = [
{
name: "agent list",
open: swipeOpenMobileAgentSidebar,
expectOpen: expectMobileAgentSidebarVisible,
close: closeMobileAgentSidebar,
expectClosed: expectMobileAgentSidebarHidden,
},
{
name: "Explorer",
open: swipeOpenMobileExplorer,
expectOpen: expectMobileExplorerVisible,
close: closeMobileExplorer,
expectClosed: expectMobileExplorerHidden,
},
] as const;
for (const panel of COMPACT_PANELS) {
test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-panel-",
title: `Composer focus under ${panel.name}`,
});
const draft = "Line one\nLine two\nLine three";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("an untouched editor stays untouched", async () => {
await expectComposerNotFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
});
await test.step("an inline editor blurred under the panel stays blurred", async () => {
await composerLocator(page).fill(draft);
await composerLocator(page).click();
await expectComposerFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await blurComposerInput(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
await expectComposerDraft(page, draft);
});
await test.step("a fullscreen editor blurred under the panel stays blurred", async () => {
await composerLocator(page).click();
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectComposerFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await blurComposerInput(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
await expectComposerFullscreenControls(page);
await expectComposerDraft(page, draft);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
});
} finally {
await agent.cleanup();
}
});
}
@@ -1,3 +1,5 @@
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expect, test, type Page } from "../support/fixtures";
import {
expectNearBottom,
@@ -5,8 +7,38 @@ import {
waitForScrollableChat,
} from "../support/helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
attachComposerTestImage,
pasteComposerTestImage,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
selectComposerDraftRange,
expectComposerDraftSelection,
expectComposerFullscreenControls,
sendComposerWithButton,
queueComposerWithButton,
configureComposerQueueSend,
activateRetainedComposerTab,
switchComposerTabWithKeyboard,
expectWorkspaceHeaderHidden,
expectWorkspaceHeaderVisible,
recordVisibleComposerGeometry,
expectNoCollapsedVisibleComposer,
expectAttachmentPill,
expectQueuedMessageButton,
startRunningMockAgent,
composerLocator,
expectComposerVisible,
expectComposerFullscreenFitsVisibleViewport,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
swipeOpenMobileAgentSidebar,
} from "../support/helpers/sidebar";
async function composerHeight(page: Page): Promise<number> {
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
@@ -133,3 +165,471 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
await agent.cleanup();
}
});
/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
async function pausePageClock(page: Page): Promise<void> {
const now = Date.now();
await page.clock.install({ time: now });
// The installed clock advances between protocol calls; pause at a future time.
await page.clock.pauseAt(now + 60_000);
}
/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
async function expectFullscreenErrorAboveSend(page: Page): Promise<void> {
const frame = page.getByTestId("composer-fullscreen");
const alertBox = (await frame.getByRole("alert").boundingBox())!;
const sendBox = (await page
.getByRole("button", { name: "Send message", exact: true })
.boundingBox())!;
const editorBox = (await composerLocator(page).boundingBox())!;
expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height);
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y);
}
async function writeCompactDraft(page: Page, text: string): Promise<void> {
await composerLocator(page).fill(text);
await expect(composerLocator(page)).toHaveValue(text);
}
test("compact long drafts stop growing before the header", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-cap-",
title: "Composer cap",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "A long draft line\n".repeat(40));
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
} finally {
await agent.cleanup();
}
});
test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-",
title: "Fullscreen composer",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("the affordance appears after two rendered lines, including soft wraps", async () => {
await attachComposerTestImage(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await writeCompactDraft(page, "First line\nSecond line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await writeCompactDraft(
page,
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
4,
),
);
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
});
await test.step("fullscreen covers the header and keeps the live draft and selection", async () => {
await selectComposerDraftRange(page, 4, 17);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectWorkspaceHeaderHidden(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
await selectComposerDraftRange(page, 3, 12);
});
await test.step("exit restores the inline composer with the fullscreen edits", async () => {
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
await expect(composerLocator(page)).toHaveValue(
"Edited fullscreen draft\nSecond line\nThird line",
);
await expectComposerDraftSelection(page, 3, 12);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(composerLocator(page)).toBeFocused();
});
await test.step("sending inline after a fullscreen edit clears the visible editor", async () => {
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Typed after the fullscreen cycle");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
});
await test.step("sending inside fullscreen returns to chat and clears the editor", async () => {
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Short draft");
await expectComposerFullscreenControls(page);
await selectComposerDraftRange(page, 0, 5);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 0, 5);
await expect(composerLocator(page)).toHaveValue("Short draft");
await expect(composerLocator(page)).toBeFocused();
await resizeComposerViewport(page, "portrait");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectWorkspaceHeaderVisible(page);
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
});
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Typed inside fullscreen");
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen");
await writeCompactDraft(page, "Typed inside fullscreen, finished inline");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await writeCompactDraft(page, "One more inline message");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
});
} finally {
await agent.cleanup();
}
});
test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-remount-caret-",
title: "Remount caret",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "Short draft");
await selectComposerDraftRange(page, 5, 5);
// The draft owner publishes web edits after the next paint. A rotation inside that
// window remounts the editor before the edit has reached the draft store; the paused
// clock holds the page there instead of racing the frame.
await pausePageClock(page);
await composerLocator(page).press("X");
await resizeComposerViewport(page, "landscape");
await page.clock.resume();
await expect(composerLocator(page)).toHaveValue("ShortX draft");
await expectComposerDraftSelection(page, 6, 6);
await expect(composerLocator(page)).toBeFocused();
await composerLocator(page).press("Y");
await expect(composerLocator(page)).toHaveValue("ShortXY draft");
} finally {
await agent.cleanup();
}
});
test("fullscreen submit rejection retains the editable draft and supports retry", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-retry-",
title: "Fullscreen retry",
featureValues: { mockPromptRejections: 1 },
});
const draft = "First line\nSecond line\nThird line";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await sendComposerWithButton(page);
await expectWorkspaceHeaderVisible(page);
await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection");
await enterComposerFullscreen(page);
await expectFullscreenErrorAboveSend(page);
await expect(composerLocator(page)).toHaveValue(draft);
await expect(composerLocator(page)).toBeEditable();
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectWorkspaceHeaderVisible(page);
} finally {
await agent.cleanup();
}
});
async function blurComposer(page: Page): Promise<void> {
await composerLocator(page).blur();
await expect(composerLocator(page)).not.toBeFocused();
}
/** Mid-band at the given height and near the editor's bottom edge. */
async function reservedBandPoints(
page: Page,
bandX: number,
topY: number,
): Promise<Array<{ x: number; y: number }>> {
const editor = (await composerLocator(page).boundingBox())!;
return [
{ x: bandX, y: topY },
{ x: bandX, y: editor.y + editor.height - 6 },
];
}
test("the reserved band beside the inline editor focuses it", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-band-focus-",
title: "Composer band focus",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true });
const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true });
const longDraft = "Band first line\nBand second line\nBand third line";
await writeCompactDraft(page, longDraft);
await expect(fullscreenButton).toBeVisible();
const button = (await fullscreenButton.boundingBox())!;
const bandX = button.x + button.width / 2;
await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => {
for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) {
await blurComposer(page);
await page.mouse.click(point.x, point.y);
await expect(composerLocator(page)).toBeFocused();
await expect(composerLocator(page)).toHaveValue(longDraft);
await expect(exitButton).toHaveCount(0);
}
});
await test.step("the button itself still toggles fullscreen", async () => {
await fullscreenButton.click();
await expectComposerFullscreenControls(page);
await exitComposerFullscreen(page);
await expect(exitButton).toHaveCount(0);
await expect(composerLocator(page)).toHaveValue(longDraft);
});
await test.step("with no fullscreen button, the whole band still focuses the editor", async () => {
await writeCompactDraft(page, "Short");
await expect(fullscreenButton).toHaveCount(0);
const editor = (await composerLocator(page).boundingBox())!;
for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) {
await blurComposer(page);
await page.mouse.click(point.x, point.y);
await expect(composerLocator(page)).toBeFocused();
await expect(composerLocator(page)).toHaveValue("Short");
await expect(exitButton).toHaveCount(0);
}
});
} finally {
await agent.cleanup();
}
});
test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
page,
}) => {
await configureComposerQueueSend(page);
await resizeComposerViewport(page, "portrait");
const agent = await startRunningMockAgent(page, {
prefix: "composer-fullscreen-queue-",
model: "one-minute-stream",
prompt: "Stay running for fullscreen queue test.",
});
try {
await writeCompactDraft(page, "Queued first line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await queueComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectWorkspaceHeaderVisible(page);
await expectQueuedMessageButton(page);
} finally {
await agent.cleanup();
}
});
test("image paste stays attached to the active editor across fullscreen entry and exit", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-paste-remount-",
title: "Composer paste",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
expect(await pasteComposerTestImage(page)).toBe(true);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1);
await enterComposerFullscreen(page);
expect(await pasteComposerTestImage(page)).toBe(true);
await exitComposerFullscreen(page);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2);
expect(await pasteComposerTestImage(page)).toBe(true);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3);
} finally {
await agent.cleanup();
}
});
test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" });
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "First retained chat",
});
const second = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Second retained chat",
});
await openWorkspaceWithAgents(page, [first, second]);
await resizeComposerViewport(page, "portrait");
const draft = "Live first line\nLive second line\nLive third line";
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await expectWorkspaceHeaderHidden(page);
await selectComposerDraftRange(page, 4, 17);
// Fullscreen covers the compact header, so a phone leaves the tab by exiting first;
// a desktop-width window still has the tab shortcut.
await resizeComposerViewport(page, "landscape");
await switchComposerTabWithKeyboard(page, "previous");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
// The exit action belongs to the active pane: Escape here leaves the hidden tab alone.
await composerLocator(page).press("Escape");
await switchComposerTabWithKeyboard(page, "next");
await expectComposerFullscreenControls(page);
await switchComposerTabWithKeyboard(page, "previous");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await resizeComposerViewport(page, "portrait");
await expectWorkspaceHeaderVisible(page);
await writeCompactDraft(page, "Other tab draft");
await shrinkVisibleViewport(page);
await activateRetainedComposerTab(page, second.title);
await expectComposerFullscreenControls(page);
await expectWorkspaceHeaderHidden(page);
await expect(composerLocator(page)).toHaveValue(draft);
await expectComposerFullscreenFitsVisibleViewport(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(composerLocator(page)).toBeFocused();
expect(await pasteComposerTestImage(page)).toBe(true);
await exitComposerFullscreen(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
} finally {
await workspace.cleanup();
}
});
test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-sidebar-",
title: "Fullscreen under sidebar",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
const draft = "Line one\nLine two\nLine three";
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await expectWorkspaceHeaderHidden(page);
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
await swipeOpenMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
// The list covers the pane, so the editor's exit action is not armed.
await page.keyboard.press("Escape");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expectComposerFullscreenControls(page);
await expect(composerLocator(page)).toHaveValue(draft);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
} finally {
await agent.cleanup();
}
});
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" });
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "First capacity chat",
});
const second = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Second capacity chat",
});
await openWorkspaceWithAgents(page, [first, second]);
await resizeComposerViewport(page, "portrait");
const draft = "Retained long line\n".repeat(40);
await writeCompactDraft(page, draft);
await recordVisibleComposerGeometry(page);
await activateRetainedComposerTab(page, first.title);
await writeCompactDraft(page, "Other visible draft");
await activateRetainedComposerTab(page, second.title);
await expect(composerLocator(page)).toHaveValue(draft);
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible();
await expectNoCollapsedVisibleComposer(page);
} finally {
await workspace.cleanup();
}
});
async function shrinkVisibleViewport(page: Page): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 });
await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422);
await cdp.detach();
}
async function panVisibleViewport(page: Page): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: 4, y: 300 }],
});
for (let y = 290; y >= 100; y -= 10) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: 4, y }],
});
}
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0);
await cdp.detach();
}
test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-visible-viewport-",
title: "Visible viewport composer",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await shrinkVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await panVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
} finally {
await agent.cleanup();
}
});
@@ -128,3 +128,46 @@ for (const scenario of [
await creation.expectAgentCount(1);
});
}
for (const entry of [
{
name: "agent draft",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openAgentDraft(),
button: "Send message",
settled: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
},
{
name: "new workspace",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openWorkspaceForm("local"),
button: "Create",
settled: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.expectOneCreatedWorkspace(),
},
]) {
test(`${entry.name} becomes an agent tab when the first prompt is rejected`, async ({
creation,
promptRejection,
page,
}, testInfo) => {
void promptRejection;
const prompt =
"Continue the attached conversation.\n" + "Earlier conversation context.\n".repeat(100);
await entry.open(creation);
await creation.submitPrompt(prompt, entry.button);
await entry.settled(creation);
await creation.expectCreatedAgentError();
await creation.expectPromptBeforeError(prompt);
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
await creation.reloadAgent();
await creation.expectPromptBeforeError(prompt);
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
await creation.expectPromptVisible(
"emit 1 coalesced agent stream updates for a corrected prompt.",
);
await creation.expectAssistantReply();
await creation.expectAgentCount(1);
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
});
}
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
);
expect(workspaces).toHaveLength(1);
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
async function holdLegacyAgentCreation(page: Page, port: number) {
const readers = await loadSessionMessageReaders();
const arrived = Promise.withResolvers<void>();
let created = Promise.withResolvers<void>();
let createRequestId: string | undefined;
let release = () => {};
let holding = true;
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (holding && readers.client(frame)?.type === "create_agent_request") {
const message = readers.client(frame);
if (message?.type === "create_agent_request") createRequestId = message.requestId;
if (holding && message?.type === "create_agent_request") {
holding = false;
release = () => server.send(frame);
arrived.resolve();
} else server.send(frame);
});
server.onMessage((frame) => browser.send(frame));
server.onMessage((frame) => {
const message = readers.server(frame);
if (
message?.type === "status" &&
message.payload.status === "agent_created" &&
message.payload.requestId === createRequestId
) {
created.resolve();
}
browser.send(frame);
});
});
return {
waitForRequest: () => arrived.promise,
async waitForCreated() {
// Agent-list membership includes initialization, before the initial prompt starts.
await created.promise;
created = Promise.withResolvers<void>();
},
release() {
const send = release;
release = () => {};
@@ -0,0 +1,29 @@
import { test } from "../support/fixtures";
import {
addCustomModel,
expectCustomModelRetained,
openCustomModels,
removeCustomModel,
} from "../support/helpers/custom-models";
const longModelId = "custom-model-with-a-long-name-".repeat(6);
const shortModelId = "custom-short";
for (const width of [1200, 390]) {
test(`removes a long custom model at ${width}px without removing another model`, async ({
page,
}) => {
await page.setViewportSize({ width: 1200, height: 1000 });
await openCustomModels(page);
await addCustomModel(page, shortModelId);
await addCustomModel(page, longModelId);
await test.step("remove the long model at the target width", async () => {
await page.setViewportSize({ width, height: 1000 });
await removeCustomModel(page, longModelId);
await expectCustomModelRetained(page, shortModelId);
});
await removeCustomModel(page, shortModelId);
});
}
@@ -0,0 +1,118 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
interface DragMeasurement {
observer: MutationObserver;
stylesheetMutations: number;
}
declare global {
interface Window {
__PASEO_EXPLORER_DRAG_MEASUREMENT__?: DragMeasurement;
}
}
async function waitForResizePaint(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
async function beginResizeMeasurement(page: Page): Promise<void> {
await page.evaluate(() => {
const stylesheet = document.getElementById("unistyles-web");
if (!stylesheet) throw new Error("Shared stylesheet is missing");
globalThis.__PASEO_RESET_RENDER_PROFILE__!();
const measurement: DragMeasurement = {
stylesheetMutations: 0,
observer: new MutationObserver((records) => {
measurement.stylesheetMutations += records.length;
}),
};
measurement.observer.observe(stylesheet, {
childList: true,
subtree: true,
characterData: true,
});
window.__PASEO_EXPLORER_DRAG_MEASUREMENT__ = measurement;
});
}
async function previewExplorerResize(page: Page, distance: number) {
const handle = page
.getByTestId("workspace-explorer-sidebar-resize-handle")
.getByRole("separator");
const explorer = page.getByTestId("workspace-explorer-sidebar");
const bounds = await handle.boundingBox();
if (!bounds) throw new Error("Explorer resize handle is missing");
const before = (await explorer.boundingBox())!.width;
const x = bounds.x + bounds.width / 2;
const y = bounds.y + 100;
await page.mouse.move(x, y);
await page.mouse.down();
await waitForResizePaint(page);
await beginResizeMeasurement(page);
for (let move = 1; move <= 20; move++) {
await page.mouse.move(x + (distance * move) / 20, y);
await waitForResizePaint(page);
}
const during = (await explorer.boundingBox())!.width;
const measurement = await page.evaluate(() => {
const state = window.__PASEO_EXPLORER_DRAG_MEASUREMENT__!;
state.observer.disconnect();
return {
stylesheetMutations: state.stylesheetMutations,
paneRenders: Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {})
.filter(([id]) => id.startsWith("SplitPaneView:"))
.reduce((sum, [, reasons]) => sum + (reasons.render ?? 0), 0),
};
});
await page.mouse.up();
await waitForResizePaint(page);
const after = (await explorer.boundingBox())!.width;
expect(during).toBeCloseTo(before - distance, 0);
expect(after).toBeCloseTo(during, 0);
return { ...measurement, width: after };
}
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
await page.reload();
const explorer = page.getByTestId("workspace-explorer-sidebar");
await expect(explorer).toBeVisible();
await expect.poll(async () => (await explorer.boundingBox())?.width).toBeCloseTo(width, 0);
}
test("Explorer drag previews without rerendering panes or rewriting the shared stylesheet", async ({
page,
withWorkspace,
}) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await page.addInitScript(() => {
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
});
const workspace = await withWorkspace();
await workspace.navigateTo();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page.getByTestId("workspace-explorer-toggle").first().click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
// Verify that direct pane renders are being counted before resetting them.
expect(
await page.evaluate(() =>
Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).some((id) =>
id.startsWith("SplitPaneView:"),
),
),
).toBe(true);
const grown = await previewExplorerResize(page, -100);
const shrunk = await previewExplorerResize(page, 50);
expect(grown.stylesheetMutations).toBe(0);
expect(grown.paneRenders).toBe(0);
expect(shrunk.stylesheetMutations).toBe(0);
expect(shrunk.paneRenders).toBe(0);
await expectExplorerWidthSurvivesReload(page, shrunk.width);
});
+154 -1
View File
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import {
expandFolder,
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
const image = page.getByRole("button", { name, exact: true });
await expect(image).toBeVisible();
await expect
.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth))
.toBeGreaterThan(0);
}
function watchRequestsTo(page: Page, origin: string): string[] {
const requests: string[] = [];
page.on("request", (request) => {
@@ -96,6 +105,95 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "screenshot-markdown-link-",
title: "Screenshot file links",
initialPrompt: "Show screenshots",
featureValues: {
mockAssistantResponse:
"[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
},
});
try {
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
await openAgentRoute(page, session);
await test.step("Open the linked screenshot", async () => {
await openAssistantMarkdownLink(page, "Capture");
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
});
await test.step("Explain why the missing screenshot cannot open", async () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "Missing capture");
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
await expect(page.getByText(/ENOENT/)).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
});
} finally {
await session.cleanup();
}
});
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
title: "Non-ASCII file link",
initialPrompt: "Show the report link",
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
});
try {
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
await openAgentRoute(page, session);
await openAssistantMarkdownLink(page, "报告");
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText("Report opened", { exact: true }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
} finally {
await session.cleanup();
}
});
for (const order of ["raw-first", "href-first"] as const) {
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "percent-link-cache-",
title: "Distinct file links",
initialPrompt: "Show both filenames",
featureValues: {
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
},
});
try {
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
await openAgentRoute(page, session);
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
for (const link of links) {
await returnToAssistant(page, session.agentId);
if (link === "raw") {
await openAssistantInlinePath(page, "literal%20.md");
await expectFileTabOpen(page, "literal%20.md");
await expectPreviewText(page, "Literal filename");
} else {
await openAssistantMarkdownLink(page, "Decoded");
await expectFileTabOpen(page, "literal .md");
await expectPreviewText(page, "Decoded filename");
}
}
} finally {
await session.cleanup();
}
});
}
test("jumps to each relative reference to the same file", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "relative-reference-qa-",
@@ -154,7 +252,7 @@ test.describe("CodeMirror workspace file editing", () => {
const fileLink = page.getByRole("link", { name: "Open target" }).first();
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.hover();
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
await expect(fileLink).toHaveAttribute("title", "src/target.ts:42-45");
} finally {
await workspace.cleanup();
}
@@ -626,6 +724,40 @@ test.describe("CodeMirror workspace file editing", () => {
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
});
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
await writeFile(
path.join(workspace.repoPath, "docs", "guide.md"),
[
"# Guide",
"",
"![sibling](./sibling.png)",
"",
"![hero](../data/cache/hero.png?v=2#preview)",
"",
].join("\n"),
"utf8",
);
await workspace.navigateTo();
await openFileExplorer(page);
await expandFolder(page, "docs");
await openFileFromExplorer(page, "guide.md");
await expectFileTabOpen(page, "docs/guide.md");
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
await expectMarkdownImageLoaded(page, "sibling");
await expectMarkdownImageLoaded(page, "hero");
await visibleFilePane.screenshot({
path: test.info().outputPath("markdown-relative-images.png"),
});
});
test("previews and refreshes an HTML plan while preserving source access", async ({
page,
withWorkspace,
@@ -757,3 +889,24 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
});
});
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
await page.getByRole("link", { name, exact: true }).first().click();
}
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
}
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
await page.getByText(token, { exact: true }).click();
}
async function expectPreviewText(page: Page, text: string): Promise<void> {
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText(text, { exact: true }),
).toBeVisible();
}
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
@@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
});
test("downloads a file over direct TCP with its original filename and bytes", async ({
page,
}, testInfo) => {
const fileName = "rapport données.bin";
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const downloaded = await downloadFileFromExplorer(page, fileName);
const url = new URL(downloaded.url());
expect(url.protocol).toBe("http:");
expect(url.port).toBe(getE2EDaemonPort());
expect(url.pathname).toBe("/api/files/download");
expect(url.searchParams.get("token")).toBeTruthy();
expect(downloaded.suggestedFilename()).toBe(fileName);
const savedFile = testInfo.outputPath(fileName);
await downloaded.saveAs(savedFile);
expect(await downloaded.failure()).toBeNull();
expect(await readFile(savedFile)).toEqual(source);
});
@@ -0,0 +1,110 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectFileTabOpen,
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs";
async function openMarkdownPreview(page: Page) {
await openFileExplorer(page);
await openFileFromExplorer(page, "README.md");
await expectFileTabOpen(page, "README.md");
await page
.getByTestId("file-panel-bar")
.getByRole("button", { name: "Preview", exact: true })
.click();
await ensureWorkspaceAgentPaneVisible(page);
}
async function followPreviewLink(page: Page, name: string) {
await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByRole("link", { name, exact: true })
.click();
}
test("Markdown preview folder links reveal Files and file links still open a file", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "preview-folder-links-" });
await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true });
await writeFile(
path.join(workspace.repoPath, "docs/guides/guide.md"),
"# Folder link destination\n",
);
await writeFile(
path.join(workspace.repoPath, "README.md"),
"[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n",
);
await workspace.navigateTo();
await openMarkdownPreview(page);
await followPreviewLink(page, "Guide");
await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible();
await openMarkdownPreview(page);
await followPreviewLink(page, "Dot");
await expectFolderContents(page);
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
await openMarkdownPreview(page);
await followPreviewLink(page, "Bare");
await expectFolderContents(page);
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") });
});
async function openReportedFolderLink(page: Page, name: string) {
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
await link.click();
}
async function expectFolderContents(page: Page) {
await expect(page.getByText("Requested path is not a file", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible();
await expect(
page.getByTestId("file-explorer-tree-scroll").getByText("guide.md", { exact: true }),
).toBeVisible();
}
for (const folder of [
"./docs/guides",
"./docs/guides/",
"./docs/reports.v1",
"./.github/workflows",
"docs/guides",
"docs/guides/",
"docs/指南",
]) {
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "folder-link-repro-",
title: "Folder link reproduction",
initialPrompt: "Show the folder link",
featureValues: { mockAssistantResponse: `[Open folder](${folder})` },
});
try {
await mkdir(path.join(session.cwd, folder), { recursive: true });
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
await session.client.waitForFinish(session.agentId, 30_000);
await page.addInitScript(() => {
localStorage.setItem(
"panel-state",
JSON.stringify({ state: { explorerShowHiddenFiles: false }, version: 0 }),
);
});
await openAgentRoute(page, session);
await openReportedFolderLink(page, "Open folder");
await expectFolderContents(page);
} finally {
await page.screenshot({ path: test.info().outputPath("folder-link-result.png") });
await session.cleanup();
}
});
}
@@ -0,0 +1,110 @@
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import {
closeModelPicker,
drillIntoProvider,
openModelPicker,
} from "../support/helpers/agent-profiles";
import { expectComposerVisible, expectComposerFocused } from "../support/helpers/composer";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHost, openSettingsHostSection } from "../support/helpers/settings";
// Use an existing repository linked to the signed-in user's Hoplite project.
const cwd = process.env.E2E_HOPLITE_CWD ?? path.resolve(__dirname, "../../../..");
test.use({
viewport: { width: 1440, height: 1000 },
e2eDaemonConfig: { version: 1, agents: { providers: { hoplite: { enabled: true } } } },
});
async function openHopliteSettings(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await page.getByRole("button", { name: "Hoplite provider details", exact: true }).click();
}
async function expectEmptyGlobalCatalog(page: Page, client: DaemonClient): Promise<void> {
const models = await client.listProviderModels("hoplite");
expect(models.error).toBeNull();
expect(models.models).toEqual([]);
const snapshot = await client.getProvidersSnapshot();
expect(snapshot.entries.find((entry) => entry.provider === "hoplite")).toMatchObject({
status: "ready",
models: [],
});
await expect(page.getByText("No models detected", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
}
async function refreshGlobalCatalog(page: Page): Promise<void> {
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expect(page.getByRole("button", { name: "Refresh", exact: true })).toBeEnabled({
timeout: 120_000,
});
}
async function openWorkspaceModels(page: Page, workspaceId: string): Promise<void> {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await expectComposerFocused(page);
await openModelPicker(page);
await page.getByTestId("sheet-header-back").click();
await drillIntoProvider(page, "hoplite");
}
test("real Hoplite has an empty global catalog and discovers models in its linked workspace", async ({
page,
e2eWorkerClient,
}, testInfo) => {
test.setTimeout(180_000);
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "hoplite-discovery" });
const created = await e2eWorkerClient.createWorkspace({
source: { kind: "directory", path: cwd },
title: "Hoplite discovery QA",
});
expect(created.error).toBeNull();
const workspace = created.workspace!;
try {
await test.step("global settings succeed with no models, including refresh", async () => {
await openHopliteSettings(page);
await expectEmptyGlobalCatalog(page, client);
await refreshGlobalCatalog(page);
await expectEmptyGlobalCatalog(page, client);
await page.screenshot({ path: testInfo.outputPath("hoplite-global-empty.png") });
});
await test.step("the linked workspace discovers real models", async () => {
const models = await client.listProviderModels("hoplite", { cwd });
expect(models.error).toBeNull();
expect(models.models!.length).toBeGreaterThan(0);
await testInfo.attach("workspace-models", {
body: JSON.stringify(models, null, 2),
contentType: "application/json",
});
await openWorkspaceModels(page, workspace.id);
await expect(page.getByText(models.models![0]!.label, { exact: true }).last()).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("hoplite-workspace-models.png") });
await closeModelPicker(page);
});
await test.step("workspace discovery does not populate the global catalog", async () => {
await openHopliteSettings(page);
await expectEmptyGlobalCatalog(page, client);
await refreshGlobalCatalog(page);
await expectEmptyGlobalCatalog(page, client);
const models = await client.listProviderModels("hoplite", { cwd });
expect(models.error).toBeNull();
expect(models.models!.length).toBeGreaterThan(0);
});
} finally {
await client.close();
await e2eWorkerClient.removeProject(workspace.projectId);
}
});
+19 -48
View File
@@ -2,9 +2,7 @@ import { test, expect } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertNewTabMenuTriggerVisible,
assertSingleNewTabButton,
openNewTabMenuWithShortcut,
clickNewChat,
clickNewTerminal,
@@ -126,6 +124,7 @@ test.describe("Tab creation", () => {
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertNewTabMenuTriggerVisible(page);
await pressNewTabShortcut(page);
const newTabs = page
.locator('[data-testid^="workspace-tab-tab_"]')
@@ -190,11 +189,18 @@ test.describe("Tab creation", () => {
await expect(agent).toBeFocused();
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
test("the Agent menu creates a draft and shows its composer within the visual budget", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composerLocator(page),
10_000,
);
expect(elapsed).toBeLessThan(3_000);
await expectComposerVisible(page);
const tabsAfter = await getTabTestIds(page);
@@ -202,12 +208,17 @@ test.describe("Tab creation", () => {
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
});
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
const elapsed = await measureTileTransition(
page,
() => clickNewTerminal(page),
terminalSurfaceLocator(page),
20_000,
);
expect(elapsed).toBeLessThan(5_000);
await expectTerminalSurfaceVisible(page);
const tabsAfter = await getTabTestIds(page);
@@ -295,13 +306,6 @@ test.describe("Tab creation", () => {
await profileSeed.restore();
}
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page);
await assertNewTabMenuTriggerVisible(page);
});
});
// ═══════════════════════════════════════════════════════════════════════════
@@ -339,37 +343,4 @@ test.describe("Tab transitions (no flash)", () => {
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
});
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewTerminal(page),
terminalSurfaceLocator(page),
20_000,
);
// Terminal surface should appear within a reasonable budget.
// Note: terminal creation involves a server round-trip, so we allow more time
// than a pure in-memory transition, but it should still be well under 5 seconds.
expect(elapsed).toBeLessThan(5_000);
});
test("New agent tab click shows composer without flash", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composerLocator(page),
10_000,
);
// Draft creation is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion
// is that no blank/flash frame appears (tested above).
expect(elapsed).toBeLessThan(3_000);
});
});
@@ -1,7 +1,14 @@
import {
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
} from "../support/helpers/composer";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
expectNewWorkspaceProjectSelected,
fillNewWorkspaceDraft,
@@ -134,3 +141,45 @@ test.describe("New workspace composer draft", () => {
}
});
});
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
page,
}) => {
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
const delay = await delayBrowserWorkspaceCreatedResponse(page);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await resizeComposerViewport(page, "portrait");
await fillNewWorkspaceDraft(page, DRAFT);
await enterComposerFullscreen(page);
await page.getByRole("button", { name: "Create", exact: true }).click();
await delay.waitForCreateRequest();
await test.step("submission returns inline with the original draft and spinner", async () => {
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
});
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
await enterComposerFullscreen(page);
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
await exitComposerFullscreen(page);
});
delay.release();
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
await expect(composerLocator(page)).toHaveValue("");
await expect(composerLocator(page)).toBeEditable();
expect(delay.agentRequests).toHaveLength(1);
} finally {
delay.release();
await project.cleanup();
}
});
@@ -6,6 +6,15 @@ import {
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
composerLocator,
expectComposerVisible,
fillComposerDraft,
enterComposerFullscreen,
resizeComposerViewport,
expectComposerFullscreenControls,
} from "../support/helpers/composer";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
@@ -196,3 +205,52 @@ test.describe("New Workspace dictation submit", () => {
}
});
});
async function startDictationWithShortcut(page: Page): Promise<void> {
await composerLocator(page).press("Control+d");
}
test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "fullscreen-dictation-",
title: "Fullscreen dictation shortcut",
});
await installSyntheticMicrophone(page);
const harness = await installDictationFailureHarness(page);
const draft = "First line\nSecond line\nThird line";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await fillComposerDraft(page, draft);
await enterComposerFullscreen(page);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await startDictationWithShortcut(page);
await harness.waitForAudio();
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "Insert transcription", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Insert transcription and send", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue(draft);
await resizeComposerViewport(page, "portrait");
await enterComposerFullscreen(page);
await resizeComposerViewport(page, "landscape");
await composerLocator(page).press("Control+Shift+d");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await expect(
page.getByText("Realtime voice is disabled in this test.", { exact: true }),
).toBeVisible();
await expect(composerLocator(page)).toHaveValue(draft);
} finally {
await agent.cleanup();
}
});
@@ -1,7 +1,18 @@
import { expect, test } from "../support/fixtures";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import {
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import {
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
expectComposerFullscreenControls,
resizeComposerViewport,
} from "../support/helpers/composer";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalLaunchSelected,
@@ -9,13 +20,12 @@ import {
expectTerminalPreviewCommand,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
seedTerminalProfiles,
launchTrigger,
selectLaunchOption,
submitTerminalLaunch,
terminalLaunchSubmit,
terminalPromptInput,
type TerminalProfile,
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// Someone who wants a terminal agent instead of a chat: they pick a profile
@@ -42,17 +52,18 @@ const BARE_PROFILE: TerminalProfile = {
args: ["-c", "echo bare-launch-static-line; exec cat"],
};
test.use({
e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } },
});
test.describe("New workspace: launching a terminal", () => {
let workspace: SeededWorkspace;
let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
});
test.afterEach(async () => {
await profileSeed.restore();
await workspace?.cleanup();
});
@@ -97,4 +108,165 @@ test.describe("New workspace: launching a terminal", () => {
await expectTerminalOutputContains(page, "bare-launch-static-line");
});
});
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
page,
}) => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await page.setViewportSize({ width: 390, height: 844 });
const prompt = "First replacement line\nSecond replacement line\nThird replacement line";
const selectProfile = async (profile: TerminalProfile) => {
await launchTrigger(page).click();
const option = page.getByRole("menuitem").filter({ hasText: profile.name });
await option.click();
await expect(option).not.toBeVisible();
};
const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true });
await test.step("restore a multiline prompt after a read-only profile", async () => {
await selectProfile(PROMPT_PROFILE);
await fillTerminalPrompt(page, prompt);
await expect(fullscreen).toBeVisible();
await selectProfile(BARE_PROFILE);
await expect(terminalPromptInput(page)).toHaveCount(0);
await selectProfile(PROMPT_PROFILE);
await expect(terminalPromptInput(page)).toHaveValue(prompt);
await expect(fullscreen).toBeVisible();
});
await test.step("restore the locally edited prompt and measure subsequent typing", async () => {
await fillTerminalPrompt(page, "Short draft");
await expect(fullscreen).toHaveCount(0);
await selectProfile(BARE_PROFILE);
await selectProfile(PROMPT_PROFILE);
await expect(terminalPromptInput(page)).toHaveValue("Short draft");
await expect(fullscreen).toHaveCount(0);
await terminalPromptInput(page).press("End");
await terminalPromptInput(page).press("Shift+Enter");
await terminalPromptInput(page).pressSequentially("Second");
await terminalPromptInput(page).press("Shift+Enter");
await terminalPromptInput(page).pressSequentially("Third");
await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird");
await expect(fullscreen).toBeVisible();
});
});
});
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
}
async function expectNewWorkspaceHeaderVisible(page: Page): Promise<void> {
await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible();
}
/**
* Fullscreen fills the content area top to bottom and out to the right edge (a pinned
* sidebar keeps its column), covers the form, and shows only exit and the mode's submit.
*/
async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise<void> {
const frame = page.getByTestId("composer-fullscreen");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
await expect(frame.getByRole("button")).toHaveCount(2);
await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible();
await expectNewWorkspaceHeaderHidden(page);
const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!;
await expect
.poll(() =>
frame.evaluate(
(element, point) => {
const viewport = window.visualViewport!;
const box = element.getBoundingClientRect();
const covering = document.elementFromPoint(point.x, point.y);
return {
fillsHeight:
Math.abs(box.top - viewport.offsetTop) < 1 &&
Math.abs(box.height - viewport.height) < 1,
reachesRightEdge:
Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 &&
box.left >= viewport.offsetLeft,
coversForm: element.contains(covering),
};
},
{ x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 },
),
)
.toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true });
}
test.describe("New workspace: fullscreen composer across the compact breakpoint", () => {
let workspace: SeededWorkspace;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" });
});
test.afterEach(async () => {
await workspace?.cleanup();
});
test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({
page,
}) => {
test.setTimeout(90_000);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
const projectPicker = page.getByRole("button", { name: "Workspace project" });
await test.step("chat: fullscreen covers the centered form and exits back into it", async () => {
await resizeComposerViewport(page, "portrait");
const draft = "Chat first line\nChat second line\nChat third line";
await fillNewWorkspaceDraft(page, draft);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectNewWorkspaceHeaderHidden(page);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectNewWorkspaceFullscreen(page, "Create");
// The desktop form's own import button is dropped from the fullscreen slot.
await expect(
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
).toHaveCount(0);
await composerLocator(page).press("End");
await composerLocator(page).pressSequentially(" wide");
await exitComposerFullscreen(page);
await expectNewWorkspaceHeaderVisible(page);
await expect(projectPicker).toBeVisible();
await expect(
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
).toBeVisible();
await expectNewWorkspaceDraft(page, `${draft} wide`);
});
// Terminal mode and its prompt are screen state, and the shell remounts the screen
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
// layout only.
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
await resizeComposerViewport(page, "portrait");
// The compact launch control opens a sheet, not the desktop menu.
await launchTrigger(page).click();
const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name });
await option.click();
await expect(option).not.toBeVisible();
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
const prompt = "Prompt first line\nPrompt second line\nPrompt third line";
await fillTerminalPrompt(page, prompt);
await enterComposerFullscreen(page);
await expect(terminalPromptInput(page)).toBeVisible();
await expectNewWorkspaceFullscreen(page, "Launch");
await exitComposerFullscreen(page);
await expectNewWorkspaceHeaderVisible(page);
await expect(projectPicker).toBeVisible();
await expect(terminalPromptInput(page)).toHaveValue(prompt);
});
});
});
@@ -0,0 +1,32 @@
import { expect, test } from "../support/fixtures";
import { expectComposerModel } from "../support/helpers/agent-profiles";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
import { submitMessage } from "../support/helpers/composer";
async function restoreHostConnection(gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>) {
gate.holdNextServerMessage("status");
gate.restore();
await gate.waitForHeldServerMessage("status");
gate.releaseHeldServerMessage("status");
}
test("an offline workspace draft reports the disconnected host instead of loading models", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace();
await workspace.navigateTo();
await gate.drop();
await gate.waitForBlockedConnection();
await createAgentTabFromMenu(page);
await submitMessage(page, "hello");
await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveText(
"Host is not connected",
);
await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0);
await restoreHostConnection(gate);
await expectComposerModel(page, "Ten second stream");
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello");
});
@@ -0,0 +1,42 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
async function openProjectHome(page: Page) {
await page.goto("/open-project");
await expect(page.getByText("Add a project", { exact: true })).toBeVisible();
}
async function scrollToHomeFooter(page: Page) {
await page.mouse.move(160, 350);
await page.mouse.wheel(0, 1500);
}
async function expectHomeActionsReachable(page: Page) {
const providers = page.getByTestId("open-project-setup-providers");
const community = page.getByRole("button", { name: "Community", exact: true });
await expect(providers).toBeInViewport({ ratio: 1 });
await expect(community).toBeInViewport({ ratio: 1 });
const providerBounds = await providers.boundingBox();
const communityBounds = await community.boundingBox();
expect(providerBounds).not.toBeNull();
expect(communityBounds).not.toBeNull();
expect(communityBounds!.y).toBeGreaterThanOrEqual(providerBounds!.y + providerBounds!.height);
}
test("home actions remain reachable on a short phone screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 320, height: 568 });
await openProjectHome(page);
await scrollToHomeFooter(page);
await expectHomeActionsReachable(page);
await page.screenshot({ path: testInfo.outputPath("home-scrolled.png") });
});
test("home actions fit on a desktop screen without overlap", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await openProjectHome(page);
await expectHomeActionsReachable(page);
await expect(page.getByTestId("open-project-setup-providers")).toHaveCSS(
"-webkit-app-region",
"none",
);
});
@@ -1,6 +1,8 @@
import { test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
expectPairingOffer,
expectCompletePairingQr,
expectRelayConsent,
openPairDeviceModal,
preparePairingHost,
@@ -18,3 +20,20 @@ test("opens relay consent in browser web", async ({ page }) => {
await daemon.close();
}
});
test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => {
const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", {
mutableRelay: { enabled: true },
});
try {
await preparePairingHost(page, daemon);
await page.setViewportSize({ width: 320, height: 568 });
await openPairDeviceModal(page);
await expectPairingOffer(page);
await expectCompletePairingQr(page);
await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") });
} finally {
await daemon.close();
}
});
@@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => {
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
});
await captureToolCall(page, testInfo, {
label: "Send agent prompt",
label: "Send message",
artifact: "send-prompt-presentation",
expectedText: ["Prompt", GREETING, "Agent", "Result"],
});
@@ -1,15 +1,9 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { waitForPermissionPrompt } from "../support/helpers/permissions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// The plan card has to pass its own markdown parser. Drop that prop and
// react-native-markdown-display silently supplies one with `typographer: true`
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
// No unit test can catch that, because the defect lives in the JSX.
//
// Nothing here is provider-specific: the mock provider drives it, and the
// behavior is the same for every agent.
// Literal shell commands and copyright markers must survive Markdown rendering.
test.describe("Plan card markdown", () => {
test("renders plan text verbatim", async ({ page }) => {
test.setTimeout(180_000);
@@ -25,6 +19,7 @@ test.describe("Plan card markdown", () => {
await waitForPermissionPrompt(page, 120_000);
const planCard = page.getByTestId("permission-plan-card");
await expect(planCard.locator(".enrm-text")).toBeVisible();
await expect(planCard).toContainText("(c)");
await expect(planCard).toContainText('--name="my repo"');
await expect(planCard).toContainText("---buzz");
@@ -35,3 +30,279 @@ test.describe("Plan card markdown", () => {
}
});
});
test("chat and file preview share the library renderer and resolve file links", async ({
page,
}, testInfo) => {
await withSharedMarkdownFiles(page, async () => {
const message = page.getByTestId("assistant-message");
await expect(message).toHaveCount(1);
await expect(message.locator(".enrm-text > p").first()).toContainText("First paragraph.");
await expect(message.locator(".enrm-text > p").nth(1)).toContainText("Second paragraph.");
await expect(message.locator("blockquote")).toContainText("A quote");
await expect(message.getByRole("table")).toContainText("42");
await expect(message.getByRole("cell").nth(0)).toHaveText("Answer");
await expect(message.getByRole("cell").nth(1)).toHaveText("");
await expect(message.getByRole("cell").nth(2)).toHaveText("42");
await expect(message.locator("p div")).toHaveCount(0);
await openSharedMarkdownFile(page, "README.md");
const preview = page.getByTestId("markdown-preview-frame");
await expect(preview.locator(".enrm-text")).toContainText("Second paragraph.");
await expect(preview.getByRole("table")).toContainText("42");
await returnToSharedMarkdownChat(page);
await expectFileLinkTooltip(
page,
message.getByRole("link", { name: "src/说明.md:2-3", exact: true }),
"packages/app/src/说明.md:2-3",
);
await openSharedMarkdownFile(page, "src/说明.md:2-3");
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
await openSharedMarkdownFile(page, "packages/app/src/说明.md:2-3");
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
await openSharedMarkdownFile(page, "Space file");
await expect(
page.getByText("Explicit link opened the space filename.", { exact: true }),
).toBeVisible();
await openSharedMarkdownFile(page, "my%20file.ts");
await expect(
page.getByText("Inline code opened the literal percent filename.", { exact: true }),
).toBeVisible();
await testInfo.attach("shared-renderer", {
body: await page.screenshot(),
contentType: "image/png",
});
});
});
async function expectFileLinkTooltip(page: Page, link: Locator, path: string) {
await link.hover();
await expect(page.getByRole("tooltip")).toHaveText(path);
}
async function withSharedMarkdownFiles(page: Page, run: () => Promise<void>) {
const { writeFile, mkdir } = await import("node:fs/promises");
const source =
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`, or packages/app/src/说明.md:2-3.\n\n[Space file](my%20file.ts) and `my%20file.ts`.";
const session = await seedMockAgentWorkspace({
repoPrefix: "shared-markdown-",
title: "Shared Markdown",
featureValues: { mockAssistantResponse: source },
initialPrompt: "Show Markdown",
});
try {
await writeFile(`${session.cwd}/README.md`, source);
await writeFile(`${session.cwd}/my file.ts`, "Explicit link opened the space filename.");
await writeFile(
`${session.cwd}/my%20file.ts`,
"Inline code opened the literal percent filename.",
);
await mkdir(`${session.cwd}/packages/app/src`, { recursive: true });
await writeFile(
`${session.cwd}/packages/app/src/说明.md`,
"# Nested file\n\nUnicode suffix lookup succeeded.",
);
await session.client.waitForFinish(session.agentId, 30_000);
// A cold render must work even when a separate parser chunk cannot be fetched.
await page.route("**/wasm/md4c.bundle?**", (route) => route.abort("failed"));
await openAgentRoute(page, session);
await run();
} finally {
await session.cleanup();
}
}
async function returnToSharedMarkdownChat(page: Page) {
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
}
async function openSharedMarkdownFile(page: Page, name: string) {
await returnToSharedMarkdownChat(page);
await page.getByTestId("assistant-message").getByRole("link", { name, exact: true }).click();
}
test("linked inline code keeps code typography and wraps long paths", async ({
page,
}, testInfo) => {
const longPath =
"packages/app/src/components/markdown/renderer/very-long-directory-name/another-long-directory-name/file.ts:12";
const session = await seedMockAgentWorkspace({
repoPrefix: "inline-code-style-",
title: "Inline code style",
featureValues: {
mockAssistantResponse:
"Compare `ordinary_token`, `file.ts:12`, `https://example.com`. At the end of this line open `" +
longPath +
"`.",
},
initialPrompt: "Show inline code",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("link", { name: longPath, exact: true })).toBeVisible();
const measurements = await measureInlineCode(message);
await testInfo.attach("inline-code-measurements", {
body: JSON.stringify(measurements, null, 2),
contentType: "application/json",
});
expect(measurements.codes).toHaveLength(4);
const [plain, file, url, long] = measurements.codes;
for (const linked of [file, url, long]) {
expect(linked.fontSize).toBe(plain.fontSize);
expect(linked.fontFamily).toBe(plain.fontFamily);
expect(linked.backgroundColor).toBe(plain.backgroundColor);
expect(linked.color).toBe(linked.anchorColor);
expect(linked.color).not.toBe(plain.color);
}
expect(long.lines).toBeGreaterThan(1);
expect(long.right).toBeLessThanOrEqual(measurements.right + 1);
} finally {
await session.cleanup();
}
});
async function measureInlineCode(message: Locator) {
return message.evaluate((element) => {
// Constrain the paragraph to exercise an inline path at a line boundary.
const paragraph = element.querySelector("p")!;
paragraph.style.width = "300px";
const codes = Array.from(paragraph.querySelectorAll("code"), (code) => {
const style = getComputedStyle(code);
const range = document.createRange();
range.selectNodeContents(code);
const rects = Array.from(range.getClientRects());
return {
text: code.textContent,
fontSize: style.fontSize,
fontFamily: style.fontFamily,
backgroundColor: style.backgroundColor,
color: style.color,
anchorColor: code.closest("a") ? getComputedStyle(code.closest("a")!).color : null,
whiteSpace: style.whiteSpace,
overflowWrap: style.overflowWrap,
rects: rects.map((rect) => ({ left: rect.left, right: rect.right, top: rect.top })),
lines: new Set(rects.map((rect) => rect.top)).size,
right: Math.max(...rects.map((rect) => rect.right)),
};
});
return { codes, right: paragraph.getBoundingClientRect().right };
});
}
test("inline-code paths preserve stable recognition and file navigation", async ({
page,
context,
}, testInfo) => {
const { writeFile, mkdir } = await import("node:fs/promises");
const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"];
const source =
paths.map((path) => `Open \`${path}\`.`).join("\n\n") +
"\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain.";
const session = await seedMockAgentWorkspace({
repoPrefix: "inline-path-regression-",
title: "Inline path regression",
initialPrompt: "Show file paths",
featureValues: { mockAssistantResponse: source },
});
try {
await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true });
for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`);
await writeFile(
`${session.cwd}/src/index.ts`,
Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"),
);
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible();
await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0);
await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0);
for (const path of paths) {
await openInlineRegressionFile(page, path);
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText(
`Opened ${path}`,
);
}
await openInlineRegressionFile(page, "src/index.ts#L10-L20");
await expect(page.getByLabel("Line 20, column 26")).toBeVisible();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-content")
.focus();
await page.keyboard.press("ControlOrMeta+c");
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(
Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"),
);
await expect(
page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-selectionBackground"),
).not.toHaveCount(0);
await testInfo.attach("inline-path-line-selection", {
body: await page.screenshot(),
contentType: "image/png",
});
} finally {
await session.cleanup();
}
});
async function openInlineRegressionFile(page: Page, path: string) {
await page.getByRole("button", { name: "Inline path regression", exact: true }).click();
await page
.getByTestId("assistant-message")
.getByRole("link", { name: path, exact: true })
.click();
}
// Web links are styled inline by the library, so hover state has to restyle the anchor.
test("markdown links underline on hover", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "markdown-link-hover-",
title: "Markdown link hover",
featureValues: {
mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).",
},
initialPrompt: "Show links",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
for (const name of ["the docs", "README"]) {
const link = message.getByRole("link", { name, exact: true });
await expect(link).toHaveCSS("text-decoration-line", "none");
await link.hover();
await expect(link).toHaveCSS("text-decoration-line", "underline");
await page.mouse.move(0, 0);
await expect(link).toHaveCSS("text-decoration-line", "none");
}
} finally {
await session.cleanup();
}
});
const PIXEL_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
test("Markdown images show a zoom-in cursor", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "markdown-image-cursor-",
title: "Markdown image cursor",
featureValues: { mockAssistantResponse: `Before.\n\n![pixel](${PIXEL_PNG})\n\nAfter.` },
initialPrompt: "Show image",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const image = page.getByTestId("assistant-message").getByRole("button", { name: "pixel" });
await expect(image).toHaveCSS("cursor", "zoom-in");
} finally {
await session.cleanup();
}
});
@@ -1,3 +1,5 @@
import { existsSync } from "node:fs";
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
@@ -315,6 +317,47 @@ export default function contribute(plugin) {
return directory;
}
/** Build command that holds the plugin lifecycle queue until the test writes the release file. */
const gatedBuildCommand = [
'const fs = require("node:fs");',
'fs.writeFileSync(process.argv[1], "started");',
"const wait = () => {",
" if (fs.existsSync(process.argv[2])) return;",
" setTimeout(wait, 50);",
"};",
"wait();",
].join("\n");
async function createGatedBuildRepository(
root: string,
gate: { started: string; release: string },
): Promise<string> {
const repository = path.join(root, "gated-repository");
await mkdir(repository);
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "gated-build-plugin",
description: "Holds the plugin lifecycle queue while its build waits",
requirements: pluginRequirements,
build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]],
}),
);
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin"));
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
return repository;
}
async function installFailedPlugin(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
@@ -557,7 +600,7 @@ async function retryGitInstallAndInspectRows(
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Plugin source is neither an existing directory nor a Git URL",
`Plugin directory does not exist: ${missingSource}`,
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
@@ -655,3 +698,179 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
}
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
await installPluginWithUntrustedDescription(pluginEnvironment, description);
await gotoAppShell(page);
await openPluginSettings(page);
const text = page.getByText(description, { exact: true });
await expect(text).toBeVisible();
// This field must remain a text node, with no Markdown links, images, or HTML children.
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
});
async function installPluginWithUntrustedDescription(
environment: PluginEnvironment,
description: string,
) {
const directory = await createDirectoryPlugin(
environment.directory,
"plain-text-plugin",
description,
"Plain text plugin",
);
await environment.client.installPluginSource({ source: directory });
}
/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */
function observePluginReload(page: Page) {
const requests: number[] = [];
const responses: number[] = [];
const isSessionMessage = (payload: unknown, type: string): boolean => {
if (typeof payload !== "string") return false;
try {
const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } };
const message = envelope.type === "session" ? envelope.message : envelope;
return message?.type === type;
} catch {
return false;
}
};
page.on("websocket", (socket) => {
// The reload request travels client -> daemon (outbound); the answer comes back inbound.
socket.on("framesent", ({ payload }) => {
if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now());
});
socket.on("framereceived", ({ payload }) => {
if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now());
});
});
return {
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
requestedAt: () => requests[0]!,
requestCount: () => requests.length,
};
}
/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */
async function holdPluginLifecycleQueue(
client: PluginEnvironment["client"],
root: string,
): Promise<{ release: () => Promise<void>; settle: () => Promise<void> }> {
const gate = {
started: path.join(root, "build-started"),
release: path.join(root, "build-release"),
};
const repository = await createGatedBuildRepository(root, gate);
const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` });
// The marker wait below can outlive an early failure: a refused preparation, a dropped
// connection, or a build command that exits before writing its marker. Handle that rejection now
// so it never surfaces as an unhandled one, and drain the same promise in finish().
const installDrained = install.catch(() => undefined);
// Every path releases the gate and drains the install: a build still polling for its gate holds
// the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall.
const finish = async () => {
await writeFile(gate.release, "go").catch(() => undefined);
await installDrained;
};
try {
// Whichever comes first decides the scenario: the build reports that it started, or the setup
// fails with the daemon's own error instead of a bare marker timeout.
await Promise.race([
expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true),
install,
]);
} catch (error) {
// Setup that never reached the build must not hand the caller a queue it cannot settle.
await finish();
throw error;
}
return {
release: () => writeFile(gate.release, "go"),
settle: finish,
};
}
function pluginRowActions(page: Page, pluginId: string) {
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
}
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
}
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
}
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await page.screenshot({
path: testInfo.outputPath(name),
fullPage: true,
animations: "disabled",
});
}
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
const remaining = 66_000 - (Date.now() - requestedAt);
if (remaining > 0) await page.waitForTimeout(remaining);
}
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
page,
pluginEnvironment,
}, testInfo) => {
test.setTimeout(180_000);
const { client, directory } = pluginEnvironment;
const pluginDirectory = await createDirectoryPlugin(
directory,
"e2e-plugin",
"Exercises the complete local plugin lifecycle",
"Plugin v1",
);
// Observe before the app loads: its daemon socket comes up with the page.
const reload = observePluginReload(page);
await gotoAppShell(page);
await openPluginSettings(page);
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: pluginDirectory });
await expectPluginRowSettled(page, "e2e-plugin");
const queue = await holdPluginLifecycleQueue(client, directory);
try {
await selectPluginAction(page, "e2e-plugin", "Reload");
await reload.waitForRequest();
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
await expectPluginRowPending(page, "e2e-plugin");
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
await waitPastFormerReloadDeadline(page, reload.requestedAt());
// Past the former deadline the reload is still pending, the row shows its previous state, and
// no timeout error reached the surface.
await expectPluginRowPending(page, "e2e-plugin");
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
await queue.release();
// The daemon's answer only arrives once the plugin really started again; a request timeout
// would have shown up as feedback during the wait above.
await reload.waitForResponse();
expect(reload.requestCount()).toBe(1);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Reloaded e2e-plugin",
);
await expectPluginRowSettled(page, "e2e-plugin");
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
} finally {
await queue.settle();
}
});
@@ -0,0 +1,47 @@
import { expect, test, type Page } from "../support/fixtures";
import { composerLocator, submitMessageWithButton } from "../support/helpers/composer";
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
import { withPopoverTimelinePlugin } from "../support/helpers/plugin-timeline";
async function openPreviewWhileTyping(page: Page): Promise<void> {
await composerLocator(page).focus();
await page.getByRole("button", { name: "Hover plugin preview", exact: true }).hover();
await expect(page.getByRole("dialog", { name: "Plugin preview" })).toBeVisible();
await expect(composerLocator(page)).toBeFocused();
}
async function dismissPreview(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Plugin preview" })).not.toBeVisible();
}
test("Escape dismisses a plugin preview before interrupting the agent", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "plugin-popover-",
title: "Plugin preview keyboard regression",
model: "e2e-fast-stream",
});
try {
await withPopoverTimelinePlugin(async () => {
await openAgentRoute(page, agent);
await submitMessageWithButton(page, "Show the plugin preview.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByRole("button", { name: "Hover plugin preview" })).toBeVisible();
await submitMessageWithButton(page, "withhold synthetic user message until interrupted");
const stop = page.getByRole("button", { name: "Stop agent", exact: true });
await expect(stop).toBeVisible();
await openPreviewWhileTyping(page);
await dismissPreview(page);
await expect(stop).toBeVisible();
await expect(composerLocator(page)).toBeFocused();
await page.keyboard.press("Escape");
await expect(stop).not.toBeVisible();
await agent.client.waitForFinish(agent.agentId, 15_000);
});
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,64 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test as base } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
const test = base.extend<{ projectWithTrailingSpace: string }>({
projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
try {
const repoPath = path.join(root, "repo ");
execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.local",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repoPath, stdio: "pipe" },
);
await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
const added = await client.addProject(repoPath);
expect(added.error).toBeNull();
const projectId = added.project!.projectId;
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: repoPath, projectId },
});
expect(created.error).toBeNull();
await provide(created.workspace!.id);
} finally {
await client.removeProject(projectId);
}
} finally {
await rm(root, { recursive: true, force: true });
}
},
});
test.skip(
process.platform === "win32",
"Windows does not preserve trailing spaces in folder names",
);
test("Changes shows files in a project whose folder ends in a space", async ({
page,
projectWithTrailingSpace,
}, testInfo) => {
await gotoWorkspace(page, projectWithTrailingSpace);
await openChangesTreePanel(page);
await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("changes.png") });
});
@@ -223,7 +223,10 @@ test.describe("Projects settings", () => {
}
});
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
test("edits worktree setup, navigates within the host, and removes a script", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
@@ -236,15 +239,22 @@ test.describe("Projects settings", () => {
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
});
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectProjectHostContextHidden(page);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
await test.step("navigation stays inside the selected host", async () => {
await expectProjectHostContextHidden(page);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
});
await test.step("removing a script leaves the committed setup unchanged", async () => {
await expectScriptRowCount(page, 1);
await removeProjectScript(page, "dev");
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
await clickSaveProjectSettings(page);
await expectNoUncommittedSetupWarning(page);
});
});
test("user edits worktree setup on a non-GitHub remote project", async ({
@@ -259,18 +269,7 @@ test.describe("Projects settings", () => {
await expectProjectConfigSaved(gitlabRemoteProject);
});
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
});
test("reopening the edit sheet seeds from the saved project", async ({
test("renames a project, reopens the saved edit sheet, and adds an icon", async ({
page,
editableProject,
}) => {
@@ -279,26 +278,26 @@ test.describe("Projects settings", () => {
await openProjectEditSheet(page);
await fillProjectName(page, "Renamed project");
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
await openProjectEditSheet(page);
await expectProjectEditName(page, "Renamed project");
await expectProjectEditsSaveDisabled(page);
});
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await openProjectEditSheet(page);
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
await test.step("reopening seeds the saved name with no unsaved changes", async () => {
await openProjectEditSheet(page);
await expectProjectEditName(page, "Renamed project");
await expectProjectEditsSaveDisabled(page);
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await test.step("an icon can be saved without another name change", async () => {
await chooseProjectIconImage(page, {
name: "logo.png",
mimeType: "image/png",
buffer: PNG_1X1,
});
await saveProjectEdits(page);
await expectProjectEditSaved(page);
await expectProjectTitle(page, "Renamed project");
});
});
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
@@ -459,31 +458,4 @@ test.describe("Projects settings — error UX", () => {
await expectNoEditableTarget(page);
});
test("project detail does not render a second host selector", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectProjectHostContextHidden(page);
});
test("script removal via kebab menu removes the row from the form", async ({
page,
editableProject,
}) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectScriptRowCount(page, 1);
await removeProjectScript(page, "dev");
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
await clickSaveProjectSettings(page);
await expectNoUncommittedSetupWarning(page);
});
});
@@ -10,16 +10,29 @@ import {
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
const CUSTOM_PROVIDER = {
id: "junie",
name: "Junie",
} as const;
const PLUGIN_PROVIDER = {
id: "direct-example",
name: "Direct provider example",
pluginId: "provider-direct-example",
} as const;
interface ProviderRemovalDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
patchDaemonConfig(config: {
removeProviders?: string[];
pluginsEnabled?: boolean;
}): Promise<unknown>;
installDirectoryPlugin(path: string): Promise<unknown>;
removePlugin(pluginId: string): Promise<void>;
getProvidersSnapshot(): Promise<{
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
}>;
@@ -93,4 +106,45 @@ test.describe("provider removal", () => {
await client.close().catch(() => undefined);
}
});
test("does not offer Remove for a plugin-registered provider", async ({ page }) => {
test.setTimeout(120_000);
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
clientIdPrefix: "provider-removal-e2e",
});
const plugin = await copyPluginExample("provider-direct");
try {
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
const pluginProviderRow = page.getByRole("button", {
name: `${PLUGIN_PROVIDER.name} provider details`,
exact: true,
});
await expect(pluginProviderRow).toBeVisible();
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
// The provider survives a removeProviders patch pointed at it: the
// plugin, not config.json, owns the definition.
await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] });
await expect(pluginProviderRow).toBeVisible();
} finally {
await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled })
.catch(() => undefined);
}
} finally {
await client.close().catch(() => undefined);
await plugin.cleanup();
}
});
});
@@ -116,28 +116,12 @@ async function exerciseProviderSettingsStack(page: Page) {
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expectProviderSettingsVisible(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
}
test.describe("provider settings overlay stack", () => {
test("provider model update age says just now", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-relative-time-",
title: "Provider relative time",
});
try {
await page.clock.install({ time: Date.now() });
await page.setViewportSize(MOBILE_VIEWPORT);
await openAgentRoute(page, session);
await expectComposerVisible(page);
await openProviderSettingsFromModelSelector(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await session.cleanup();
}
});
test("provider settings covers the desktop model selector without closing it", async ({
page,
}) => {
@@ -1,5 +1,16 @@
import { fork } from "node:child_process";
import path from "node:path";
import { expect } from "@playwright/test";
import { z } from "zod";
import { test } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { killProcessTree } from "../support/helpers/spawn-node";
import {
buildAgentRoute,
openAgentRoute,
seedMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import {
chooseQuestionOption,
continueToNextQuestion,
@@ -7,6 +18,7 @@ import {
expectQuestionDismissEnabled,
expectQuestionHidden,
expectQuestionNavigationEnabled,
expectQuestionOptionPreview,
expectQuestionOptionSelected,
expectQuestionPrimaryActionDisabled,
expectQuestionPrimaryActionEnabled,
@@ -61,6 +73,10 @@ test.describe("Question prompt pagination", () => {
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await expectQuestionOptionPreview(page, {
option: "Behind feature flag",
preview: 'flags.enabled("new-surface")',
});
await chooseQuestionOption(page, "Behind feature flag");
await expectCurrentQuestion(page, {
index: 3,
@@ -125,3 +141,204 @@ test.describe("Question prompt pagination", () => {
}
});
});
const questionDaemonMessage = z.discriminatedUnion("type", [
z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }),
z.object({
type: z.literal("attempt"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
}),
z.object({
type: z.literal("resolved"),
requestId: z.string(),
response: z.unknown(),
sessionId: z.string(),
pendingRequestIds: z.array(z.string()),
}),
z.object({ type: z.literal("error"), error: z.string() }),
]);
type QuestionResponseAttempt = Extract<z.infer<typeof questionDaemonMessage>, { type: "attempt" }>;
type QuestionResolution = Extract<z.infer<typeof questionDaemonMessage>, { type: "resolved" }>;
interface QuestionRetryControl {
attempts: QuestionResponseAttempt[];
resolutions: QuestionResolution[];
releaseFirstResponse(): void;
}
const retryTest = test.extend<{
questionClient: QuestionRetryControl;
}>({
questionClient: async ({ page }, provide, testInfo) => {
const serverDir = path.resolve(__dirname, "../../../server");
const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), {
cwd: serverDir,
execArgv: ["--import", "tsx"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
const output: string[] = [];
child.stdout?.on("data", (data: Buffer) => output.push(data.toString()));
child.stderr?.on("data", (data: Buffer) => output.push(data.toString()));
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
const attempts: QuestionResponseAttempt[] = [];
const resolutions: QuestionResolution[] = [];
child.on("message", (message: unknown) => {
const parsed = questionDaemonMessage.parse(message);
switch (parsed.type) {
case "ready":
ready.resolve(parsed);
break;
case "attempt":
attempts.push(parsed);
break;
case "resolved":
resolutions.push(parsed);
break;
case "error":
ready.reject(new Error(parsed.error));
break;
}
});
child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`)));
const timeout = setTimeout(
() => ready.reject(new Error("Question daemon startup timed out")),
20_000,
);
try {
const daemon = await ready.promise;
clearTimeout(timeout);
const session = await seedMockAgentWorkspace({
port: daemon.port,
repoPrefix: "question-retry-",
title: "Question response retry",
initialPrompt: "Emit synthetic questions.",
});
try {
await page.goto("/", { waitUntil: "commit" });
const serverId = daemon.serverId;
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
label: "Question retry host",
nowIso: new Date().toISOString(),
});
await page.evaluate((extraHost) => {
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
}, host);
await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), {
waitUntil: "commit",
});
await waitForQuestionPrompt(page, 120_000);
await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") });
} finally {
await session.cleanup();
}
} finally {
clearTimeout(timeout);
await killProcessTree(child);
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach("question-daemon", {
body: output.join(""),
contentType: "text/plain",
});
}
}
},
});
retryTest(
"failed question submission preserves answers and allows retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.",
});
await card.getByRole("button", { name: "Submit" }).click();
await expect(card.getByRole("progressbar")).toBeVisible();
await expectQuestionPrimaryActionDisabled(page, "Submit");
await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled();
await expect(card.getByRole("tab").first()).toBeDisabled();
await expect(card.getByRole("textbox")).not.toBeEditable();
questionClient.releaseFirstResponse();
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("submission-failed.png") });
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit");
await expectQuestionDismissEnabled(page);
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "Behind feature flag");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue(
"Preserve my answers after a failed submission.",
);
await submitQuestionAnswers(page);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toMatchObject({
behavior: "allow",
updatedInput: {
answers: {
surface: "App",
rollout: "Behind feature flag",
success: "Preserve my answers after a failed submission.",
},
},
});
await page.screenshot({ path: testInfo.outputPath("submission-retried.png") });
},
);
retryTest(
"failed question dismissal allows an immediate retry",
async ({ page, questionClient }, testInfo) => {
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App");
await chooseQuestionOption(page, "Behind feature flag");
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Start with the internal team.",
});
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
await expect(card.getByRole("progressbar")).toHaveCount(0);
await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss");
await expectQuestionDismissEnabled(page);
await expectQuestionPrimaryActionEnabled(page, "Submit");
await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") });
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team.");
await expect(card.getByRole("alert")).toBeVisible();
await card.getByRole("button", { name: "Dismiss" }).click();
await expect(card).toHaveCount(0);
await expectVisibleAgentSurfacesIdle(page);
await expect.poll(() => questionClient.attempts.length).toBe(2);
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
await expect
.poll(() => questionClient.resolutions)
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
expect(questionClient.attempts[1].response).toEqual({
behavior: "deny",
message: "Dismissed by user",
});
await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") });
},
);
@@ -21,6 +21,15 @@ test("project and workspace kebabs open action sheets on compact layouts", async
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await waitForSidebarHydration(page);
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Title/,
"Show",
"Show background",
]);
await closeMenuSheet(page);
await page
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
.click();
@@ -29,6 +29,16 @@ test.describe("Sidebar project filter", () => {
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
await expect(betaRow).toBeVisible({ timeout: 30_000 });
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByRole("menuitem")).toHaveText([
/^Grouping/,
/^Title/,
"Show",
"Project",
"Show background",
]);
await closeSidebarDisplayPreferences(page);
await openSidebarProjectFilter(page);
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
await toggleProjectFilter(page, alpha.projectKey);
@@ -9,6 +9,8 @@ import {
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openGroupedProjectWorkspace,
expectWorkspaceHost,
openProjectsForSettingsHost,
openProjectDirectory,
openProjectDirectoryWithHosts,
@@ -418,4 +420,29 @@ test.describe("Sidebar project grouping", () => {
count: 2,
});
});
test("creates grouped project workspaces on the active workspace host", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, SECONDARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: SECONDARY_HOST_LABEL,
count: 2,
});
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, PRIMARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: PRIMARY_HOST_LABEL,
count: 2,
});
});
});
@@ -1,6 +1,7 @@
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
function workspaceRowTestId(workspaceId: string): string {
@@ -73,3 +74,75 @@ test.describe("Sidebar workspace rename", () => {
}
});
});
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: "Show background", exact: true }).click();
await page.keyboard.press("Escape");
}
async function expectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
// Rows have no single accessible role: their nested title and actions share this container.
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toBeVisible({ timeout: 30_000 });
}
async function expectWorkspaceHidden(page: Page, workspaceId: string): Promise<void> {
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toHaveCount(0);
}
async function reconnectApp(page: Page): Promise<void> {
await page.reload();
await expect(page.getByLabel("Display preferences", { exact: true })).toBeVisible();
}
async function expectWorkerOpen(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
await expect(page.getByText("Background reviewer", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Background review prompt", { exact: true }).first()).toBeVisible();
}
test("background workspace stays directly accessible across sidebar toggles and reconnect", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-background-",
title: "Background review",
background: true,
});
try {
const worker = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Background reviewer",
initialPrompt: "Background review prompt",
model: "e2e-fast-stream",
modeId: "load-test",
});
await test.step("Default sidebar hides background work", async () => {
await gotoAppShell(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
});
await test.step("Show background exposes the fully replicated workspace", async () => {
await toggleBackgroundWorkspaces(page);
await expectWorkspaceInSidebar(page, workspace.workspaceId);
await reconnectApp(page);
await expectWorkspaceInSidebar(page, workspace.workspaceId);
});
await test.step("Direct agent navigation survives hiding and reconnect", async () => {
await toggleBackgroundWorkspaces(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: worker.id });
await expectWorkerOpen(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
await reconnectApp(page);
await expectWorkerOpen(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
await page.screenshot({ path: testInfo.outputPath("background-direct-open.png") });
});
} finally {
await workspace.cleanup();
}
});
@@ -504,6 +504,17 @@ test.describe("Mobile sidebar panelState transition", () => {
await expectMobileAgentSidebarHidden(page);
});
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
});
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
page,
}) => {
@@ -0,0 +1,71 @@
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import { openLaunchMenu } from "../support/helpers/new-workspace-launch";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Harmless commands: this journey opens the profile menu without launching them.
test.use({
e2eDaemonConfig: {
version: 1,
daemon: {
terminalProfiles: [
{ id: "antigravity", name: "Antigravity artwork", command: "/bin/sh", icon: "agy" },
{ id: "cursor", name: "Cursor artwork", command: "/bin/sh", icon: "cursor" },
{ id: "unknown", name: "Unknown artwork", command: "/bin/sh", icon: "unknown-icon" },
],
},
},
});
function profileIcon(page: Page, name: string) {
return page.getByRole("menuitem", { name: new RegExp(`^${name} artwork`) }).locator("svg");
}
async function expectBundledArtwork(page: Page, name: string, artwork: string) {
const icon = profileIcon(page, name);
await expect(icon).toBeVisible();
const expectedPaths = await page.evaluate((xml) => {
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
}, artwork);
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
for (const [index, path] of expectedPaths.entries()) {
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
}
await expect(icon).toHaveAttribute("width", "14");
await expect(icon).toHaveAttribute("height", "14");
const terminalColor = await page
.getByRole("menuitem", { name: "Terminal", exact: true })
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
await expect(icon.locator("path").first()).toHaveCSS("fill", terminalColor);
}
async function expectUnknownArtworkFallback(page: Page) {
const icon = profileIcon(page, "Unknown");
await expect(icon).toBeVisible();
await expect(icon.locator('path[d="M12 8V4H8"]')).toHaveCount(1);
}
test("terminal profiles show bundled artwork while unknown icons keep the Bot fallback", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "terminal-icons-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await openLaunchMenu(page);
await expectBundledArtwork(page, "Antigravity", ACP_PROVIDER_ICON_SVGS.agy);
await expectBundledArtwork(page, "Cursor", ACP_PROVIDER_ICON_SVGS.cursor);
await expectUnknownArtworkFallback(page);
} finally {
await workspace.cleanup();
}
});
+114 -1
View File
@@ -16,6 +16,7 @@ import {
} from "../support/helpers/usage-reports";
import {
installLoginUsage,
expectLoginUsageFreshness,
installCodexWindowUsage,
expectCodexReportedWindows,
refreshLoginUsage,
@@ -100,6 +101,40 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
};
}
function installReversedUsageRefresh(page: Page) {
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
return installUsageReportsFixture(page, {
lists: [(request) => (request.forceRefresh ? refreshed : initial)],
});
}
async function openUsageSummaryAtWidth(page: Page, width: number) {
await page.setViewportSize({ width, height: 900 });
await gotoAppShell(page);
if (width === 390) await openCompactSidebar(page);
await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
await openUsageFromItem(page);
}
async function expectUsageProviderOrder(page: Page, summary: string[]) {
const group = page.getByTestId(`usage-host-${getServerId()}`);
await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
"opencode plan",
"openai plan",
]);
await expectPinnedUsage(page, summary);
}
async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) {
const requestCount = usage.listRequests().length;
await refreshAllUsage(page);
await usage.waitForListRequests(requestCount + 1);
const group = page.getByTestId(`usage-host-${getServerId()}`);
await expect(group.getByText("41%", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
}
test.describe("usage modal", () => {
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
page,
@@ -152,6 +187,21 @@ test.describe("usage modal", () => {
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
});
for (const width of [1440, 390]) {
test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({
page,
}) => {
const usage = await installReversedUsageRefresh(page);
await openUsageSummaryAtWidth(page, width);
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
await refreshUsageAndWaitForReports(page, usage);
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
});
}
test("refreshes one report from its card", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
@@ -539,6 +589,69 @@ async function expectLoginErrors(page: Page, lines: string[]) {
async function expectLoginUsageOnly(page: Page, percentage: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
}
for (const compact of [false, true]) {
test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({
page,
}) => {
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
try {
await page.setViewportSize(
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
await gotoAppShell(page);
if (compact) await openCompactSidebar(page);
await openUsageFromIcon(page);
await expectLoginErrors(page, ["Claude: Initial failure"]);
await expectLoginUsageFreshness(page, "No successful update", compact);
await fixture.setReport({
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }],
});
// Move only the browser clock; real daemon fetches continue using the host clock.
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
await fixture.setReport({ status: "error", error: "Rate limited" });
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await fixture.setReport({
status: "unavailable",
problem: { kind: "no_quota", detail: "No active quota" },
});
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: No active quota");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await fixture.setReport({
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }],
});
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43%");
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
} finally {
await fixture.cleanup();
}
});
}
async function expectRetainedLoginUsage(page: Page, message: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
}
@@ -30,7 +30,7 @@ import {
openUsageFromIcon,
openUsageFromItem,
pinRow,
scrollUsage,
scrollUsageToWindow,
setSummaryInSidebar,
openUsageOptions,
showUsageAs,
@@ -146,7 +146,9 @@ test.describe("Usage item", () => {
compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
};
for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => {
test(`long usage reports scroll between their first and last windows on ${size}`, async ({
page,
}) => {
test.setTimeout(120_000);
const source = await installTallUsageSource();
try {
@@ -161,10 +163,10 @@ test.describe("Usage item", () => {
await waitForSettledPosition(content);
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
await qaScreenshot(page, `${size}-usage-scroll-top`);
await scrollUsage(page, 5_000);
await scrollUsageToWindow(page, "Window 20");
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
await qaScreenshot(page, `${size}-usage-scroll-bottom`);
await scrollUsage(page, -5_000);
await scrollUsageToWindow(page, "Window 1");
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
} finally {
await source.cleanup();
@@ -1,99 +0,0 @@
import { expect, test } from "../support/fixtures";
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalSurfaceVisible,
focusTerminalSurface,
typeInTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
} from "../support/helpers/terminal-perf";
interface CreatedAgentCwdAssertion {
workspaceId: string;
cwd: string | null;
}
async function fetchSingleAgentForWorkspace(
workspace: SeededWorkspace,
): Promise<CreatedAgentCwdAssertion | null> {
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
.map((entry) => entry.agent)
.filter((agent) => agent.workspaceId === workspace.workspaceId);
if (agents.length !== 1) {
return null;
}
const [agent] = agents;
return {
workspaceId: workspace.workspaceId,
cwd: agent.cwd,
};
}
test.describe("Workspace cwd correctness", () => {
test("main checkout workspace opens terminals in the project root", async ({
page,
withWorkspace,
}) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
await workspace.navigateTo();
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
});
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const message = `cwd draft create ${Date.now()}`;
await expect(composer).toBeEditable({ timeout: 15_000 });
await composer.fill(message);
await composer.press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
.toEqual({
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
});
} finally {
await workspace.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
await workspace.navigateTo();
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await focusTerminalSurface(page);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await typeInTerminal(page, "pwd\n");
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
});
});
@@ -3,6 +3,7 @@ import {
createAgentChatFromLauncher,
createStandaloneTerminalFromLauncher,
expectTerminalCwd,
submitAgentChatAndExpectCwd,
} from "../support/helpers/workspace-lifecycle";
test.describe("Workspace lifecycle", () => {
@@ -19,8 +20,9 @@ test.describe("Workspace lifecycle", () => {
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
await workspace.navigateTo();
await test.step("creates an agent chat", async () => {
await test.step("submitting a draft creates an agent in the project root", async () => {
await createAgentChatFromLauncher(page);
await submitAgentChatAndExpectCwd(page, workspace);
});
await test.step("creates a terminal in the project root", async () => {
@@ -0,0 +1,74 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
expectSidebarWorkspaceSelected,
switchWorkspaceViaSidebar,
} from "../support/helpers/workspace-ui";
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(12);
return rows.evaluateAll(
(elements, prefix) =>
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
`sidebar-workspace-row-${getServerId()}:`,
);
}
async function navigateWorkspace(
page: Page,
direction: "next" | "previous",
workspaceId: string,
): Promise<void> {
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
}
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
page,
}) => {
test.setTimeout(120_000);
const project = await seedWorkspace({
repoPrefix: "relative-navigation-",
title: "Workspace 01",
});
try {
for (let index = 2; index <= 12; index += 1) {
const created = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath },
title: `Workspace ${String(index).padStart(2, "0")}`,
});
expect(created.error).toBeNull();
}
await page.setViewportSize({ width: 1440, height: 1100 });
await gotoAppShell(page);
const order = await readVisibleWorkspaceOrder(page);
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
await navigateWorkspace(page, "next", order[9]);
await navigateWorkspace(page, "previous", order[8]);
});
await test.step("wrapping uses the true first and last visible workspace", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await navigateWorkspace(page, "previous", order[11]);
await navigateWorkspace(page, "next", order[0]);
});
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
await page.keyboard.press("Alt+9");
await expectSidebarWorkspaceSelected({
page,
serverId: getServerId(),
workspaceId: order[8],
});
});
} finally {
await project.cleanup();
}
});
@@ -1,3 +1,4 @@
import type { Page, TestInfo } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { test, expect } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 });
async function openTabSwitcher(page: Page) {
await page.getByRole("button", { name: /Switch tabs/ }).click();
const footer = page.getByRole("button", { name: "New terminal", exact: true });
await expect(footer).toBeVisible();
// Visibility alone passes while the sheet is still travelling up from below the viewport.
await expect
.poll(async () => {
const bounds = await footer.boundingBox();
return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(MOBILE_VIEWPORT.height);
}
async function captureScreen(page: Page, testInfo: TestInfo, name: string) {
await page.screenshot({ path: testInfo.outputPath(name + ".png") });
}
async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") {
await openTabSwitcher(page);
await page.getByRole("button", { name: kind, exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
@@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
test.describe("Workspace session rename (compact tab switcher)", () => {
test("renames an agent session from the switcher's per-session actions sheet", async ({
page,
}) => {
}, testInfo) => {
test.setTimeout(120_000);
await page.setViewportSize(MOBILE_VIEWPORT);
@@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
timeout: 30_000,
});
await captureScreen(page, testInfo, "compact-chat-header");
// Open the sessions dropdown (a bottom sheet on compact).
await page.getByRole("button", { name: /Switch tabs/ }).click();
await openTabSwitcher(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
{
timeout: 10_000,
@@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
await expect(page.getByText(renamed, { exact: true })).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
await expect(page.getByText("Rename agent", { exact: true })).toBeHidden();
await captureScreen(page, testInfo, "renamed-tab-in-switcher");
} finally {
await session.cleanup();
}
});
});
test("switcher footer creates tabs and returns to the existing chat", async ({
page,
}, testInfo) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "compact-header-actions-",
title: "Existing chat",
});
try {
await openAgentRoute(page, session);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await createTabFromSwitcher(page, "New terminal");
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2");
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
await captureScreen(page, testInfo, "compact-terminal-header");
await createTabFromSwitcher(page, "New agent");
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
await expect(
page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }),
).toHaveCount(1);
await captureScreen(page, testInfo, "compact-draft-header");
await openTabSwitcher(page);
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
await captureScreen(page, testInfo, "switcher-three-tabs");
await page.getByText("Existing chat", { exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
await openTabSwitcher(page);
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.click({ position: { x: 10, y: 10 } });
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await captureScreen(page, testInfo, "returned-chat-header");
} finally {
await session.cleanup();
}
});
@@ -0,0 +1,15 @@
# Native compact workspace scripts flow
`native-compact-workspace-scripts.android.ad` covers live script status in the compact ⋯ → Scripts sheet when nothing else keeps the host directory live. Start on a phone with a workspace open, the sidebar closed, and no agent tab selected. A viewed agent tab or the open sidebar hides the failure, because either one keeps the directory subscribed.
The workspace's `paseo.json` needs a long-running plain script, stopped before the run. `SCRIPT` defaults to `worker`:
```json
{ "scripts": { "worker": { "command": "node -e \"setInterval(() => {}, 1000)\"" } } }
```
The flow starts the script, checks that Stop flips its page to Run, runs it again, and checks that Restart brings it back to running with no other interaction. It ends with the script stopped and the sheet dismissed, so it can be replayed.
```sh
agent-device replay packages/app/e2e/mobile/agent-device/native-compact-workspace-scripts.android.ad --platform android --serial emulator-5590 --metro-port 8137
```
@@ -0,0 +1,45 @@
context platform=android timeout=120000 retries=0
env APP_ID=sh.paseo.debug
env SCRIPT=worker
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 45000
wait stable 1000 15000
press "id=\"workspace-header-menu-trigger\""
wait "id=\"workspace-header-scripts\"" 10000
press "id=\"workspace-header-scripts\""
wait "id=\"workspace-scripts-row-${SCRIPT}\"" 10000
press "id=\"workspace-scripts-row-${SCRIPT}\""
wait stable 1000 15000
wait "id=\"workspace-header-menu-trigger\"" 10000
press "id=\"workspace-header-menu-trigger\""
wait "id=\"workspace-header-scripts\"" 10000
press "id=\"workspace-header-scripts\""
wait "text" "Running" 10000
press "id=\"workspace-scripts-row-${SCRIPT}\""
wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
press "id=\"workspace-script-${SCRIPT}-stop\""
wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
press "id=\"workspace-script-${SCRIPT}-run\""
wait stable 1000 15000
wait "id=\"workspace-header-menu-trigger\"" 10000
press "id=\"workspace-header-menu-trigger\""
wait "id=\"workspace-header-scripts\"" 10000
press "id=\"workspace-header-scripts\""
wait "text" "Running" 10000
press "id=\"workspace-scripts-row-${SCRIPT}\""
wait "id=\"workspace-script-${SCRIPT}-restart\"" 10000
press "id=\"workspace-script-${SCRIPT}-restart\""
wait stable 1000 15000
wait "id=\"workspace-header-menu-trigger\"" 10000
press "id=\"workspace-header-menu-trigger\""
wait "id=\"workspace-header-scripts\"" 10000
press "id=\"workspace-header-scripts\""
wait "text" "Running" 15000
press "id=\"workspace-scripts-row-${SCRIPT}\""
wait "id=\"workspace-script-${SCRIPT}-stop\"" 10000
press "id=\"workspace-script-${SCRIPT}-stop\""
wait "id=\"workspace-script-${SCRIPT}-run\"" 10000
back
wait stable 1000 15000
close
@@ -0,0 +1,18 @@
context platform=ios timeout=60000 retries=0
# Start with an empty composer in an idle chat or new-agent draft on an iPhone with the software keyboard enabled.
# Expand a long draft with the keyboard open; Exit fullscreen must remain reachable.
open sh.paseo.debug
wait "editable=true" 60000
fill "editable=true" "iOS fullscreen composer smoke.\n\nLine 1: Check that the composer keeps this draft when expanded and collapsed.\nLine 2: Check that the composer keeps this draft when expanded and collapsed.\nLine 3: Check that the composer keeps this draft when expanded and collapsed.\nLine 4: Check that the composer keeps this draft when expanded and collapsed.\nLine 5: Check that the composer keeps this draft when expanded and collapsed.\nLine 6: Check that the composer keeps this draft when expanded and collapsed.\nLine 7: Check that the composer keeps this draft when expanded and collapsed.\nLine 8: Check that the composer keeps this draft when expanded and collapsed.\nLine 9: Check that the composer keeps this draft when expanded and collapsed.\nLine 10: Check that the composer keeps this draft when expanded and collapsed.\nLine 11: Check that the composer keeps this draft when expanded and collapsed.\nLine 12: Check that the composer keeps this draft when expanded and collapsed.\nLine 13: Check that the composer keeps this draft when expanded and collapsed.\nLine 14: Check that the composer keeps this draft when expanded and collapsed."
press "label=\"Fullscreen\""
wait "label=\"Exit fullscreen\" role=button" 5000
is visible "label=\"Exit fullscreen\" role=button"
press "label=\"Exit fullscreen\" role=button"
wait "label=\"Fullscreen\" role=button" 5000
is visible "label=\"Fullscreen\" role=button"
press "label=\"Fullscreen\""
wait "label=\"Exit fullscreen\" role=button" 5000
is visible "label=\"Exit fullscreen\" role=button"
press "label=\"Exit fullscreen\" role=button"
wait "label=\"Fullscreen\" role=button" 5000
is visible "label=\"Fullscreen\" role=button"
@@ -0,0 +1,7 @@
# Native question option preview
`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible.
```sh
agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554
```
@@ -0,0 +1,10 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "label=\"rollout\"" 20000
press "label=\"rollout\""
wait "id=\"question-form-option-preview\"" 10000
gesture pan 570 1120 0 -440 600
is visible "text=\"// LAST PREVIEW LINE\""
close
@@ -0,0 +1,13 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
# A released drag must not swallow the next tap: navigation closes the sidebar.
wait "label=\"Open menu\"" 5000
close
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-surface\"" 10000
press "id=\"terminal-surface\""
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
@@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen
ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host_scenario chat
ad keyboard dismiss || true
ad press 'id="workspace-header-menu-trigger"' --settle
ad wait 'id="workspace-header-new-agent"' 10000
ad press 'id="workspace-header-new-agent"' --settle
ad press 'id="workspace-tab-switcher-trigger"' --settle
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
ad press 'id="workspace-tab-switcher-new-agent"' --settle
ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
ad keyboard dismiss || true
@@ -1,26 +0,0 @@
# Native iOS text geometry regression
Run on the Namespace Mac with a fresh simulator development build. Use the PID
returned by `xcrun simctl launch <your-task-simulator-UDID> sh.paseo.debug`.
Attach only to your task's app, while no device replay is running:
```sh
PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
bash packages/app/e2e/mobile/native-text-reflow/ios.sh
```
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
and resizes its content container from 500 to 240 and back to 500 points. It
asserts the inner frame follows both widths and the same selection survives.
It uses public UIView/UITextView operations, without mocking React Native.
LLDB detaches after the probe. An assertion failure or evaluation error fails the
shell command; the log distinguishes them.
This covers native container resizing and selection retention. It does not cover
Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
menu. Those require the real chat journey: read an older multiline response,
open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
retain expanded/collapsed details, and check a stationary reader's position.
Accessibility exposes full text even when glyphs are clipped, so a text wait
alone does not verify that journey.
@@ -1,55 +0,0 @@
import lldb
def __lldb_init_module(debugger, _internal_dict):
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
options = lldb.SBExpressionOptions()
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
result = frame.EvaluateExpression(
r'''({
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
auto hostFrame = host.frame;
hostFrame.size.width = 500;
hostFrame.size.height = 200;
host.frame = hostFrame;
UIView *container = (UIView *)[host performSelector:@selector(contentView)];
container.frame = host.bounds;
UITextView *text = nil;
for (UIView *child in host.subviews) {
if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
for (UIView *nested in child.subviews) {
if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
}
}
text.frame = container.bounds;
text.font = [UIFont systemFontOfSize:16];
text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
auto range = text.selectedRange;
range.location = 12;
range.length = 9;
text.selectedRange = range;
auto frame = container.frame;
frame.size.width = 240;
container.frame = frame;
[container layoutIfNeeded];
CGFloat narrow = text.frame.size.width;
auto selection = text.selectedRange;
frame.size.width = 500;
container.frame = frame;
[container layoutIfNeeded];
CGFloat wide = text.frame.size.width;
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
selection.location == text.selectedRange.location &&
selection.length == text.selectedRange.length &&
selection.location == 12 && selection.length == 9;
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide,
(unsigned long)text.selectedRange.location,
(unsigned long)text.selectedRange.length];
})''',
options,
)
if result.GetError().Fail():
raise RuntimeError(result.GetError().GetCString())
print(result.GetObjectDescription())
@@ -1,18 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
echo "Expected a running PaseoDebug simulator app" >&2
exit 1
fi
probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
-o "command script import $probe_dir/ios.py" \
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
@@ -0,0 +1,43 @@
// Start on Settings > Host > Pair device with relay enabled in the Android app.
// node packages/app/e2e/mobile/pair-device/android.mjs <agent-device-session>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session] = process.argv;
assert(session, "Pass the agent-device session for the connected Android app");
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 60_000,
});
}
ad(
"press",
'label="Pair a device, Scan a QR code or copy a link to connect your phone to this host"',
"--settle",
);
ad("wait", "text", "Pairing QR code", "10000");
const { data } = JSON.parse(ad("snapshot", "-i", "--json"));
const sheet = data.nodes.find((node) => node.label === "Bottom Sheet");
const qr = data.nodes.find((node) => node.label === "Pairing QR code");
assert(sheet?.rect, "Pairing sheet must be visible");
assert(qr?.rect, "Pairing QR code must be visible");
assert(qr.rect.x > sheet.rect.x, "QR must have an unclipped left margin inside the sheet");
assert(
qr.rect.x + qr.rect.width < sheet.rect.x + sheet.rect.width,
"QR must have an unclipped right margin inside the sheet",
);
assert(Math.abs(qr.rect.width - qr.rect.height) <= 1, "Complete QR must remain square");
assert(qr.rect.y >= sheet.rect.y, "QR top must be visible inside the sheet");
assert(
qr.rect.y + qr.rect.height <= sheet.rect.y + sheet.rect.height,
"QR bottom must be visible inside the sheet",
);
assert(
data.nodes.some((node) => node.label === "Copy" && node.enabled),
"Copy must remain available",
);
console.log("PASS: complete square QR fits inside the pairing sheet", qr.rect);
ad("press", 'label="Close"', "--settle");
@@ -0,0 +1,62 @@
// Open Settings > Appearance in a native Android app loaded from this checkout.
// AGENT_DEVICE_STATE_DIR=<state-dir> node packages/app/e2e/mobile/system-bars/android.mjs <session> <adb-serial> <app-id>
// Requires English UI and a dedicated emulator. Leaves System theme and light OS appearance.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, serial, appId] = process.argv;
assert(session && serial && appId, "Pass the agent-device session, adb serial, and app ID");
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 60_000,
});
}
function windowState() {
const state = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
assert(state.match(/mCurrentFocus=.*\n/)?.[0].includes(`${appId}/`), "App must be focused");
const regions = state.match(/mLastStatusBarAppearanceRegions=\s*\n([^\n]+)/)?.[1];
assert(regions, "Android must report the status-bar appearance region");
return regions.includes("LIGHT_STATUS_BARS");
}
async function expectDarkIcons(expected, context) {
const deadline = Date.now() + 5_000;
do {
if (windowState() === expected) {
console.log(`PASS: ${context}`);
return;
}
await delay(100);
} while (Date.now() < deadline);
assert.equal(windowState(), expected, context);
}
function selectTheme(theme) {
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
assert(current, "Start on Settings > Appearance");
ad("press", `label="Theme: ${current}"`, "--settle");
ad("press", `label="${theme}"`, "--settle");
ad("wait", "text", `Theme: ${theme}`, "5000");
}
ad("settings", "appearance", "light");
selectTheme("Light");
await expectDarkIcons(true, "Light app theme uses dark status icons");
selectTheme("Dark");
await expectDarkIcons(false, "Dark app theme uses light status icons");
selectTheme("System");
await expectDarkIcons(true, "System theme starts with dark icons on light OS appearance");
ad("settings", "appearance", "dark");
await expectDarkIcons(
false,
"System theme follows an OS change to dark without reopening settings",
);
ad("settings", "appearance", "light");
await expectDarkIcons(true, "System theme follows an OS change back to light");
@@ -13,6 +13,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
promptRejection: void;
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
@@ -65,6 +66,37 @@ export default function contribute(server) {
await client.close();
}
},
promptRejection: async ({ page }, provide) => {
void page;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const previous = await client.getDaemonConfig();
try {
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
export default function contribute(server) {
return server.before("agent.create", ({ request }) => ({
...request,
config: {
...request.config,
model: "e2e-fast-stream",
featureValues: { ...request.config.featureValues, mockPromptRejections: 1 },
},
}));
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await provide();
} finally {
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
await client.close();
}
},
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
@@ -52,13 +52,11 @@ export async function expectAcpMarkdown(page: Page, count: number): Promise<void
await expect(messages).toHaveCount(count);
for (let index = 0; index < count; index++) {
const message = messages.nth(index);
await expect(message).toHaveText("•Current temperature: 25°C. Read Paseo docs.");
await expect(message).toHaveText("Current temperature: 25°C. Read Paseo docs.");
await expect(
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
).toHaveAttribute("href", "https://example.com/docs");
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveText(
"Current temperature",
);
await expect(message.locator("strong")).toHaveText("Current temperature");
}
}
@@ -90,3 +90,26 @@ export async function expectNewWorkspaceForAddedProject(
{ timeout: 30_000 },
);
}
export async function navigateAddProjectResults(
page: Page,
key: "ArrowDown" | "ArrowUp",
count: number,
): Promise<void> {
for (let index = 0; index < count; index += 1) await page.keyboard.press(key);
}
export async function expectAddProjectResultVisible(page: Page, text: string): Promise<void> {
const results = page.getByTestId("add-project-flow-results");
const row = results.getByRole("button").filter({ has: page.getByText(text, { exact: true }) });
await expect
.poll(async () => {
const viewport = await results.boundingBox();
const bounds = await row.boundingBox();
if (!viewport || !bounds) return false;
return (
bounds.y >= viewport.y - 1 && bounds.y + bounds.height <= viewport.y + viewport.height + 1
);
})
.toBe(true);
}
@@ -6,6 +6,8 @@ type WebSocketMessage = string | Buffer;
interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
text: unknown;
attachments: unknown;
agentId: string;
/** Absent when the client sends into an idle agent. */
activeTurnBehavior?: string;
@@ -29,6 +31,8 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n
return {
type: "send_agent_message_request",
requestId: request.requestId,
text: request.text,
attachments: request.attachments,
agentId: request.agentId,
activeTurnBehavior:
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
@@ -74,6 +78,7 @@ export async function gateNextAgentMessage(page: Page) {
return {
waitForRequest,
requestCount: () => requests.length,
accept(index = 0) {
const heldMessage = heldMessages[index];
if (!serverSocket || !heldMessage) {
@@ -575,20 +575,25 @@ export async function holdAssistantStream(page: Page, agentId: string) {
});
});
const forwardUntil = async (done: (text: string) => boolean): Promise<void> => {
while (!done(forwardedText)) {
const frame = pending.shift();
if (!frame) {
await new Promise<void>((resolve) => {
notifyFrame = resolve;
});
continue;
}
forwardedText += frame.text;
frame.forward();
}
};
return {
waitForInitialTimeline: () => initialTimeline,
showUntil: (text: string) => forwardUntil((forwarded) => forwarded.includes(text)),
async showThrough(prefix: string): Promise<void> {
while (forwardedText.length < prefix.length) {
const frame = pending.shift();
if (!frame) {
await new Promise<void>((resolve) => {
notifyFrame = resolve;
});
continue;
}
forwardedText += frame.text;
frame.forward();
}
await forwardUntil((text) => text.length >= prefix.length);
if (forwardedText !== prefix) {
throw new Error(
`Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`,
@@ -26,16 +26,13 @@ export interface AssistantImageFixture {
export async function createSmallAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
input: { alt: string; fileName: string; width?: number; height?: number },
): Promise<AssistantImageFixture> {
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
return {
alt: input.alt,
height: 1,
relativePath: input.fileName,
size: SMALL_PNG.byteLength,
width: 1,
};
const width = input.width ?? 1;
const height = input.height ?? 1;
const bytes = width === 1 && height === 1 ? SMALL_PNG : encodeDeterministicPng(width, height);
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
return { alt: input.alt, height, relativePath: input.fileName, size: bytes.byteLength, width };
}
export async function createNearTenMegabyteAssistantPng(
@@ -61,6 +58,7 @@ export async function createNearTenMegabyteAssistantPng(
export async function createSettledMockAgent(
workspace: SeededWorkspace,
title: string,
markdown?: string,
): Promise<ArchiveTabAgent> {
const agent = await workspace.client.createAgent({
provider: "mock",
@@ -69,6 +67,7 @@ export async function createSettledMockAgent(
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
featureValues: markdown === undefined ? undefined : { mockAssistantResponse: markdown },
});
await workspace.client.waitForAgentUpsert(
agent.id,
@@ -87,10 +86,11 @@ export async function emitSettledAssistantImage(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
image: AssistantImageFixture,
destination: string = image.relativePath,
): Promise<void> {
await client.sendAgentMessage(
agent.id,
`Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`,
`Emit settled assistant image Markdown: ![${image.alt}](${destination})`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
@@ -123,7 +123,7 @@ export async function expectAssistantImageRendered(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt }).first();
const rendered = page.getByRole("button", { name: image.alt }).first();
await expect(rendered).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () =>
@@ -171,14 +171,14 @@ export async function expectAssistantImageNotMounted(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
await expect(page.getByRole("button", { name: image.alt })).toHaveCount(0);
}
export async function userPagesUntilAssistantImageRenders(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt });
const rendered = page.getByRole("button", { name: image.alt });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await rendered.count()) > 0) {
await expectAssistantImageRendered(page, image);
@@ -252,8 +252,8 @@ async function beginVisibleImageStabilityObservation(
);
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
if (imageTab?.getAttribute("aria-selected") !== "true") return;
const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
(element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
const imageVisible = Array.from(document.querySelectorAll("img[alt]")).some(
(element) => element.getAttribute("alt") === accessibleName && isVisible(element),
);
record.missingSeen ||= !imageVisible;
};
@@ -98,7 +98,6 @@ async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, i
settings: { modeId: "full-access", thinkingOptionId: "low" },
title: "Codex worktree restore reproduction",
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
background: true,
}),
);
agentId = agent.agentId;
@@ -203,7 +203,8 @@ export async function expectOneLineTruncatedFileResult(
expect(lineBox).not.toBeNull();
expect(filenameBox).not.toBeNull();
expect(pathBox).not.toBeNull();
expect(rowBox?.height).toBe(36);
// COMMAND_CENTER_LIST_METRICS.desktop.singleLineRow (list-metrics imports react-native).
expect(rowBox?.height).toBe(32);
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
expect(overflow.overflow).toBe("hidden");
@@ -0,0 +1,61 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** The compact ⋯ sheet. Its trigger shares "Workspace actions" with the sidebar row kebabs. */
export function compactWorkspaceMenu(page: Page): Locator {
return page.getByTestId("workspace-header-menu-content");
}
export async function openCompactWorkspaceMenu(page: Page): Promise<void> {
await page.getByTestId("workspace-header-menu-trigger").click();
await expect(compactWorkspaceMenu(page)).toBeVisible({ timeout: 10_000 });
}
export function menuRow(page: Page, name: string | RegExp): Locator {
return compactWorkspaceMenu(page).getByRole("menuitem", { name });
}
/** The root row that leads to the Scripts page; its accessible name carries its value. */
export function scriptsMenuRow(page: Page): Locator {
return menuRow(page, /^Scripts/);
}
/** A row on the Scripts page. A running script's name is followed by "Running". */
export function scriptRow(page: Page, scriptName: string): Locator {
return menuRow(page, new RegExp(`^${scriptName}`));
}
export async function openScriptsPage(page: Page): Promise<void> {
await openCompactWorkspaceMenu(page);
await scriptsMenuRow(page).click();
await expect(scriptsMenuRow(page)).toHaveCount(0);
await expect(backButton(page)).toBeVisible();
}
export async function openRunningScriptPage(page: Page, scriptName: string): Promise<void> {
await openScriptsPage(page);
await scriptRow(page, scriptName).click();
await expect(menuRow(page, "Stop")).toBeVisible();
}
/** Taps a stopped script on the Scripts page, which runs it and closes the sheet. */
export async function runScriptFromScriptsPage(page: Page, scriptName: string): Promise<void> {
await openScriptsPage(page);
await scriptRow(page, scriptName).click();
await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
}
export async function goBackInMenu(page: Page): Promise<void> {
await backButton(page).click();
}
export async function closeCompactWorkspaceMenu(page: Page): Promise<void> {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.first()
.click({ position: { x: 12, y: 12 } });
await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
}
function backButton(page: Page): Locator {
return compactWorkspaceMenu(page).getByRole("button", { name: "Back" });
}
@@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise<void> {
await expect(composerInput(page)).not.toBeFocused();
}
/** Blur the editor without anything else taking focus. */
export async function blurComposerInput(page: Page): Promise<void> {
await composerInput(page).blur();
await expectComposerNotFocused(page);
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -316,3 +322,182 @@ export async function openGithubWorkspace(
},
};
}
const COMPOSER_TEST_IMAGE_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
export async function attachComposerTestImage(page: Page): Promise<void> {
await attachImageFromMenu(page, {
name: "fullscreen.png",
mimeType: "image/png",
buffer: Buffer.from(COMPOSER_TEST_IMAGE_BASE64, "base64"),
});
}
export async function pasteComposerTestImage(page: Page): Promise<boolean> {
return composerInput(page).evaluate((input, base64) => {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
input.dispatchEvent(event);
return event.defaultPrevented;
}, COMPOSER_TEST_IMAGE_BASE64);
}
export async function enterComposerFullscreen(page: Page): Promise<void> {
await page.getByRole("button", { name: "Fullscreen", exact: true }).click();
}
export async function exitComposerFullscreen(page: Page): Promise<void> {
await page.getByRole("button", { name: "Exit fullscreen", exact: true }).click();
}
export async function resizeComposerViewport(
page: Page,
orientation: "portrait" | "landscape" | "short",
): Promise<void> {
const sizes = {
portrait: { width: 390, height: 844 },
landscape: { width: 844, height: 390 },
short: { width: 390, height: 390 },
};
await page.setViewportSize(sizes[orientation]);
// Before the app has loaded there is no layout to wait for.
if (page.url() === "about:blank") return;
// Crossing the compact breakpoint remounts the shell and the editor. Anything that
// follows (a shortcut, a locator) belongs to the layout the new size implies, so
// wait for that layout before returning.
await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
}
export async function selectComposerDraftRange(
page: Page,
start: number,
end: number,
): Promise<void> {
await composerInput(page).focus();
await composerInput(page).evaluate(
(element, selection) => {
const input = element as HTMLTextAreaElement;
input.setSelectionRange(selection.start, selection.end);
input.dispatchEvent(new Event("select", { bubbles: true }));
},
{ start, end },
);
}
export async function expectComposerDraftSelection(
page: Page,
start: number,
end: number,
): Promise<void> {
await expect
.poll(() =>
composerInput(page).evaluate((element) => {
const input = element as HTMLTextAreaElement;
return [input.selectionStart, input.selectionEnd];
}),
)
.toEqual([start, end]);
}
export async function expectComposerFullscreenControls(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
await expect(composerInput(page)).toHaveCount(1);
}
/** The fullscreen frame and its submit button sit inside the browser's visible viewport. */
export async function expectComposerFullscreenFitsVisibleViewport(
page: Page,
submitLabel = "Send message",
): Promise<void> {
await expect
.poll(() =>
page.getByTestId("composer-fullscreen").evaluate((element, label) => {
const viewport = window.visualViewport!;
const frame = element.getBoundingClientRect();
const send = element.querySelector(`[aria-label="${label}"]`)!.getBoundingClientRect();
return {
frameFits:
Math.abs(frame.top - viewport.offsetTop) < 1 &&
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
Math.abs(frame.height - viewport.height) < 1 &&
Math.abs(frame.width - viewport.width) < 1,
sendFits:
send.top >= viewport.offsetTop &&
send.bottom <= viewport.offsetTop + viewport.height &&
send.left >= viewport.offsetLeft &&
send.right <= viewport.offsetLeft + viewport.width,
};
}, submitLabel),
)
.toEqual({ frameFits: true, sendFits: true });
}
export async function sendComposerWithButton(page: Page): Promise<void> {
await page.getByRole("button", { name: "Send message", exact: true }).click();
}
export async function queueComposerWithButton(page: Page): Promise<void> {
await page.getByRole("button", { name: "Queue message", exact: true }).click();
}
export async function configureComposerQueueSend(page: Page): Promise<void> {
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ sendBehavior: "queue" })),
);
}
/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface.
* This exercises retained activity changes, not a claim that covered chrome is clickable.
*/
/** Needs a desktop-width viewport: compact layouts have no keyboard shortcuts. */
export async function switchComposerTabWithKeyboard(
page: Page,
direction: "previous" | "next",
): Promise<void> {
await page.keyboard.press(
direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight",
);
}
export async function expectWorkspaceHeaderHidden(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0);
}
export async function expectWorkspaceHeaderVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
}
export async function activateRetainedComposerTab(page: Page, title: string): Promise<void> {
await page
.getByTestId("workspace-tab-switcher-trigger")
.evaluate((element) => (element as HTMLElement).click());
const option = page.getByText(title, { exact: true }).filter({ visible: true }).last();
await expect(option).toBeVisible();
await option.evaluate((element) => (element as HTMLElement).click());
}
export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
await page.evaluate(() => {
const frames: { input: number; root: number }[] = [];
Reflect.set(window, "__composerGeometryFrames", frames);
let running = true;
const tick = () => {
if (!running) return;
for (const root of document.querySelectorAll('[data-testid="message-input-root"]')) {
if (root.getClientRects().length === 0) continue;
const input = root.querySelector("textarea");
if (input)
frames.push({
input: input.getBoundingClientRect().height,
root: root.getBoundingClientRect().height,
});
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
Reflect.set(window, "__stopComposerGeometry", () => {
running = false;
});
});
}
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
const frames = await page.evaluate(() => {
Reflect.get(window, "__stopComposerGeometry")?.();
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
});
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
}
@@ -182,6 +182,47 @@ export async function createCreationScenario(page: Page) {
await this.expectWorkspaceReadyBeforeAgentCompletion();
expect((await project.client.fetchAgents()).entries).toHaveLength(0);
},
async expectCreatedAgentError() {
await requests.settled();
await expect(
page
.getByTestId("assistant-message")
.filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
await expect(
page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }),
).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEditable();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
},
async expectPromptBeforeError(prompt: string) {
const messages = page
.getByTestId("user-message")
.or(page.getByTestId("assistant-message"))
.filter({ visible: true });
await expect(messages).toHaveCount(2);
await expect(messages.nth(0)).toContainText(prompt);
await expect(messages.nth(1)).toContainText("[System Error]");
await expect(messages.nth(1)).toBeInViewport();
},
async reloadAgent() {
await page.reload();
await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
await this.expectCreatedAgentError();
},
async expectAssistantReply() {
await expect(
page
.getByTestId("assistant-message")
.filter({ visible: true, hasNotText: "[System Error]" })
.first(),
).toBeVisible();
},
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
},
@@ -0,0 +1,37 @@
import { expect, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { openSettingsHostSection } from "./settings";
import { getServerId } from "./server-id";
export async function openCustomModels(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, getServerId(), "providers");
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
}
export async function addCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("button", { name: "Add model", exact: true }).click();
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
await page
.getByTestId("add-custom-model-sheet")
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
await expect(remove).toBeInViewport({ ratio: 1 });
await remove.click();
await expect(remove).toHaveCount(0);
}
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
ratio: 1,
});
}
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { expect, type Download, type Page } from "@playwright/test";
import { openFilesPanel } from "./workspace-tabs";
function fileExplorerTree(page: Page) {
@@ -39,3 +39,10 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
timeout: 30_000,
});
}
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
await fileExplorerEntry(page, fileName).click({ button: "right" });
const downloaded = page.waitForEvent("download");
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
return downloaded;
}
@@ -80,13 +80,6 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** Assert the inline plus button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
).toBeVisible();
}
/** Assert the New tab button is visible in the tab bar. */
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
await expect(
@@ -132,13 +125,6 @@ export async function waitForTabWithTitle(
).toBeVisible({ timeout });
}
/** Assert the inline plus button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
// ─── No-flash measurement ──────────────────────────────────────────────────
/**
@@ -220,3 +220,24 @@ export function expectDaemonPidUnchanged(
expect(before).toBeDefined();
expect(after).toBe(before);
}
export async function expectCompletePairingQr(page: Page): Promise<void> {
const qr = page.getByRole("img", { name: "Pairing QR code" });
await qr.scrollIntoViewIfNeeded();
// IntersectionObserver rounds fractional sheet/scroll coordinates.
await expect(qr).toBeInViewport({ ratio: 0.999 });
const bounds = await qr.evaluate((element) => {
const tile = element.parentElement;
if (!tile) throw new Error("Pairing QR tile is missing");
const qrRect = element.getBoundingClientRect();
const tileRect = tile.getBoundingClientRect();
return {
qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom },
tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom },
};
});
expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x);
expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y);
expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right);
expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom);
}
@@ -30,7 +30,6 @@ async function capture(
name: string,
subject: Locator = headerButtons(page),
) {
await expect(headerButtons(page)).toHaveCount(1);
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = info.outputPath(`${name}.png`);
@@ -80,6 +79,7 @@ export async function withButtonAuthorExample(
refreshFromHeaderAndComposer: () =>
test.step("icon-only header action and labeled composer action update in place", async () => {
await exampleCommand(page, "action");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
@@ -88,7 +88,13 @@ export async function withButtonAuthorExample(
).toContainText("Refreshed · 2");
await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT);
await capture(page, info, "02-compact-actions");
await expect(headerButtons(page)).toHaveCount(0);
await capture(
page,
info,
"02-compact-actions",
page.getByRole("button", { name: "Refresh context", exact: true }),
);
}),
useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
@@ -128,7 +134,8 @@ export async function withButtonAuthorExample(
await capture(page, info, "05-disabled");
await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT);
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveCount(0);
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport();
@@ -143,6 +150,7 @@ export async function withButtonAuthorExample(
inspectLiveWorkspaceDetails: () =>
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
await exampleCommand(page, "popover");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
@@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) {
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
}
async function holdButton(page: Page, button: Locator) {
await button.hover();
await page.mouse.down();
}
async function openCompactOverflowWithHeaderChrome(page: Page) {
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
const action = page.getByRole("button", { name: "Deploy application", exact: true });
const overflow = page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "More actions", exact: true });
await expect(action).toHaveCSS("border-top-width", "0px");
await expect(overflow).toHaveCSS("border-top-width", "0px");
await hamburger.hover();
const highlight = await hamburger.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await action.hover();
await expect(action).toHaveCSS("background-color", highlight);
await holdButton(page, overflow);
await expect(overflow).toHaveCSS("background-color", highlight);
await expectSurfaceVisible(page);
await page.mouse.up();
}
export async function withButtonShowcase(
page: Page,
run: (buttons: {
@@ -408,7 +383,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -426,39 +401,12 @@ export async function withButtonShowcase(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
await expectSurfaceVisible(page);
await openCompactOverflowWithHeaderChrome(page);
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Header checks");
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await expectSurfaceVisible(
page,
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await showcase.setTitle("Compact checks");
await press(page, "Header checks");
await expect(
page
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
).toHaveCount(0);
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
await expect(
page.getByRole("button", { name: "Open menu", exact: true }),
).toBeInViewport();
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
@@ -47,6 +47,65 @@ export default function contribute(client) {
return () => {};
}`;
const PROMPT_PLUGIN_ID = "timeline-prompt-e2e";
function promptClientSource(prompts?: readonly string[]): string {
return `import React from "react";
import { Text, View } from "react-native";
import { z } from "zod";
const selectedPrompts = ${JSON.stringify(prompts ?? null)};
function PromptCard({ item }) {
return <View testID="plugin-prompt"><Text>{item.data.text}</Text></View>;
}
export default function contribute(client) {
client.addTimelineTransformer({
id: "prompt",
query: { itemType: "user_message" },
transform({ item }) {
if (selectedPrompts && !selectedPrompts.includes(item.text)) return undefined;
return { items: [{ type: "plugin", kind: "prompt", version: 1, data: { text: item.text } }] };
},
});
client.addTimelineRenderer({
kind: "prompt", version: 1,
schema: z.object({ text: z.string() }),
Component: PromptCard,
});
return () => {};
}`;
}
async function withDirectoryPlugin(options: {
pluginId: string;
source: string;
run: () => Promise<void>;
}): Promise<void> {
const { pluginId, source, run } = options;
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: pluginId, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), source);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await run();
} finally {
await pluginClient.removePlugin(pluginId);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await rm(directory, { recursive: true, force: true });
}
}
export async function withTimelinePlugin(
page: Page,
info: TestInfo,
@@ -60,7 +119,6 @@ export async function withTimelinePlugin(
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-plugin-",
title: "Timeline plugin regression",
@@ -73,40 +131,53 @@ export async function withTimelinePlugin(
}
: {},
});
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
await withDirectoryPlugin({
pluginId: PLUGIN_ID,
source: CLIENT_SOURCE,
run: async () => {
try {
await openAgentRoute(page, agent);
await expect(
page.getByRole("textbox", { name: "Message agent..." }).first(),
).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
}
},
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
} finally {
await pluginClient.removePlugin(PLUGIN_ID);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await agent.cleanup();
await rm(directory, { recursive: true, force: true });
}
}
/** Draws all prompts, or only the selected prompts, as plugin cards. */
export async function withPromptTimelinePlugin(
run: () => Promise<void>,
prompts?: readonly string[],
): Promise<void> {
await withDirectoryPlugin({
pluginId: PROMPT_PLUGIN_ID,
source: promptClientSource(prompts),
run,
});
}
export async function expectPromptsDrawnByPlugin(page: Page): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByTestId("plugin-prompt").first()).toBeVisible({ timeout: 30_000 });
await expect(timeline.getByTestId("user-message")).toHaveCount(0);
}
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
}
@@ -142,3 +213,64 @@ export async function expectBothConsecutiveTools(page: Page): Promise<void> {
await grep.scrollIntoViewIfNeeded();
await expect(grep).toBeVisible();
}
/** The row belongs to the virtualized history, not the always-mounted recent window. */
export async function expectVirtualizedPluginPromptVisible(
page: Page,
prompt: string,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(
timeline.locator("[data-index]").getByTestId("plugin-prompt").filter({ hasText: prompt }),
).toBeVisible();
}
export async function attachPromptTimelineEvidence(page: Page, info: TestInfo): Promise<void> {
await info.attach("plugin-outline-jump", {
body: await page.screenshot({ path: info.outputPath("plugin-outline-jump.png") }),
contentType: "image/png",
});
}
const POPOVER_CLIENT_SOURCE = `import React, { useEffect, useRef } from "react";
import { z } from "zod";
function Preview() {
const panel = useRef(null);
useEffect(() => {
const close = (event) => {
if (event.key === "Escape" && panel.current.matches(":popover-open")) {
panel.current.hidePopover();
}
};
document.addEventListener("keydown", close, true);
return () => document.removeEventListener("keydown", close, true);
}, []);
return <div>
<button onMouseEnter={() => panel.current.showPopover()}>Hover plugin preview</button>
<div ref={panel} popover="manual" role="dialog" aria-label="Plugin preview">Plugin hover preview</div>
</div>;
}
export default function contribute(client) {
client.addTimelineTransformer({
id: "preview", query: { itemType: "user_message" },
transform({ item }) {
if (item.text !== "Show the plugin preview.") return;
return { items: [{ type: "plugin", kind: "preview", version: 1, data: {} }] };
},
});
client.addTimelineRenderer({
kind: "preview", version: 1, schema: z.object({}), Component: Preview,
});
return () => {};
}`;
/** Installs a timeline preview that owns Escape through the DOM Popover API. */
export async function withPopoverTimelinePlugin(run: () => Promise<void>): Promise<void> {
await withDirectoryPlugin({
pluginId: "timeline-popover-e2e",
source: POPOVER_CLIENT_SOURCE,
run,
});
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/git/pull-request-panel/data";
import { en } from "@/i18n/resources/en";
import { openPullRequestPanel } from "./workspace-tabs";
export async function openPrPane(page: Page): Promise<void> {
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
page: Page,
state: "open" | "merged" | "closed" | "draft",
): Promise<void> {
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
timeout: 15_000,
});
}
@@ -134,3 +134,19 @@ export async function expectProjectSettingsName(page: Page, projectName: string)
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectWorkspace(
page: Page,
projectName: string,
workspaceName: string,
): Promise<void> {
await projectGroup(page, projectName)
.getByRole("button")
.filter({ hasText: workspaceName })
.click();
await expect(page).toHaveURL(/\/workspace\//);
}
export async function expectWorkspaceHost(page: Page, hostName: string): Promise<void> {
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
}
@@ -43,6 +43,15 @@ export async function expectQuestionOptionSelected(page: Page, option: string):
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
}
export async function expectQuestionOptionPreview(
page: Page,
input: { option: string; preview: string },
): Promise<void> {
await expect(
questionOption(page, input.option).getByTestId("question-form-option-preview"),
).toContainText(input.preview);
}
export async function openQuestion(
page: Page,
input: { index: number; total: number },

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