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 retries and browser smoke exits

alice b71c204b 25608293

+268 -3
+7 -2
lib/ATProto/PDS/ServiceProxy.pm
··· 132 132 my $url = $args{url}; 133 133 my $headers = $args{headers} // {}; 134 134 my $body = $args{body}; 135 - my $attempts = ($method eq 'GET' || $method eq 'HEAD') ? 2 : 1; 135 + my $attempts = ($method eq 'GET' || $method eq 'HEAD') ? 3 : 1; 136 136 my $last_res; 137 137 138 138 for my $attempt (1 .. $attempts) { ··· 145 145 my $message = "$err"; 146 146 xrpc_error(502, 'UpstreamFailure', $message || 'Upstream service unreachable') 147 147 if $attempt >= $attempts; 148 + select undef, undef, undef, 0.2 * $attempt; 148 149 next; 149 150 } 150 151 ··· 153 154 if (!$res->code) { 154 155 xrpc_error(502, 'UpstreamFailure', $err->{message} // 'Upstream service unreachable') 155 156 if $attempt >= $attempts; 157 + select undef, undef, undef, 0.2 * $attempt; 156 158 next; 157 159 } 158 160 } 159 161 160 162 $last_res = $res; 161 - next if ($method eq 'GET' || $method eq 'HEAD') && ($res->code // 0) >= 500 && $attempt < $attempts; 163 + if (($method eq 'GET' || $method eq 'HEAD') && ($res->code // 0) >= 500 && $attempt < $attempts) { 164 + select undef, undef, undef, 0.2 * $attempt; 165 + next; 166 + } 162 167 return $res; 163 168 } 164 169
+258 -1
tools/browser-automation/dual-smoke.mjs
··· 49 49 { url: /live-events\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 50 50 { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 51 51 { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 52 - { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts)/i, error: /ERR_ABORTED/i }, 52 + { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts|.*\.vtt)/i, error: /ERR_ABORTED/i }, 53 53 { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 54 + { url: /\/xrpc\/app\.bsky\.graph\.(?:muteActor|unmuteActor)/i, error: /ERR_ABORTED/i }, 54 55 ]; 55 56 56 57 const ignoredHttpFailure = [ ··· 410 411 411 412 const accountFromConfig = (entry) => ({ 412 413 ...entry, 414 + mediaPostText: entry.mediaPostText || `${entry.postText} image`, 413 415 shortHandle: entry.handle.replace(/^@/, ''), 414 416 }); 415 417 ··· 481 483 await wait(page, 300); 482 484 await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 483 485 await wait(page, 4000); 486 + }; 487 + 488 + const uploadComposerMedia = async (page) => { 489 + const mediaFile = await ensureAvatarFixture(); 490 + const openMedia = page.getByTestId('openMediaBtn').last(); 491 + if (!(await openMedia.count())) { 492 + throw new Error('composer media button unavailable'); 493 + } 494 + const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 495 + await openMedia.click({ noWaitAfter: true }); 496 + const chooser = await chooserPromise; 497 + await chooser.setFiles(mediaFile); 498 + await wait(page, 2000); 499 + return mediaFile; 500 + }; 501 + 502 + const composePostWithImage = async (page, text) => { 503 + await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 504 + await wait(page, 800); 505 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 506 + await editor.click({ noWaitAfter: true }); 507 + await editor.fill(text); 508 + const mediaFile = await uploadComposerMedia(page); 509 + await wait(page, 500); 510 + await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 511 + await wait(page, 5000); 512 + return { mediaFile }; 484 513 }; 485 514 486 515 const dismissModalBackdropIfPresent = async (page) => { ··· 710 739 return { note: await buttonText(btn) }; 711 740 }; 712 741 742 + const ensureBookmarked = async (page, row) => { 743 + const btn = row.getByTestId('postBookmarkBtn').first(); 744 + const before = await buttonText(btn); 745 + if (/remove from saved posts/i.test(before)) { 746 + return { note: 'already bookmarked' }; 747 + } 748 + await btn.click({ noWaitAfter: true }); 749 + await wait(page, 1500); 750 + return { note: await buttonText(btn) }; 751 + }; 752 + 753 + const ensureNotBookmarked = async (page, row) => { 754 + const btn = row.getByTestId('postBookmarkBtn').first(); 755 + const before = await buttonText(btn); 756 + if (!/remove from saved posts/i.test(before)) { 757 + return { note: 'already not bookmarked' }; 758 + } 759 + await btn.click({ noWaitAfter: true }); 760 + await wait(page, 1500); 761 + return { note: await buttonText(btn) }; 762 + }; 763 + 713 764 const clickQuote = async (page, row, text) => { 714 765 await dismissBlockingOverlays(page); 715 766 const btn = row.getByTestId('repostBtn').first(); ··· 752 803 await dismissBlockingOverlays(page); 753 804 }; 754 805 806 + const openProfileMenu = async (page) => { 807 + const btn = page.getByTestId('profileHeaderDropdownBtn').first(); 808 + await btn.waitFor({ state: 'visible', timeout: 15000 }); 809 + await btn.click({ noWaitAfter: true }); 810 + const menu = page.locator('[role="menu"]').last(); 811 + await menu.waitFor({ state: 'visible', timeout: 10000 }); 812 + return menu; 813 + }; 814 + 815 + const menuItems = async (page) => 816 + page.locator('[role="menuitem"]').evaluateAll((els) => 817 + els.map((el) => (el.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean), 818 + ); 819 + 820 + const closeActiveMenu = async (page) => { 821 + const backdrop = page.locator('[aria-label*="backdrop"]').last(); 822 + if (await backdrop.count()) { 823 + await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 824 + await wait(page, 400); 825 + return; 826 + } 827 + await page.keyboard.press('Escape').catch(() => undefined); 828 + await wait(page, 400); 829 + }; 830 + 831 + const ensureProfileMuted = async (page) => { 832 + await openProfileMenu(page); 833 + const items = await menuItems(page); 834 + if (items.some((item) => /unmute account/i.test(item))) { 835 + await closeActiveMenu(page); 836 + return { note: 'already muted' }; 837 + } 838 + await page.getByRole('menuitem', { name: /mute account/i }).click({ noWaitAfter: true }); 839 + await wait(page, 1500); 840 + await openProfileMenu(page); 841 + const after = await menuItems(page); 842 + await closeActiveMenu(page); 843 + if (!after.some((item) => /unmute account/i.test(item))) { 844 + throw new Error('mute account did not switch menu state'); 845 + } 846 + return { note: 'muted account' }; 847 + }; 848 + 849 + const ensureProfileUnmuted = async (page) => { 850 + await openProfileMenu(page); 851 + const items = await menuItems(page); 852 + if (!items.some((item) => /unmute account/i.test(item))) { 853 + await closeActiveMenu(page); 854 + return { note: 'already unmuted' }; 855 + } 856 + await page.getByRole('menuitem', { name: /unmute account/i }).click({ noWaitAfter: true }); 857 + await wait(page, 1500); 858 + await openProfileMenu(page); 859 + const after = await menuItems(page); 860 + await closeActiveMenu(page); 861 + if (!after.some((item) => /mute account/i.test(item))) { 862 + throw new Error('unmute account did not restore menu state'); 863 + } 864 + return { note: 'unmuted account' }; 865 + }; 866 + 867 + const blockProfile = async (page) => { 868 + await openProfileMenu(page); 869 + const items = await menuItems(page); 870 + if (items.some((item) => /unblock account/i.test(item))) { 871 + await closeActiveMenu(page); 872 + return { note: 'already blocked' }; 873 + } 874 + await page.getByRole('menuitem', { name: /block account/i }).click({ noWaitAfter: true }); 875 + const dialog = page.locator('[role="dialog"]').last(); 876 + await dialog.waitFor({ state: 'visible', timeout: 10000 }); 877 + await dialog.getByRole('button', { name: /^Block$/i }).click({ noWaitAfter: true }); 878 + await wait(page, 2500); 879 + const unblock = page.getByRole('button', { name: /unblock/i }).first(); 880 + if (!(await unblock.count())) { 881 + throw new Error('block account did not expose an unblock button'); 882 + } 883 + return { note: 'blocked account' }; 884 + }; 885 + 886 + const unblockProfile = async (page) => { 887 + const unblock = page.getByRole('button', { name: /unblock/i }).first(); 888 + if (!(await unblock.count())) { 889 + return { note: 'already unblocked' }; 890 + } 891 + await unblock.click({ noWaitAfter: true }); 892 + await wait(page, 1000); 893 + const dialog = page.locator('[role="dialog"]').last(); 894 + const confirm = dialog.getByRole('button', { name: /unblock/i }).last(); 895 + if (await confirm.count()) { 896 + await confirm.click({ noWaitAfter: true }); 897 + } 898 + await wait(page, 1500); 899 + const blockedBadge = page.getByText(/user blocked/i).first(); 900 + if (await blockedBadge.count()) { 901 + throw new Error('profile still appears blocked after unblock'); 902 + } 903 + return { note: 'unblocked account' }; 904 + }; 905 + 906 + const openReportPostDraft = async (page, row) => { 907 + await openPostOptions(page, row); 908 + await page.getByRole('menuitem', { name: /report post/i }).click({ noWaitAfter: true }); 909 + const dialog = page.locator('[role="dialog"]').last(); 910 + await dialog.waitFor({ state: 'visible', timeout: 10000 }); 911 + await dialog.getByRole('button', { name: /create report for other/i }).click({ noWaitAfter: true }); 912 + await wait(page, 1000); 913 + const submit = dialog.getByRole('button', { name: /submit report/i }).last(); 914 + await submit.waitFor({ state: 'visible', timeout: 10000 }); 915 + const body = normalizeText(await dialog.textContent()); 916 + const close = dialog.getByRole('button', { name: /close active dialog/i }).last(); 917 + if (await close.count()) { 918 + await close.click({ noWaitAfter: true }); 919 + } else { 920 + await page.keyboard.press('Escape').catch(() => undefined); 921 + } 922 + await wait(page, 1000); 923 + return { 924 + note: 'opened report draft without submitting', 925 + submitVisible: true, 926 + body, 927 + }; 928 + }; 929 + 755 930 const waitForVisibleEditor = async (page) => { 756 931 const editors = page.locator('[aria-label="Rich-Text Editor"]'); 757 932 const started = Date.now(); ··· 841 1016 } 842 1017 }; 843 1018 1019 + const openSavedPosts = async (page) => { 1020 + await page.goto(`${appBaseUrl}/saved`, { 1021 + waitUntil: 'domcontentloaded', 1022 + timeout: 60000, 1023 + }); 1024 + await wait(page, 3000); 1025 + }; 1026 + 844 1027 const waitForNotificationsFeed = async (page) => { 845 1028 const feed = page.getByTestId('notifsFeed').first(); 846 1029 if (await feed.count()) { ··· 944 1127 return { rowTestId }; 945 1128 }, { pageNames: ['primary'] }); 946 1129 1130 + await step('primary-compose-image-post', async () => composePostWithImage(primaryPage, primary.mediaPostText), { 1131 + pageNames: ['primary'], 1132 + }); 1133 + 1134 + await step('primary-image-post-record', async () => { 1135 + primary.imagePost = await waitForOwnPostRecord(primary, primary.mediaPostText); 1136 + const embed = primary.imagePost.value?.embed; 1137 + if (embed?.$type !== 'app.bsky.embed.images' || !Array.isArray(embed.images) || embed.images.length < 1) { 1138 + throw new Error('image post did not persist an app.bsky.embed.images record'); 1139 + } 1140 + return { 1141 + uri: primary.imagePost.uri, 1142 + imageCount: embed.images.length, 1143 + mimeType: embed.images[0]?.image?.mimeType, 1144 + }; 1145 + }); 1146 + 947 1147 await step('secondary-compose-root-post', () => composePost(secondaryPage, secondary.postText), { 948 1148 pageNames: ['secondary'], 949 1149 }); ··· 999 1199 return ensureLiked(primaryPage, row); 1000 1200 }, { pageNames: ['primary'] }); 1001 1201 1202 + await step('primary-bookmark-secondary-post', async () => { 1203 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1204 + return ensureBookmarked(primaryPage, row); 1205 + }, { pageNames: ['primary'] }); 1206 + 1207 + await step('primary-saved-posts-secondary', async () => { 1208 + await openSavedPosts(primaryPage); 1209 + await primaryPage.getByText(`@${secondary.handle.replace(/^@/, '')}`).first().waitFor({ 1210 + state: 'visible', 1211 + timeout: 20000, 1212 + }); 1213 + return { note: `saved post by ${secondary.handle}` }; 1214 + }, { pageNames: ['primary'] }); 1215 + 1002 1216 await step('primary-repost-secondary-post', async () => { 1217 + await gotoProfile(primaryPage, secondary.handle); 1003 1218 const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1004 1219 return ensureReposted(primaryPage, row); 1005 1220 }, { pageNames: ['primary'] }); 1006 1221 1007 1222 await step('primary-quote-secondary-post', async () => { 1223 + await gotoProfile(primaryPage, secondary.handle); 1008 1224 const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1009 1225 await clickQuote(primaryPage, row, primary.quoteText); 1010 1226 primary.quotePost = await waitForOwnPostRecord(primary, primary.quoteText); ··· 1012 1228 }, { pageNames: ['primary'] }); 1013 1229 1014 1230 await step('primary-reply-secondary-post', async () => { 1231 + await gotoProfile(primaryPage, secondary.handle); 1015 1232 const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1016 1233 await clickReply(primaryPage, row, primary.replyText); 1017 1234 primary.replyPost = await waitForOwnPostRecord(primary, primary.replyText); ··· 1077 1294 return { note: feed ? 'notifications feed visible' : 'notifications page visible without explicit feed testid' }; 1078 1295 }, { pageNames: ['primary'] }); 1079 1296 1297 + await step('primary-mute-secondary', async () => { 1298 + await gotoProfile(primaryPage, secondary.handle); 1299 + return ensureProfileMuted(primaryPage); 1300 + }, { pageNames: ['primary'] }); 1301 + 1302 + await step('primary-unmute-secondary', async () => { 1303 + await gotoProfile(primaryPage, secondary.handle); 1304 + return ensureProfileUnmuted(primaryPage); 1305 + }, { pageNames: ['primary'] }); 1306 + 1307 + await step('secondary-report-primary-post-draft', async () => { 1308 + await gotoProfile(secondaryPage, primary.handle); 1309 + const row = await findRowByPrimaryText(secondaryPage, primary.postText, 60000); 1310 + return openReportPostDraft(secondaryPage, row); 1311 + }, { pageNames: ['secondary'] }); 1312 + 1313 + await step('secondary-block-primary', async () => { 1314 + await gotoProfile(secondaryPage, primary.handle); 1315 + return blockProfile(secondaryPage); 1316 + }, { pageNames: ['secondary'] }); 1317 + 1318 + await step('secondary-unblock-primary', async () => { 1319 + return unblockProfile(secondaryPage); 1320 + }, { pageNames: ['secondary'] }); 1321 + 1080 1322 await step('primary-cleanup-unlike-secondary-post', async () => { 1081 1323 await gotoProfile(primaryPage, secondary.handle); 1082 1324 const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1083 1325 return ensureNotLiked(primaryPage, row); 1326 + }, { optional: true, pageNames: ['primary'] }); 1327 + 1328 + await step('primary-cleanup-unbookmark-secondary-post', async () => { 1329 + await gotoProfile(primaryPage, secondary.handle); 1330 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1331 + return ensureNotBookmarked(primaryPage, row); 1084 1332 }, { optional: true, pageNames: ['primary'] }); 1085 1333 1086 1334 await step('primary-cleanup-undo-repost-secondary-post', async () => { ··· 1103 1351 await gotoProfile(primaryPage, primary.handle); 1104 1352 await openProfileTab(primaryPage, 'Posts'); 1105 1353 return maybeDeleteOwnPostByText(primaryPage, primary.quoteText, 'deleted quote post'); 1354 + }, { pageNames: ['primary'] }); 1355 + 1356 + await step('primary-cleanup-delete-image-post', async () => { 1357 + await gotoProfile(primaryPage, primary.handle); 1358 + await openProfileTab(primaryPage, 'Posts'); 1359 + return maybeDeleteOwnPostByText(primaryPage, primary.mediaPostText, 'deleted image post'); 1106 1360 }, { pageNames: ['primary'] }); 1107 1361 1108 1362 await step('primary-cleanup-delete-reply', async () => { ··· 1151 1405 ); 1152 1406 console.log(JSON.stringify(summary, null, 2)); 1153 1407 await browser.close(); 1408 + if (!summary.ok) { 1409 + process.exitCode = 1; 1410 + }
+3
tools/browser-automation/smoke.mjs
··· 930 930 ); 931 931 console.log(JSON.stringify(summary, null, 2)); 932 932 await browser.close(); 933 + if (!summary.ok) { 934 + process.exitCode = 1; 935 + }