perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add browser smoke runner and local feed fallbacks

alice 218f4909 111e3e72

+834 -16
+2
.gitignore
··· 24 24 .DS_Store 25 25 .tools/ 26 26 .vendor/ 27 + .cache/ 28 + tools/browser-automation/node_modules/
+285 -15
lib/ATProto/PDS/ServiceProxy.pm
··· 11 11 use ATProto::PDS::API::Server qw(require_auth); 12 12 use ATProto::PDS::API::Util qw(iso8601 resolve_repo xrpc_error); 13 13 use ATProto::PDS::Auth::JWT qw(encode_service_jwt); 14 + use ATProto::PDS::Moderation qw(parse_at_uri); 14 15 15 16 has settings => sub { {} }; 16 17 has ua => sub { ··· 29 30 } 30 31 if ($nsid eq 'app.bsky.actor.getProfile') { 31 32 my $status = $self->_get_local_profile($c); 33 + return $status if defined $status; 34 + } 35 + if ($nsid eq 'app.bsky.feed.getAuthorFeed') { 36 + my $status = $self->_get_author_feed($c); 37 + return $status if defined $status; 38 + } 39 + if ($nsid eq 'app.bsky.feed.getPosts') { 40 + my $status = $self->_get_posts($c); 41 + return $status if defined $status; 42 + } 43 + if ($nsid eq 'app.bsky.feed.getPostThread') { 44 + my $status = $self->_get_post_thread($c); 32 45 return $status if defined $status; 33 46 } 34 47 ··· 204 217 xrpc_error(405, 'MethodNotAllowed', 'app.bsky.actor.getProfile expects GET') 205 218 unless $c->req->method eq 'GET'; 206 219 207 - my (undef, $viewer) = require_auth($c, audience => 'access', allow_refresh => 1); 208 220 my $actor = $c->param('actor') // q(); 209 221 xrpc_error(400, 'InvalidRequest', 'actor is required') unless length $actor; 210 222 211 223 my $account = resolve_repo($c, $actor) or return undef; 212 - my $profile = $c->store->get_record($account->{did}, 'app.bsky.actor.profile', 'self'); 213 - my $value = (ref($profile) eq 'HASH' && ref($profile->{value}) eq 'HASH') ? $profile->{value} : {}; 214 - 224 + my $profile_value = $self->_profile_record_value($c, $account); 215 225 my $result = { 216 - did => $account->{did}, 217 - handle => $account->{handle}, 226 + %{ $self->_profile_view_detailed($c, $account, $profile_value) }, 218 227 associated => { 219 - lists => 0, 220 - feedgens => 0, 221 - starterPacks => 0, 222 - labeler => JSON::PP::false, 228 + lists => 0, 229 + feedgens => 0, 230 + starterPacks => 0, 231 + labeler => JSON::PP::false, 223 232 activitySubscription => { 224 233 allowSubscriptions => 'followers', 225 234 }, ··· 228 237 muted => JSON::PP::false, 229 238 blockedBy => JSON::PP::false, 230 239 }, 240 + }; 241 + 242 + $c->render(json => $result); 243 + return 200; 244 + } 245 + 246 + sub _get_author_feed ($self, $c) { 247 + xrpc_error(405, 'MethodNotAllowed', 'app.bsky.feed.getAuthorFeed expects GET') 248 + unless $c->req->method eq 'GET'; 249 + 250 + my $actor = $c->param('actor') // q(); 251 + xrpc_error(400, 'InvalidRequest', 'actor is required') unless length $actor; 252 + 253 + my $account = resolve_repo($c, $actor) or return undef; 254 + my $viewer = $self->_optional_auth_account($c); 255 + my $limit = $c->param('limit') // 50; 256 + $limit = 1 if $limit < 1; 257 + $limit = 100 if $limit > 100; 258 + 259 + my $page = $c->store->list_records( 260 + $account->{did}, 261 + 'app.bsky.feed.post', 262 + limit => $limit, 263 + cursor => $c->param('cursor'), 264 + reverse => 1, 265 + ); 266 + my $profile_value = $self->_profile_record_value($c, $account); 267 + my @feed = map { 268 + +{ 269 + post => $self->_post_view($c, $account, $_, $profile_value, $viewer), 270 + } 271 + } @{ $page->{items} }; 272 + 273 + my %body = (feed => \@feed); 274 + $body{cursor} = $page->{cursor} if defined $page->{cursor}; 275 + $c->render(json => \%body); 276 + return 200; 277 + } 278 + 279 + sub _get_posts ($self, $c) { 280 + xrpc_error(405, 'MethodNotAllowed', 'app.bsky.feed.getPosts expects GET') 281 + unless $c->req->method eq 'GET'; 282 + 283 + my @uris = grep { defined($_) && length($_) } $c->every_param('uris'); 284 + push @uris, $c->param('uris') 285 + if !@uris && defined($c->param('uris')) && length($c->param('uris')); 286 + xrpc_error(400, 'InvalidRequest', 'uris is required') unless @uris; 287 + 288 + my @resolved = map { $self->_resolve_local_post_uri($c, $_) } @uris; 289 + return undef if grep { !defined $_ } @resolved; 290 + 291 + my $viewer = $self->_optional_auth_account($c); 292 + my @posts = map { 293 + my ($account, $row) = @$_; 294 + my $profile_value = $self->_profile_record_value($c, $account); 295 + $self->_post_view($c, $account, $row, $profile_value, $viewer); 296 + } @resolved; 297 + 298 + $c->render(json => { posts => \@posts }); 299 + return 200; 300 + } 301 + 302 + sub _get_post_thread ($self, $c) { 303 + xrpc_error(405, 'MethodNotAllowed', 'app.bsky.feed.getPostThread expects GET') 304 + unless $c->req->method eq 'GET'; 305 + 306 + my $uri = $c->param('uri') // q(); 307 + xrpc_error(400, 'InvalidRequest', 'uri is required') unless length $uri; 308 + 309 + my $resolved = $self->_resolve_local_post_uri($c, $uri) or return undef; 310 + my ($account, $row) = @$resolved; 311 + my $viewer = $self->_optional_auth_account($c); 312 + my $profile_value = $self->_profile_record_value($c, $account); 313 + my $depth = $c->param('depth') // 6; 314 + $depth = 0 if $depth < 0; 315 + my $thread = $self->_thread_view($c, $account, $row, $profile_value, $viewer, $depth); 316 + 317 + $c->render(json => { thread => $thread }); 318 + return 200; 319 + } 320 + 321 + sub _optional_auth_account ($self, $c) { 322 + my $auth = $c->req->headers->authorization; 323 + return undef unless defined $auth && length $auth; 324 + my (undef, $account) = require_auth($c, audience => 'access', allow_refresh => 1); 325 + return $account; 326 + } 327 + 328 + sub _profile_record_value ($self, $c, $account) { 329 + my $profile = $c->store->get_record($account->{did}, 'app.bsky.actor.profile', 'self'); 330 + return (ref($profile) eq 'HASH' && ref($profile->{value}) eq 'HASH') ? $profile->{value} : {}; 331 + } 332 + 333 + sub _profile_view_basic ($self, $c, $account, $profile_value = undef) { 334 + $profile_value //= $self->_profile_record_value($c, $account); 335 + my $view = { 336 + did => $account->{did}, 337 + handle => $account->{handle}, 338 + }; 339 + $view->{displayName} = $profile_value->{displayName} if defined $profile_value->{displayName}; 340 + if (my $avatar_cid = $self->_blob_cid($profile_value->{avatar})) { 341 + $view->{avatar} = $self->_blob_url($c, $account->{did}, $avatar_cid); 342 + } 343 + return $view; 344 + } 345 + 346 + sub _profile_view_detailed ($self, $c, $account, $profile_value = undef) { 347 + $profile_value //= $self->_profile_record_value($c, $account); 348 + my $view = { 349 + %{ $self->_profile_view_basic($c, $account, $profile_value) }, 231 350 labels => [], 232 351 createdAt => iso8601($account->{created_at}), 233 352 followersCount => 0, 234 353 followsCount => 0, 235 - postsCount => 0 + $c->store->count_records_by_did($account->{did}), 354 + postsCount => 0 + $c->store->count_records_by_collection($account->{did}, 'app.bsky.feed.post'), 236 355 }; 356 + $view->{description} = $profile_value->{description} if defined $profile_value->{description}; 357 + if (my $banner_cid = $self->_blob_cid($profile_value->{banner})) { 358 + $view->{banner} = $self->_blob_url($c, $account->{did}, $banner_cid); 359 + } 360 + return $view; 361 + } 237 362 238 - $result->{displayName} = $value->{displayName} if defined $value->{displayName}; 239 - $result->{description} = $value->{description} if defined $value->{description}; 363 + sub _blob_cid ($self, $blob) { 364 + return undef unless ref($blob) eq 'HASH'; 365 + return $blob->{ref}{'$link'} if ref($blob->{ref}) eq 'HASH' && defined $blob->{ref}{'$link'}; 366 + return $blob->{ref} if defined $blob->{ref} && !ref($blob->{ref}); 367 + return undef; 368 + } 240 369 241 - $c->render(json => $result); 242 - return 200; 370 + sub _blob_url ($self, $c, $did, $cid) { 371 + my $base = Mojo::URL->new($self->_config('base_url', 'http://127.0.0.1:7755')); 372 + $base->path('/xrpc/com.atproto.sync.getBlob'); 373 + $base->query({ did => $did, cid => $cid }); 374 + return $base->to_string; 375 + } 376 + 377 + sub _resolve_local_post_uri ($self, $c, $uri) { 378 + my ($repo, $collection, $rkey) = parse_at_uri($uri); 379 + return undef unless defined $repo && defined $collection && defined $rkey; 380 + return undef unless $collection eq 'app.bsky.feed.post'; 381 + my $account = resolve_repo($c, $repo) or return undef; 382 + my $row = $c->store->get_record($account->{did}, $collection, $rkey) or return undef; 383 + return [ $account, $row ]; 384 + } 385 + 386 + sub _post_uri ($self, $account, $row) { 387 + return 'at://' . $account->{did} . '/' . $row->{collection} . '/' . $row->{rkey}; 388 + } 389 + 390 + sub _post_indexed_at ($self, $row) { 391 + return $row->{value}{createdAt} 392 + if ref($row->{value}) eq 'HASH' && defined $row->{value}{createdAt}; 393 + return iso8601($row->{created_at} // $row->{updated_at}); 394 + } 395 + 396 + sub _post_view ($self, $c, $account, $row, $profile_value = undef, $viewer = undef) { 397 + my $uri = $self->_post_uri($account, $row); 398 + my $counts = $self->_post_counts_and_viewer($c, $uri, $viewer); 399 + my $post = { 400 + uri => $uri, 401 + cid => $row->{cid}, 402 + author => $self->_profile_view_basic($c, $account, $profile_value), 403 + record => $row->{value}, 404 + replyCount => $counts->{replyCount}, 405 + repostCount => $counts->{repostCount}, 406 + likeCount => $counts->{likeCount}, 407 + quoteCount => $counts->{quoteCount}, 408 + indexedAt => $self->_post_indexed_at($row), 409 + labels => [], 410 + }; 411 + $post->{viewer} = $counts->{viewer} if %{ $counts->{viewer} }; 412 + return $post; 413 + } 414 + 415 + sub _thread_view ($self, $c, $account, $row, $profile_value = undef, $viewer = undef, $depth = 6) { 416 + my $thread = { 417 + '$type' => 'app.bsky.feed.defs#threadViewPost', 418 + post => $self->_post_view($c, $account, $row, $profile_value, $viewer), 419 + }; 420 + return $thread if $depth <= 0; 421 + 422 + my $uri = $self->_post_uri($account, $row); 423 + my @replies; 424 + for my $reply ($self->_reply_rows($c, $uri)) { 425 + my ($reply_account, $reply_row) = @$reply; 426 + push @replies, $self->_thread_view( 427 + $c, 428 + $reply_account, 429 + $reply_row, 430 + undef, 431 + $viewer, 432 + $depth - 1, 433 + ); 434 + } 435 + $thread->{replies} = \@replies if @replies; 436 + return $thread; 437 + } 438 + 439 + sub _reply_rows ($self, $c, $parent_uri) { 440 + my @matches; 441 + for my $account (@{ $c->store->list_accounts }) { 442 + for my $row (@{ $c->store->all_records_for_did($account->{did}) }) { 443 + next unless ($row->{collection} // q()) eq 'app.bsky.feed.post'; 444 + my $reply = (ref($row->{value}) eq 'HASH') ? $row->{value}{reply} : undef; 445 + next unless ref($reply) eq 'HASH'; 446 + next unless (($reply->{parent}{uri} // q()) eq $parent_uri); 447 + push @matches, [ $account, $row ]; 448 + } 449 + } 450 + @matches = sort { 451 + $self->_post_indexed_at($a->[1]) cmp $self->_post_indexed_at($b->[1]) 452 + } @matches; 453 + return @matches; 454 + } 455 + 456 + sub _post_counts_and_viewer ($self, $c, $post_uri, $viewer = undef) { 457 + my $cache = $c->stash('local_post_stats') // {}; 458 + return $cache->{$post_uri} if $cache->{$post_uri}; 459 + 460 + my $viewer_did = $viewer ? $viewer->{did} : undef; 461 + my $stats = { 462 + likeCount => 0, 463 + repostCount => 0, 464 + replyCount => 0, 465 + quoteCount => 0, 466 + viewer => {}, 467 + }; 468 + 469 + for my $account (@{ $c->store->list_accounts }) { 470 + for my $row (@{ $c->store->all_records_for_did($account->{did}) }) { 471 + my $value = $row->{value}; 472 + next unless ref($value) eq 'HASH'; 473 + 474 + if (($row->{collection} // q()) eq 'app.bsky.feed.like') { 475 + next unless (($value->{subject}{uri} // q()) eq $post_uri); 476 + $stats->{likeCount}++; 477 + $stats->{viewer}{like} = 'at://' . $account->{did} . '/' . $row->{collection} . '/' . $row->{rkey} 478 + if defined $viewer_did && $account->{did} eq $viewer_did; 479 + } 480 + elsif (($row->{collection} // q()) eq 'app.bsky.feed.repost') { 481 + next unless (($value->{subject}{uri} // q()) eq $post_uri); 482 + $stats->{repostCount}++; 483 + $stats->{viewer}{repost} = 'at://' . $account->{did} . '/' . $row->{collection} . '/' . $row->{rkey} 484 + if defined $viewer_did && $account->{did} eq $viewer_did; 485 + } 486 + elsif (($row->{collection} // q()) eq 'app.bsky.feed.post') { 487 + my $reply = $value->{reply}; 488 + $stats->{replyCount}++ 489 + if ref($reply) eq 'HASH' && (($reply->{parent}{uri} // q()) eq $post_uri); 490 + $stats->{quoteCount}++ 491 + if ($self->_quoted_uri($value) // q()) eq $post_uri; 492 + } 493 + } 494 + } 495 + 496 + $cache->{$post_uri} = $stats; 497 + $c->stash(local_post_stats => $cache); 498 + return $stats; 499 + } 500 + 501 + sub _quoted_uri ($self, $value) { 502 + return undef unless ref($value) eq 'HASH'; 503 + my $embed = $value->{embed}; 504 + return undef unless ref($embed) eq 'HASH'; 505 + return $embed->{record}{uri} 506 + if (($embed->{'$type'} // q()) eq 'app.bsky.embed.record') 507 + && ref($embed->{record}) eq 'HASH'; 508 + return $embed->{record}{record}{uri} 509 + if (($embed->{'$type'} // q()) eq 'app.bsky.embed.recordWithMedia') 510 + && ref($embed->{record}) eq 'HASH' 511 + && ref($embed->{record}{record}) eq 'HASH'; 512 + return undef; 243 513 } 244 514 245 515 1;
+9
lib/ATProto/PDS/Store/SQLite.pm
··· 580 580 ) // 0; 581 581 } 582 582 583 + sub count_records_by_collection ($self, $did, $collection) { 584 + return $self->dbh->selectrow_array( 585 + q{SELECT COUNT(*) FROM records WHERE did = ? AND collection = ?}, 586 + undef, 587 + $did, 588 + $collection, 589 + ) // 0; 590 + } 591 + 583 592 sub put_block ($self, %args) { 584 593 my $cid = $args{cid} // die 'cid is required'; 585 594 my $now = $args{created_at} // time;
+151
script/perlsky-browser-smoke
··· 1 + #!/usr/bin/env perl 2 + use v5.34; 3 + use warnings; 4 + 5 + use Cwd qw(abs_path); 6 + use File::Path qw(make_path); 7 + use File::Spec; 8 + use FindBin qw($Bin); 9 + use Getopt::Long qw(GetOptionsFromArray); 10 + use JSON::PP qw(encode_json); 11 + use POSIX qw(strftime); 12 + 13 + my $root = abs_path(File::Spec->catdir($Bin, '..')); 14 + my $tools_dir = File::Spec->catdir($root, 'tools', 'browser-automation'); 15 + my $cache_dir = File::Spec->catdir($root, '.cache', 'ms-playwright'); 16 + my $default_artifacts = File::Spec->catdir($root, 'data', 'browser-smoke', 'latest'); 17 + 18 + my %opt = ( 19 + app_url => $ENV{PERLSKY_BROWSER_APP_URL} || 'https://bsky.app', 20 + pds_url => $ENV{PERLSKY_BROWSER_PDS_URL} || 'https://perlsky.mosphere.at', 21 + handle => $ENV{PERLSKY_BROWSER_HANDLE}, 22 + password => $ENV{PERLSKY_BROWSER_PASSWORD}, 23 + target_handle => $ENV{PERLSKY_BROWSER_TARGET_HANDLE} || 'alice.mosphere.at', 24 + artifacts_dir => $ENV{PERLSKY_BROWSER_ARTIFACTS} || $default_artifacts, 25 + birthdate => $ENV{PERLSKY_BROWSER_BIRTHDATE} || '1990-01-01', 26 + post_text => $ENV{PERLSKY_BROWSER_POST_TEXT}, 27 + quote_text => $ENV{PERLSKY_BROWSER_QUOTE_TEXT}, 28 + reply_text => $ENV{PERLSKY_BROWSER_REPLY_TEXT}, 29 + profile_note => $ENV{PERLSKY_BROWSER_PROFILE_NOTE}, 30 + headful => $ENV{PERLSKY_BROWSER_HEADFUL} ? 1 : 0, 31 + edit_profile => $ENV{PERLSKY_BROWSER_EDIT_PROFILE} ? 1 : 0, 32 + ); 33 + 34 + my $cmd = shift(@ARGV) // 'run'; 35 + 36 + if ($cmd eq 'help' || $cmd eq '--help' || $cmd eq '-h') { 37 + print <<'USAGE'; 38 + Usage: 39 + script/perlsky-browser-smoke install 40 + script/perlsky-browser-smoke run [options] 41 + 42 + Options: 43 + --handle HANDLE 44 + --password PASSWORD 45 + --target-handle HANDLE 46 + --app-url URL 47 + --pds-url URL 48 + --artifacts-dir DIR 49 + --birthdate YYYY-MM-DD 50 + --post-text TEXT 51 + --quote-text TEXT 52 + --reply-text TEXT 53 + --profile-note TEXT 54 + --headful 55 + --edit-profile 56 + 57 + Environment: 58 + PERLSKY_BROWSER_HANDLE 59 + PERLSKY_BROWSER_PASSWORD 60 + PERLSKY_BROWSER_TARGET_HANDLE 61 + PERLSKY_BROWSER_APP_URL 62 + PERLSKY_BROWSER_PDS_URL 63 + PERLSKY_BROWSER_ARTIFACTS 64 + PERLSKY_BROWSER_BIRTHDATE 65 + PERLSKY_BROWSER_POST_TEXT 66 + PERLSKY_BROWSER_QUOTE_TEXT 67 + PERLSKY_BROWSER_REPLY_TEXT 68 + PERLSKY_BROWSER_PROFILE_NOTE 69 + PERLSKY_BROWSER_HEADFUL=1 70 + PERLSKY_BROWSER_EDIT_PROFILE=1 71 + USAGE 72 + exit 0; 73 + } 74 + 75 + GetOptionsFromArray( 76 + \@ARGV, 77 + 'handle=s' => \$opt{handle}, 78 + 'password=s' => \$opt{password}, 79 + 'target-handle=s' => \$opt{target_handle}, 80 + 'app-url=s' => \$opt{app_url}, 81 + 'pds-url=s' => \$opt{pds_url}, 82 + 'artifacts-dir=s' => \$opt{artifacts_dir}, 83 + 'birthdate=s' => \$opt{birthdate}, 84 + 'post-text=s' => \$opt{post_text}, 85 + 'quote-text=s' => \$opt{quote_text}, 86 + 'reply-text=s' => \$opt{reply_text}, 87 + 'profile-note=s' => \$opt{profile_note}, 88 + 'headful!' => \$opt{headful}, 89 + 'edit-profile!' => \$opt{edit_profile}, 90 + ) or die "invalid options\n"; 91 + 92 + if ($cmd eq 'install') { 93 + install_runtime(); 94 + exit 0; 95 + } 96 + 97 + die "--handle is required\n" unless defined $opt{handle} && length $opt{handle}; 98 + die "--password is required\n" unless defined $opt{password} && length $opt{password}; 99 + 100 + install_runtime(); 101 + 102 + my $run_id = strftime('%Y%m%d-%H%M%S', gmtime); 103 + my $artifacts_dir = $opt{artifacts_dir}; 104 + make_path($artifacts_dir); 105 + 106 + $opt{post_text} //= "perlsky browser smoke post $run_id"; 107 + $opt{quote_text} //= "perlsky browser smoke quote $run_id"; 108 + $opt{reply_text} //= "perlsky browser smoke reply $run_id"; 109 + $opt{profile_note} //= "perlsky browser smoke profile edit $run_id"; 110 + 111 + my ($pds_host) = $opt{pds_url} =~ m{\Ahttps?://([^/]+)}; 112 + $pds_host //= $opt{pds_url}; 113 + 114 + my $config = { 115 + appUrl => $opt{app_url}, 116 + pdsUrl => $opt{pds_url}, 117 + pdsHost => $pds_host, 118 + handle => $opt{handle}, 119 + password => $opt{password}, 120 + targetHandle => $opt{target_handle}, 121 + artifactsDir => $artifacts_dir, 122 + birthdate => $opt{birthdate}, 123 + postText => $opt{post_text}, 124 + quoteText => $opt{quote_text}, 125 + replyText => $opt{reply_text}, 126 + profileNote => $opt{profile_note}, 127 + headless => $opt{headful} ? JSON::PP::false : JSON::PP::true, 128 + editProfile => $opt{edit_profile} ? JSON::PP::true : JSON::PP::false, 129 + }; 130 + 131 + my $config_path = File::Spec->catfile($artifacts_dir, 'config.json'); 132 + open(my $cfg, '>:raw', $config_path) or die "unable to write $config_path: $!"; 133 + print {$cfg} encode_json($config); 134 + close $cfg; 135 + 136 + local $ENV{PLAYWRIGHT_BROWSERS_PATH} = $cache_dir; 137 + my $script = File::Spec->catfile($tools_dir, 'smoke.mjs'); 138 + my @cmdline = ('node', $script, $config_path); 139 + system(@cmdline) == 0 or die "browser smoke failed\n"; 140 + 141 + sub install_runtime { 142 + make_path($cache_dir); 143 + run_or_die('npm', 'install', '--prefix', $tools_dir); 144 + local $ENV{PLAYWRIGHT_BROWSERS_PATH} = $cache_dir; 145 + run_or_die('npm', 'exec', '--prefix', $tools_dir, 'playwright', '--', 'install', 'chromium'); 146 + } 147 + 148 + sub run_or_die { 149 + my @cmdline = @_; 150 + system(@cmdline) == 0 or die "command failed: @cmdline\n"; 151 + }
+41 -1
t/service-proxy.t
··· 149 149 Authorization => "Bearer $access", 150 150 })->status_is(200) 151 151 ->json_is('/did' => $did) 152 - ->json_is('/handle' => $created->{handle}); 152 + ->json_is('/handle' => $created->{handle}) 153 + ->json_is('/postsCount' => 0); 154 + 155 + $t->post_ok('/xrpc/com.atproto.repo.createRecord' => { 156 + Authorization => "Bearer $access", 157 + } => json => { 158 + repo => $did, 159 + collection => 'app.bsky.feed.post', 160 + rkey => 'browser-smoke', 161 + record => { 162 + '$type' => 'app.bsky.feed.post', 163 + text => 'browser smoke post', 164 + createdAt => '2026-03-10T18:00:00Z', 165 + }, 166 + })->status_is(200) 167 + ->json_is('/uri' => "at://$did/app.bsky.feed.post/browser-smoke"); 168 + 169 + my $post_uri = "at://$did/app.bsky.feed.post/browser-smoke"; 170 + 171 + $t->get_ok("/xrpc/app.bsky.actor.getProfile?actor=$did" => { 172 + Authorization => "Bearer $access", 173 + })->status_is(200) 174 + ->json_is('/postsCount' => 1); 175 + 176 + $t->get_ok("/xrpc/app.bsky.feed.getAuthorFeed?actor=$did&limit=10" => { 177 + Authorization => "Bearer $access", 178 + })->status_is(200) 179 + ->json_is('/feed/0/post/uri' => $post_uri) 180 + ->json_is('/feed/0/post/record/text' => 'browser smoke post'); 181 + 182 + $t->get_ok('/xrpc/app.bsky.feed.getPostThread?uri=' . _uri_escape($post_uri) => { 183 + Authorization => "Bearer $access", 184 + })->status_is(200) 185 + ->json_is('/thread/post/uri' => $post_uri) 186 + ->json_is('/thread/post/record/text' => 'browser smoke post'); 153 187 154 188 $t->get_ok('/xrpc/app.bsky.notification.listNotifications?limit=40' => { 155 189 Authorization => "Bearer $access", ··· 197 231 signature => _b64url_decode($sig_b64), 198 232 signing_input => "$header_b64.$claims_b64", 199 233 }; 234 + } 235 + 236 + sub _uri_escape { 237 + my ($value) = @_; 238 + $value =~ s/([^A-Za-z0-9\-._~])/sprintf('%%%02X', ord($1))/ge; 239 + return $value; 200 240 } 201 241 202 242 sub _b64url_decode {
+60
tools/browser-automation/package-lock.json
··· 1 + { 2 + "name": "perlsky-browser-automation", 3 + "lockfileVersion": 3, 4 + "requires": true, 5 + "packages": { 6 + "": { 7 + "name": "perlsky-browser-automation", 8 + "devDependencies": { 9 + "playwright": "1.58.2" 10 + } 11 + }, 12 + "node_modules/fsevents": { 13 + "version": "2.3.2", 14 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 15 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 16 + "dev": true, 17 + "hasInstallScript": true, 18 + "license": "MIT", 19 + "optional": true, 20 + "os": [ 21 + "darwin" 22 + ], 23 + "engines": { 24 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 25 + } 26 + }, 27 + "node_modules/playwright": { 28 + "version": "1.58.2", 29 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", 30 + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", 31 + "dev": true, 32 + "license": "Apache-2.0", 33 + "dependencies": { 34 + "playwright-core": "1.58.2" 35 + }, 36 + "bin": { 37 + "playwright": "cli.js" 38 + }, 39 + "engines": { 40 + "node": ">=18" 41 + }, 42 + "optionalDependencies": { 43 + "fsevents": "2.3.2" 44 + } 45 + }, 46 + "node_modules/playwright-core": { 47 + "version": "1.58.2", 48 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", 49 + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", 50 + "dev": true, 51 + "license": "Apache-2.0", 52 + "bin": { 53 + "playwright-core": "cli.js" 54 + }, 55 + "engines": { 56 + "node": ">=18" 57 + } 58 + } 59 + } 60 + }
+8
tools/browser-automation/package.json
··· 1 + { 2 + "name": "perlsky-browser-automation", 3 + "private": true, 4 + "type": "module", 5 + "devDependencies": { 6 + "playwright": "1.58.2" 7 + } 8 + }
+278
tools/browser-automation/smoke.mjs
··· 1 + import fs from 'node:fs/promises'; 2 + import path from 'node:path'; 3 + import { chromium } from 'playwright'; 4 + 5 + const configPath = process.argv[2]; 6 + if (!configPath) { 7 + console.error('usage: node smoke.mjs <config.json>'); 8 + process.exit(2); 9 + } 10 + 11 + const config = JSON.parse(await fs.readFile(configPath, 'utf8')); 12 + await fs.mkdir(config.artifactsDir, { recursive: true }); 13 + 14 + const summary = { 15 + startedAt: new Date().toISOString(), 16 + appUrl: config.appUrl, 17 + pdsUrl: config.pdsUrl, 18 + handle: config.handle, 19 + targetHandle: config.targetHandle, 20 + steps: [], 21 + console: [], 22 + requestFailures: [], 23 + httpFailures: [], 24 + notes: [], 25 + }; 26 + 27 + const browser = await chromium.launch({ headless: config.headless !== false }); 28 + const context = await browser.newContext({ 29 + viewport: { width: 1440, height: 1000 }, 30 + }); 31 + const page = await context.newPage(); 32 + 33 + page.on('console', (msg) => { 34 + summary.console.push({ 35 + type: msg.type(), 36 + text: msg.text(), 37 + }); 38 + }); 39 + 40 + page.on('requestfailed', (req) => { 41 + summary.requestFailures.push({ 42 + url: req.url(), 43 + method: req.method(), 44 + errorText: req.failure()?.errorText ?? 'unknown', 45 + }); 46 + }); 47 + 48 + page.on('response', (res) => { 49 + const status = res.status(); 50 + if (status >= 400) { 51 + summary.httpFailures.push({ 52 + url: res.url(), 53 + status, 54 + method: res.request().method(), 55 + }); 56 + } 57 + }); 58 + 59 + const screenshot = async (name) => { 60 + const file = path.join(config.artifactsDir, `${name}.png`); 61 + await page.screenshot({ path: file, fullPage: true }); 62 + return file; 63 + }; 64 + 65 + const recordStep = (name, status, extra = {}) => { 66 + summary.steps.push({ 67 + name, 68 + status, 69 + at: new Date().toISOString(), 70 + ...extra, 71 + }); 72 + }; 73 + 74 + const step = async (name, fn, { optional = false } = {}) => { 75 + try { 76 + const result = await fn(); 77 + const shot = await screenshot(name); 78 + recordStep(name, 'ok', { screenshot: shot, ...(result ?? {}) }); 79 + return result; 80 + } catch (error) { 81 + const shot = await screenshot(`${name}-error`).catch(() => undefined); 82 + recordStep(name, optional ? 'skipped' : 'failed', { 83 + screenshot: shot, 84 + error: String(error?.message ?? error), 85 + }); 86 + if (!optional) { 87 + throw error; 88 + } 89 + return null; 90 + } 91 + }; 92 + 93 + const wait = (ms) => page.waitForTimeout(ms); 94 + 95 + const closeWelcomeModal = async () => { 96 + const close = page.getByRole('button', { name: 'Close welcome modal' }); 97 + if (await close.count()) { 98 + await close.evaluate((el) => el.click()); 99 + await wait(300); 100 + } 101 + }; 102 + 103 + const login = async () => { 104 + await page.goto(config.appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); 105 + await page.getByRole('button', { name: 'Sign in' }).nth(0).click({ noWaitAfter: true }); 106 + await wait(1000); 107 + await page.getByRole('button', { name: 'Bluesky Social' }).evaluate((el) => el.click()); 108 + await wait(500); 109 + await page.getByText('Custom').evaluate((el) => el.click()); 110 + await wait(500); 111 + await page.getByPlaceholder('my-server.com').fill(config.pdsHost); 112 + await page.getByRole('button', { name: 'Done' }).evaluate((el) => el.click()); 113 + await wait(500); 114 + await closeWelcomeModal(); 115 + await page.getByPlaceholder('Username or email address').fill(config.handle); 116 + await page.getByPlaceholder('Password').fill(config.password); 117 + await page.getByTestId('loginNextButton').click({ noWaitAfter: true }); 118 + await wait(3000); 119 + }; 120 + 121 + const completeAgeAssuranceIfNeeded = async () => { 122 + const addBirthdate = page.getByRole('button', { name: /update your birthdate/i }); 123 + if (await addBirthdate.count()) { 124 + await addBirthdate.click({ noWaitAfter: true }); 125 + await wait(800); 126 + await page.getByTestId('birthdayInput').fill(config.birthdate); 127 + await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 128 + await wait(3000); 129 + summary.notes.push('Completed age-assurance birthdate gate'); 130 + } 131 + }; 132 + 133 + const gotoProfile = async (handle) => { 134 + await page.goto(`${config.appUrl.replace(/\/$/, '')}/profile/${encodeURIComponent(handle)}`, { 135 + waitUntil: 'domcontentloaded', 136 + timeout: 60000, 137 + }); 138 + await wait(3000); 139 + }; 140 + 141 + const maybeFollowTarget = async () => { 142 + const follow = page.getByTestId('followBtn'); 143 + if (!(await follow.count())) { 144 + return { note: 'follow button unavailable' }; 145 + } 146 + const label = (await follow.getAttribute('aria-label')) ?? ''; 147 + if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 148 + return { note: 'already following target' }; 149 + } 150 + await follow.click({ noWaitAfter: true }); 151 + await wait(2000); 152 + return { note: 'follow attempted' }; 153 + }; 154 + 155 + const composePost = async (text) => { 156 + await page.getByRole('button', { name: 'New Post' }).click({ noWaitAfter: true }); 157 + await wait(800); 158 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 159 + await editor.click({ noWaitAfter: true }); 160 + await editor.fill(text); 161 + await wait(300); 162 + await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 163 + await wait(4000); 164 + }; 165 + 166 + const openOwnProfile = async () => { 167 + await gotoProfile(config.handle); 168 + }; 169 + 170 + const findOwnPostArticle = async (needle, timeout = 60000) => { 171 + const article = page.locator('article').filter({ hasText: needle }).first(); 172 + await article.waitFor({ state: 'visible', timeout }); 173 + return article; 174 + }; 175 + 176 + const likeOwnPost = async (article) => { 177 + const btn = article.getByTestId('likeBtn').first(); 178 + await btn.click({ noWaitAfter: true }); 179 + await wait(1500); 180 + }; 181 + 182 + const repostOwnPost = async (article) => { 183 + const btn = article.getByTestId('repostBtn').first(); 184 + await btn.click({ noWaitAfter: true }); 185 + await wait(500); 186 + const repost = page.getByText(/^Repost$/).last(); 187 + if (await repost.count()) { 188 + await repost.click({ noWaitAfter: true }); 189 + await wait(1500); 190 + } 191 + }; 192 + 193 + const quoteOwnPost = async (article, text) => { 194 + const btn = article.getByTestId('repostBtn').first(); 195 + await btn.click({ noWaitAfter: true }); 196 + await wait(500); 197 + const quote = page.getByText(/^Quote post$/).last(); 198 + if (!(await quote.count())) { 199 + throw new Error('quote option not available'); 200 + } 201 + await quote.click({ noWaitAfter: true }); 202 + await wait(1000); 203 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 204 + await editor.click({ noWaitAfter: true }); 205 + await editor.fill(text); 206 + await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 207 + await wait(4000); 208 + }; 209 + 210 + const replyToOwnPost = async (article, text) => { 211 + const btn = article.getByTestId('replyBtn').first(); 212 + await btn.click({ noWaitAfter: true }); 213 + await wait(1000); 214 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 215 + await editor.click({ noWaitAfter: true }); 216 + await editor.fill(text); 217 + const publishReply = page.getByRole('button', { name: /publish reply|reply/i }).last(); 218 + await publishReply.click({ noWaitAfter: true }); 219 + await wait(4000); 220 + }; 221 + 222 + const editProfile = async () => { 223 + const edit = page.getByRole('button', { name: /edit profile/i }); 224 + if (!(await edit.count())) { 225 + throw new Error('edit profile button unavailable'); 226 + } 227 + await edit.click({ noWaitAfter: true }); 228 + await wait(1000); 229 + const displayName = page.locator('input').filter({ has: page.locator('[aria-label="Name"]') }); 230 + const bio = page.locator('textarea,[contenteditable="true"],input').filter({ hasText: '' }); 231 + const bioField = page.getByLabel('Description').first(); 232 + if (await bioField.count()) { 233 + await bioField.fill(config.profileNote); 234 + } 235 + const save = page.getByRole('button', { name: /save/i }).last(); 236 + await save.click({ noWaitAfter: true }); 237 + await wait(3000); 238 + }; 239 + 240 + try { 241 + await step('login', login); 242 + await step('age-assurance', completeAgeAssuranceIfNeeded, { optional: true }); 243 + await step('compose-own-post', () => composePost(config.postText)); 244 + await step('own-profile', openOwnProfile); 245 + 246 + const ownPost = await step('find-own-post', async () => { 247 + await openOwnProfile(); 248 + const article = await findOwnPostArticle(config.postText, 60000); 249 + return { note: 'found own post', articleFound: true }; 250 + }); 251 + 252 + if (ownPost) { 253 + const article = await findOwnPostArticle(config.postText); 254 + await step('like-own-post', () => likeOwnPost(article), { optional: true }); 255 + await step('repost-own-post', () => repostOwnPost(article), { optional: true }); 256 + await step('quote-own-post', () => quoteOwnPost(article, config.quoteText), { optional: true }); 257 + await step('reply-own-post', () => replyToOwnPost(article, config.replyText), { optional: true }); 258 + } 259 + 260 + await step('target-profile', () => gotoProfile(config.targetHandle)); 261 + await step('follow-target', maybeFollowTarget, { optional: true }); 262 + 263 + if (config.editProfile) { 264 + await step('edit-profile', editProfile, { optional: true }); 265 + } 266 + } catch (error) { 267 + summary.fatal = String(error?.message ?? error); 268 + } 269 + 270 + summary.finishedAt = new Date().toISOString(); 271 + await screenshot('final').catch(() => undefined); 272 + await fs.writeFile( 273 + path.join(config.artifactsDir, 'summary.json'), 274 + JSON.stringify(summary, null, 2) + '\n', 275 + 'utf8', 276 + ); 277 + console.log(JSON.stringify(summary, null, 2)); 278 + await browser.close();