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.

Harden appview proxy retries and browser smoke

alice c3f5b01c dd616345

+228 -26
+45 -15
lib/ATProto/PDS/ServiceProxy.pm
··· 90 90 ); 91 91 } 92 92 93 - my $tx = $method eq 'POST' 94 - ? $self->ua->build_tx($method => $url => \%headers => ($c->req->body // q())) 95 - : $self->ua->build_tx($method => $url => \%headers); 96 - 97 - $tx = eval { $self->ua->start($tx) }; 98 - if (my $err = $@) { 99 - my $message = "$err"; 100 - xrpc_error(502, 'UpstreamFailure', $message || 'Upstream service unreachable'); 101 - } 102 - 103 - my $res = $tx->result; 104 - if (my $err = $res->error) { 105 - xrpc_error(502, 'UpstreamFailure', $err->{message} // 'Upstream service unreachable') 106 - unless $res->code; 107 - } 93 + my $res = $self->_perform_upstream_request( 94 + method => $method, 95 + url => $url, 96 + headers => \%headers, 97 + body => ($c->req->body // q()), 98 + ); 108 99 109 100 my $status = $res->code // 502; 110 101 my $headers_out = $c->res->headers; ··· 134 125 data => $res->body, 135 126 ); 136 127 return $status; 128 + } 129 + 130 + sub _perform_upstream_request ($self, %args) { 131 + my $method = $args{method}; 132 + my $url = $args{url}; 133 + my $headers = $args{headers} // {}; 134 + my $body = $args{body}; 135 + my $attempts = ($method eq 'GET' || $method eq 'HEAD') ? 2 : 1; 136 + my $last_res; 137 + 138 + for my $attempt (1 .. $attempts) { 139 + my $tx = $method eq 'POST' 140 + ? $self->ua->build_tx($method => $url => $headers => $body) 141 + : $self->ua->build_tx($method => $url => $headers); 142 + 143 + $tx = eval { $self->ua->start($tx) }; 144 + if (my $err = $@) { 145 + my $message = "$err"; 146 + xrpc_error(502, 'UpstreamFailure', $message || 'Upstream service unreachable') 147 + if $attempt >= $attempts; 148 + next; 149 + } 150 + 151 + my $res = $tx->result; 152 + if (my $err = $res->error) { 153 + if (!$res->code) { 154 + xrpc_error(502, 'UpstreamFailure', $err->{message} // 'Upstream service unreachable') 155 + if $attempt >= $attempts; 156 + next; 157 + } 158 + } 159 + 160 + $last_res = $res; 161 + next if ($method eq 'GET' || $method eq 'HEAD') && ($res->code // 0) >= 500 && $attempt < $attempts; 162 + return $res; 163 + } 164 + 165 + return $last_res if $last_res; 166 + xrpc_error(502, 'UpstreamFailure', 'Upstream service unreachable'); 137 167 } 138 168 139 169 sub _target_for_request ($self, $c, $nsid) {
+19
script/perlsky-browser-smoke
··· 15 15 my $cache_dir = File::Spec->catdir($root, '.cache', 'ms-playwright'); 16 16 my $default_artifacts = File::Spec->catdir($root, 'data', 'browser-smoke', 'latest'); 17 17 18 + my @default_browser_paths = ( 19 + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 20 + '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', 21 + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', 22 + ); 23 + 24 + my $default_browser_executable; 25 + for my $candidate (@default_browser_paths) { 26 + if (-x $candidate) { 27 + $default_browser_executable = $candidate; 28 + last; 29 + } 30 + } 31 + 18 32 my %opt = ( 19 33 app_url => $ENV{PERLSKY_BROWSER_APP_URL} || 'https://bsky.app', 20 34 pds_url => $ENV{PERLSKY_BROWSER_PDS_URL} || 'https://perlsky.mosphere.at', ··· 33 47 edit_profile => $ENV{PERLSKY_BROWSER_EDIT_PROFILE} ? 1 : 0, 34 48 public_checks => $ENV{PERLSKY_BROWSER_SKIP_PUBLIC_CHECKS} ? 0 : 1, 35 49 strict_errors => $ENV{PERLSKY_BROWSER_STRICT_ERRORS} ? 1 : 0, 50 + browser_executable_path => $ENV{PERLSKY_BROWSER_EXECUTABLE_PATH} || $default_browser_executable, 36 51 ); 37 52 38 53 my $cmd = shift(@ARGV) // 'run'; ··· 53 68 --birthdate YYYY-MM-DD 54 69 --public-api-url URL 55 70 --public-check-timeout-ms MS 71 + --browser-executable-path PATH 56 72 --post-text TEXT 57 73 --quote-text TEXT 58 74 --reply-text TEXT ··· 72 88 PERLSKY_BROWSER_BIRTHDATE 73 89 PERLSKY_BROWSER_PUBLIC_API_URL 74 90 PERLSKY_BROWSER_PUBLIC_CHECK_TIMEOUT_MS 91 + PERLSKY_BROWSER_EXECUTABLE_PATH 75 92 PERLSKY_BROWSER_POST_TEXT 76 93 PERLSKY_BROWSER_QUOTE_TEXT 77 94 PERLSKY_BROWSER_REPLY_TEXT ··· 95 112 'birthdate=s' => \$opt{birthdate}, 96 113 'public-api-url=s' => \$opt{public_api_url}, 97 114 'public-check-timeout-ms=i' => \$opt{public_check_timeout_ms}, 115 + 'browser-executable-path=s' => \$opt{browser_executable_path}, 98 116 'post-text=s' => \$opt{post_text}, 99 117 'quote-text=s' => \$opt{quote_text}, 100 118 'reply-text=s' => \$opt{reply_text}, ··· 140 158 publicCheckTimeoutMs => 0 + $opt{public_check_timeout_ms}, 141 159 publicChecks => $opt{public_checks} ? JSON::PP::true : JSON::PP::false, 142 160 strictErrors => $opt{strict_errors} ? JSON::PP::true : JSON::PP::false, 161 + browserExecutablePath => $opt{browser_executable_path}, 143 162 postText => $opt{post_text}, 144 163 quoteText => $opt{quote_text}, 145 164 replyText => $opt{reply_text},
+17
t/service-proxy.t
··· 41 41 remove_tree($tmp) if -d $tmp; 42 42 43 43 my $appview_app = Mojolicious->new; 44 + my %appview_seen; 44 45 $appview_app->routes->get('/ready')->to(cb => sub { 45 46 my ($c) = @_; 46 47 $c->render(text => 'ok'); ··· 48 49 $appview_app->routes->any('/xrpc/*nsid')->to(cb => sub { 49 50 my ($c) = @_; 50 51 my $nsid = $c->stash('nsid'); 52 + if ($nsid eq 'app.bsky.unspecced.getTrendingTopics' && !$appview_seen{$nsid}++) { 53 + return $c->render(status => 500, json => { 54 + error => 'UpstreamTemporaryFailure', 55 + message => 'try again', 56 + }); 57 + } 51 58 my %body = ( 52 59 nsid => $nsid, 53 60 auth => $c->req->headers->authorization, ··· 217 224 is($chat_auth->{claims}{aud}, 'did:web:chat.test', 'chat proxy auth targets the chat DID'); 218 225 is($chat_auth->{claims}{lxm}, 'chat.bsky.convo.getLog', 'chat proxy auth binds the chat method'); 219 226 ok(_verify_es256k($account->{public_key}, $chat_auth->{signing_input}, $chat_auth->{signature}), 'chat proxy auth signature verifies'); 227 + 228 + $t->get_ok('/xrpc/app.bsky.unspecced.getTrendingTopics?limit=14' => { 229 + Authorization => "Bearer $access", 230 + })->status_is(200) 231 + ->json_is('/nsid' => 'app.bsky.unspecced.getTrendingTopics'); 232 + 233 + my $trending_auth = _decode_bearer($t->tx->res->json->{auth}); 234 + is($trending_auth->{claims}{aud}, 'did:web:appview.test', 'trending topics retry still targets the appview DID'); 235 + is($trending_auth->{claims}{lxm}, 'app.bsky.unspecced.getTrendingTopics', 'trending topics retry binds the proxied method'); 236 + ok(_verify_es256k($account->{public_key}, $trending_auth->{signing_input}, $trending_auth->{signature}), 'trending topics retry auth signature verifies'); 220 237 221 238 $t->get_ok('/xrpc/app.bsky.actor.getPreferences' => { 222 239 Authorization => "Bearer $access",
+147 -11
tools/browser-automation/smoke.mjs
··· 30 30 const ignoredConsole = [ 31 31 /events\.bsky\.app\/.*ERR_BLOCKED_BY_CLIENT/i, 32 32 /slider-vertical/i, 33 + /Password field is not contained in a form/i, 33 34 ]; 34 35 35 36 const ignoredRequestFailure = [ 36 - { url: /events\.bsky\.app\//i, error: /ERR_BLOCKED_BY_CLIENT/i }, 37 + { url: /events\.bsky\.app\//i, error: /ERR_(BLOCKED_BY_CLIENT|ABORTED)/i }, 37 38 { url: /workers\.dev\/api\/config/i, error: /ERR_ABORTED/i }, 39 + { url: /app-config\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 40 + { url: /live-events\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 41 + { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 42 + { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 43 + { url: /video\.bsky\.app\/watch\/.*\/playlist\.m3u8/i, error: /ERR_ABORTED/i }, 44 + { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 38 45 ]; 39 46 40 47 const ignoredHttpFailure = [ 41 48 { url: /c\.1password\.com\/richicons/i, status: 404 }, 42 49 ]; 43 50 44 - const browser = await chromium.launch({ headless: config.headless !== false }); 51 + const executableExists = async (file) => { 52 + if (!file) { 53 + return false; 54 + } 55 + try { 56 + await fs.access(file); 57 + return true; 58 + } catch { 59 + return false; 60 + } 61 + }; 62 + 63 + const browserCandidates = async () => { 64 + const base = { 65 + headless: config.headless !== false, 66 + chromiumSandbox: true, 67 + }; 68 + const candidates = []; 69 + if (config.browserExecutablePath) { 70 + candidates.push({ 71 + label: `executable:${config.browserExecutablePath}`, 72 + options: { ...base, executablePath: config.browserExecutablePath }, 73 + }); 74 + } 75 + const systemChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 76 + if (!config.browserExecutablePath && await executableExists(systemChrome)) { 77 + candidates.push({ 78 + label: 'system-google-chrome', 79 + options: { ...base, executablePath: systemChrome }, 80 + }); 81 + } 82 + candidates.push({ 83 + label: 'playwright-chromium', 84 + options: { ...base, channel: 'chromium' }, 85 + }); 86 + return candidates; 87 + }; 88 + 89 + const launchBrowser = async () => { 90 + const errors = []; 91 + for (const candidate of await browserCandidates()) { 92 + try { 93 + const browser = await chromium.launch(candidate.options); 94 + summary.notes.push(`browser launch candidate succeeded: ${candidate.label}`); 95 + return browser; 96 + } catch (error) { 97 + errors.push(`${candidate.label}: ${String(error?.message ?? error)}`); 98 + } 99 + } 100 + throw new Error(`unable to launch browser via any candidate: ${errors.join(' | ')}`); 101 + }; 102 + 103 + const browser = await launchBrowser(); 45 104 const context = await browser.newContext({ 46 105 viewport: { width: 1440, height: 1000 }, 47 106 }); 48 107 const page = await context.newPage(); 108 + 109 + if (config.browserExecutablePath) { 110 + summary.notes.push(`requested browser executable: ${config.browserExecutablePath}`); 111 + } 49 112 50 113 page.on('console', (msg) => { 51 114 summary.console.push({ ··· 238 301 }; 239 302 240 303 const composePost = async (text) => { 241 - await page.getByRole('button', { name: 'New Post' }).click({ noWaitAfter: true }); 304 + await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 242 305 await wait(800); 243 306 const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 244 307 await editor.click({ noWaitAfter: true }); ··· 270 333 return row; 271 334 }; 272 335 273 - const likeOwnPost = async (row) => { 336 + const clickLike = async (row) => { 274 337 const btn = row.getByTestId('likeBtn').first(); 275 338 await btn.click({ noWaitAfter: true }); 276 339 await wait(1500); 277 340 }; 278 341 279 - const repostOwnPost = async (row) => { 342 + const clickRepost = async (row) => { 280 343 const btn = row.getByTestId('repostBtn').first(); 281 344 await btn.click({ noWaitAfter: true }); 282 345 await wait(500); ··· 287 350 } 288 351 }; 289 352 290 - const quoteOwnPost = async (row, text) => { 353 + const clickQuote = async (row, text) => { 291 354 const btn = row.getByTestId('repostBtn').first(); 292 355 await btn.click({ noWaitAfter: true }); 293 356 await wait(500); ··· 304 367 await wait(4000); 305 368 }; 306 369 307 - const replyToOwnPost = async (row, text) => { 370 + const clickReply = async (row, text) => { 308 371 const btn = row.getByTestId('replyBtn').first(); 309 372 await btn.click({ noWaitAfter: true }); 310 373 await wait(1000); ··· 316 379 await wait(4000); 317 380 }; 318 381 382 + const buttonText = async (locator) => { 383 + const label = await locator.getAttribute('aria-label'); 384 + if (label && label.trim()) { 385 + return label.trim(); 386 + } 387 + const text = await locator.innerText().catch(() => ''); 388 + return text.trim(); 389 + }; 390 + 391 + const ensureLiked = async (row) => { 392 + const btn = row.getByTestId('likeBtn').first(); 393 + const before = await buttonText(btn); 394 + if (/unlike/i.test(before)) { 395 + return { note: 'already liked' }; 396 + } 397 + await clickLike(row); 398 + return { note: await buttonText(btn) }; 399 + }; 400 + 401 + const ensureReposted = async (row) => { 402 + const btn = row.getByTestId('repostBtn').first(); 403 + const before = await buttonText(btn); 404 + if (/undo repost|remove repost/i.test(before)) { 405 + return { note: 'already reposted' }; 406 + } 407 + await clickRepost(row); 408 + return { note: await buttonText(btn) }; 409 + }; 410 + 411 + const openNotifications = async () => { 412 + await page.goto(`${config.appUrl.replace(/\/$/, '')}/notifications`, { 413 + waitUntil: 'domcontentloaded', 414 + timeout: 60000, 415 + }); 416 + await wait(3000); 417 + const heading = page.getByText(/^Notifications$/).first(); 418 + if (await heading.count()) { 419 + await heading.waitFor({ state: 'visible', timeout: 15000 }); 420 + } 421 + }; 422 + 319 423 const verifyPublicHandleResolution = async () => { 320 424 const result = await pollJson( 321 425 'public handle resolution', ··· 394 498 395 499 if (ownPost) { 396 500 const row = await findFeedItemByText(config.postText); 397 - await step('like-own-post', () => likeOwnPost(row), { optional: true }); 398 - await step('repost-own-post', () => repostOwnPost(row), { optional: true }); 399 - await step('quote-own-post', () => quoteOwnPost(row, config.quoteText), { optional: true }); 501 + await step('like-own-post', () => ensureLiked(row), { optional: true }); 502 + await step('repost-own-post', () => ensureReposted(row), { optional: true }); 503 + await step('quote-own-post', () => clickQuote(row, config.quoteText), { optional: true }); 400 504 await step('reply-own-post', async () => { 401 505 await openOwnProfile(); 402 506 const refreshed = await findFeedItemByText(config.postText, 60000); 403 - await replyToOwnPost(refreshed, config.replyText); 507 + await clickReply(refreshed, config.replyText); 404 508 }, { optional: true }); 405 509 } 406 510 ··· 414 518 const row = await findFirstFeedItem(20000); 415 519 const preview = ((await row.textContent()) || '').replace(/\s+/g, ' ').slice(0, 160); 416 520 return { note: preview }; 521 + }, { optional: true }); 522 + 523 + await step('like-target-post', async () => { 524 + const row = await findFirstFeedItem(20000); 525 + return ensureLiked(row); 526 + }, { optional: true }); 527 + 528 + await step('repost-target-post', async () => { 529 + const row = await findFirstFeedItem(20000); 530 + return ensureReposted(row); 531 + }, { optional: true }); 532 + 533 + await step('quote-target-post', async () => { 534 + const row = await findFirstFeedItem(20000); 535 + await clickQuote(row, `${config.quoteText} to @${config.targetHandle.replace(/^@/, '')}`); 536 + return { note: 'quoted target post' }; 537 + }, { optional: true }); 538 + 539 + await step('reply-target-post', async () => { 540 + await gotoProfile(config.targetHandle); 541 + const row = await findFirstFeedItem(20000); 542 + await clickReply(row, `${config.replyText} to @${config.targetHandle.replace(/^@/, '')}`); 543 + return { note: 'replied to target post' }; 544 + }, { optional: true }); 545 + 546 + await step('notifications-page', async () => { 547 + await openNotifications(); 548 + const tab = page.getByRole('tab', { name: /all|priority/i }).first(); 549 + if (await tab.count()) { 550 + await tab.waitFor({ state: 'visible', timeout: 15000 }); 551 + } 552 + return { note: 'notifications page loaded' }; 417 553 }, { optional: true }); 418 554 419 555 if (config.editProfile) {