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.

Expand browser smoke coverage and clean binary decode paths

alice 9279c98e 3548678c

+393 -40
+9 -1
lib/ATProto/PDS/Repo/DagCbor.pm
··· 20 20 21 21 sub decode_dag_cbor { 22 22 my ($bytes) = @_; 23 + die 'DAG-CBOR bytes required' unless defined $bytes; 24 + my $octets = $bytes; 25 + if (utf8::is_utf8($octets)) { 26 + utf8::downgrade($octets, 1) or utf8::encode($octets); 27 + } 23 28 my $decoder = CBOR::XS->new->filter(sub { 24 29 my ($tag, $value) = @_; 25 30 if ($tag == 42 && !ref($value)) { ··· 28 33 } 29 34 return; 30 35 }); 31 - return $decoder->decode($bytes); 36 + { 37 + no warnings 'uninitialized'; 38 + return $decoder->decode($octets); 39 + } 32 40 } 33 41 34 42 sub _encode_value {
+2
lib/ATProto/PDS/Store/SQLite.pm
··· 1977 1977 1978 1978 sub _valid_block_bytes ($cid, $codec, $bytes) { 1979 1979 return 0 unless defined $cid && defined $bytes; 1980 + return 0 if utf8::is_utf8($bytes); 1980 1981 my $actual = eval { 1981 1982 local $SIG{__WARN__} = sub { }; 1982 1983 if (($codec // 0) == CID_CODEC_RAW) { ··· 1991 1992 1992 1993 sub _valid_dag_cbor_cid ($cid, $bytes) { 1993 1994 return 0 unless defined $cid && defined $bytes; 1995 + return 0 if utf8::is_utf8($bytes); 1994 1996 my $actual = eval { 1995 1997 local $SIG{__WARN__} = sub { }; 1996 1998 decode_dag_cbor($bytes);
+382 -39
tools/browser-automation/smoke.mjs
··· 10 10 11 11 const config = JSON.parse(await fs.readFile(configPath, 'utf8')); 12 12 await fs.mkdir(config.artifactsDir, { recursive: true }); 13 + const appBaseUrl = config.appUrl.replace(/\/$/, ''); 13 14 14 15 const summary = { 15 16 startedAt: new Date().toISOString(), ··· 40 41 { url: /live-events\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 41 42 { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 42 43 { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 43 - { url: /video\.bsky\.app\/watch\/.*\/(?:playlist\.m3u8|.*\.ts)/i, error: /ERR_ABORTED/i }, 44 + { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts)/i, error: /ERR_ABORTED/i }, 44 45 { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 45 46 ]; 46 47 ··· 48 49 { url: /c\.1password\.com\/richicons/i, status: 404 }, 49 50 ]; 50 51 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 - }; 52 + const AVATAR_PNG_BASE64 = 53 + 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAV0lEQVR4nO3PQQ0AIBDAMMC/58MCP7KkVbDX1pk5A6gWUC2gWkC1gGoB1QKqBVQLqBZQLaBaQLWAagHVAqoFVAuoFlAtoFpAtYBqAdUCqgVUC6gWUC2gWkD1B4a2AX/y3CvgAAAAAElFTkSuQmCC'; 62 54 63 55 const browserCandidates = async () => { 64 56 const base = { ··· 73 65 }); 74 66 } 75 67 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 - }); 68 + if (!config.browserExecutablePath) { 69 + try { 70 + await fs.access(systemChrome); 71 + candidates.push({ 72 + label: 'system-google-chrome', 73 + options: { ...base, executablePath: systemChrome }, 74 + }); 75 + } catch { 76 + // Fall back to Playwright-managed Chromium below. 77 + } 81 78 } 82 79 candidates.push({ 83 80 label: 'playwright-chromium', ··· 159 156 return file; 160 157 }; 161 158 159 + const ensureAvatarFixture = async () => { 160 + const file = path.join(config.artifactsDir, 'avatar-fixture.png'); 161 + await fs.writeFile(file, Buffer.from(AVATAR_PNG_BASE64, 'base64')); 162 + return file; 163 + }; 164 + 162 165 const recordStep = (name, status, extra = {}) => { 163 166 summary.steps.push({ 164 167 name, ··· 167 170 ...extra, 168 171 }); 169 172 }; 173 + 174 + const normalizeText = (text) => (text || '').replace(/\s+/g, ' ').trim(); 170 175 171 176 const isIgnoredConsole = (entry) => 172 177 ignoredConsole.some((pattern) => pattern.test(entry.text || '')); ··· 217 222 return { ok: res.ok, status: res.status, text, json }; 218 223 }; 219 224 225 + const fetchStatus = async (url) => { 226 + const res = await fetch(url, { 227 + redirect: 'follow', 228 + }); 229 + return { ok: res.ok, status: res.status, url: res.url }; 230 + }; 231 + 220 232 const pollJson = async (name, buildUrl, predicate, timeoutMs) => { 221 233 const started = Date.now(); 222 234 let last; ··· 230 242 throw new Error(`${name} did not succeed before timeout; last status=${last?.status ?? 'none'}`); 231 243 }; 232 244 233 - const closeWelcomeModal = async () => { 234 - const close = page.getByRole('button', { name: 'Close welcome modal' }); 235 - if (await close.count()) { 236 - await close.evaluate((el) => el.click()); 237 - await wait(300); 238 - } 239 - }; 240 - 241 245 const login = async () => { 242 246 await page.goto(config.appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); 243 247 await page.getByRole('button', { name: 'Sign in' }).nth(0).click({ noWaitAfter: true }); ··· 249 253 await page.getByPlaceholder('my-server.com').fill(config.pdsHost); 250 254 await page.getByRole('button', { name: 'Done' }).evaluate((el) => el.click()); 251 255 await wait(500); 252 - await closeWelcomeModal(); 256 + const close = page.getByRole('button', { name: 'Close welcome modal' }); 257 + if (await close.count()) { 258 + await close.evaluate((el) => el.click()); 259 + await wait(300); 260 + } 253 261 await page.getByPlaceholder('Username or email address').fill(config.handle); 254 262 await page.getByPlaceholder('Password').fill(config.password); 255 263 await page.getByTestId('loginNextButton').click({ noWaitAfter: true }); ··· 269 277 }; 270 278 271 279 const gotoProfile = async (handle) => { 272 - await page.goto(`${config.appUrl.replace(/\/$/, '')}/profile/${encodeURIComponent(handle)}`, { 280 + await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 273 281 waitUntil: 'domcontentloaded', 274 282 timeout: 60000, 275 283 }); ··· 327 335 return row; 328 336 }; 329 337 338 + const findRowByPrimaryText = async (needle, timeout = 60000) => { 339 + const started = Date.now(); 340 + while (Date.now() - started < timeout) { 341 + const rows = page.locator('[data-testid^="feedItem-by-"]'); 342 + const count = await rows.count(); 343 + for (let i = 0; i < count; i += 1) { 344 + const row = rows.nth(i); 345 + const primary = row.locator('[data-testid="postText"]').first(); 346 + if (!(await primary.count())) { 347 + continue; 348 + } 349 + const text = normalizeText(await primary.textContent()); 350 + if (text === needle) { 351 + await row.waitFor({ state: 'visible', timeout: 10000 }); 352 + return row; 353 + } 354 + } 355 + await wait(1000); 356 + } 357 + throw new Error(`feed item with primary text not found: ${needle}`); 358 + }; 359 + 360 + const maybeFindRowByPrimaryText = async (needle, timeout = 5000) => { 361 + try { 362 + return await findRowByPrimaryText(needle, timeout); 363 + } catch { 364 + return null; 365 + } 366 + }; 367 + 330 368 const findFirstFeedItem = async (timeout = 60000) => { 331 369 const row = page.locator('[data-testid^="feedItem-by-"]').first(); 332 370 await row.waitFor({ state: 'visible', timeout }); ··· 379 417 await wait(4000); 380 418 }; 381 419 420 + const ensureBookmarked = async (row) => { 421 + const btn = row.getByTestId('postBookmarkBtn').first(); 422 + const before = await buttonText(btn); 423 + if (/remove from saved posts/i.test(before)) { 424 + return { note: 'already bookmarked' }; 425 + } 426 + await btn.click({ noWaitAfter: true }); 427 + await wait(1500); 428 + return { note: await buttonText(btn) }; 429 + }; 430 + 431 + const ensureNotBookmarked = async (row) => { 432 + const btn = row.getByTestId('postBookmarkBtn').first(); 433 + const before = await buttonText(btn); 434 + if (!/remove from saved posts/i.test(before)) { 435 + return { note: 'already not bookmarked' }; 436 + } 437 + await btn.click({ noWaitAfter: true }); 438 + await wait(1500); 439 + return { note: await buttonText(btn) }; 440 + }; 441 + 382 442 const buttonText = async (locator) => { 383 443 const label = await locator.getAttribute('aria-label'); 384 444 if (label && label.trim()) { ··· 398 458 return { note: await buttonText(btn) }; 399 459 }; 400 460 461 + const ensureNotLiked = async (row) => { 462 + const btn = row.getByTestId('likeBtn').first(); 463 + const before = await buttonText(btn); 464 + if (!/unlike/i.test(before)) { 465 + return { note: 'already not liked' }; 466 + } 467 + await clickLike(row); 468 + return { note: await buttonText(btn) }; 469 + }; 470 + 401 471 const ensureReposted = async (row) => { 402 472 const btn = row.getByTestId('repostBtn').first(); 403 473 const before = await buttonText(btn); ··· 408 478 return { note: await buttonText(btn) }; 409 479 }; 410 480 481 + const ensureNotReposted = async (row) => { 482 + const btn = row.getByTestId('repostBtn').first(); 483 + const before = await buttonText(btn); 484 + if (!/undo repost|remove repost/i.test(before)) { 485 + return { note: 'already not reposted' }; 486 + } 487 + await btn.click({ noWaitAfter: true }); 488 + await wait(1500); 489 + return { note: await buttonText(btn) }; 490 + }; 491 + 492 + const openProfileTab = async (name) => { 493 + const tab = page.getByRole('tab', { name }).first(); 494 + await tab.waitFor({ state: 'visible', timeout: 15000 }); 495 + await tab.click({ noWaitAfter: true }); 496 + await wait(2000); 497 + }; 498 + 499 + const maybeUnfollowTarget = async () => { 500 + const btn = page.getByTestId('unfollowBtn').first(); 501 + if (!(await btn.count())) { 502 + return { note: 'already not following target' }; 503 + } 504 + await btn.click({ noWaitAfter: true }); 505 + await wait(2000); 506 + return { note: 'unfollow attempted' }; 507 + }; 508 + 509 + const openPostOptions = async (row) => { 510 + const btn = row.getByTestId('postDropdownBtn').first(); 511 + await btn.click({ noWaitAfter: true }); 512 + const menu = page.locator('[role="menu"]').last(); 513 + await menu.waitFor({ state: 'visible', timeout: 10000 }); 514 + return menu; 515 + }; 516 + 517 + const deletePostRow = async (row) => { 518 + await openPostOptions(row); 519 + const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 520 + await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 521 + await deleteItem.click({ noWaitAfter: true }); 522 + const dialog = page.locator('[role="dialog"]').last(); 523 + await dialog.waitFor({ state: 'visible', timeout: 10000 }); 524 + const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 525 + await confirm.click({ noWaitAfter: true }); 526 + await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 527 + await wait(3000); 528 + }; 529 + 530 + const maybeDeleteOwnPostByText = async (text, successNote) => { 531 + const row = await maybeFindRowByPrimaryText(text, 10000); 532 + if (!row) { 533 + return { note: `not surfaced for cleanup: ${text}` }; 534 + } 535 + await deletePostRow(row); 536 + return { note: successNote }; 537 + }; 538 + 411 539 const openNotifications = async () => { 412 - await page.goto(`${config.appUrl.replace(/\/$/, '')}/notifications`, { 540 + await page.goto(`${appBaseUrl}/notifications`, { 413 541 waitUntil: 'domcontentloaded', 414 542 timeout: 60000, 415 543 }); ··· 420 548 } 421 549 }; 422 550 551 + const openSavedPosts = async () => { 552 + await page.goto(`${appBaseUrl}/saved`, { 553 + waitUntil: 'domcontentloaded', 554 + timeout: 60000, 555 + }); 556 + await wait(3000); 557 + }; 558 + 423 559 const verifyPublicHandleResolution = async () => { 424 560 const result = await pollJson( 425 561 'public handle resolution', ··· 456 592 postsCount: result.json.postsCount, 457 593 followersCount: result.json.followersCount, 458 594 followsCount: result.json.followsCount, 595 + avatar: result.json.avatar, 596 + description: result.json.description, 459 597 }; 460 598 }; 461 599 600 + const verifyPublicProfileAfterEdit = async () => { 601 + const result = await pollJson( 602 + 'public profile edit indexing', 603 + () => `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 604 + ({ ok, json }) => 605 + ok && 606 + json?.description === config.profileNote && 607 + typeof json?.avatar === 'string' && 608 + json.avatar.length > 0, 609 + config.publicCheckTimeoutMs ?? 180000, 610 + ); 611 + const avatarResult = await fetchStatus(result.json.avatar); 612 + if (!avatarResult.ok) { 613 + throw new Error(`public avatar URL returned ${avatarResult.status}`); 614 + } 615 + return { 616 + avatar: result.json.avatar, 617 + avatarStatus: avatarResult.status, 618 + description: result.json.description, 619 + }; 620 + }; 621 + 622 + const verifyLocalProfileAfterEdit = async () => { 623 + const didResult = await pollJson( 624 + 'local handle resolution after profile edit', 625 + () => `${config.pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 626 + ({ ok, json }) => ok && typeof json?.did === 'string' && json.did.length > 0, 627 + 30000, 628 + ); 629 + const did = didResult.json.did; 630 + const result = await pollJson( 631 + 'local profile record after edit', 632 + () => 633 + `${config.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, 634 + ({ ok, json }) => 635 + ok && 636 + json?.value?.description === config.profileNote && 637 + typeof json?.value?.avatar?.ref?.$link === 'string' && 638 + json.value.avatar.ref.$link.length > 0, 639 + 30000, 640 + ); 641 + return { 642 + did, 643 + avatarCid: result.json.value.avatar.ref.$link, 644 + description: result.json.value.description, 645 + }; 646 + }; 647 + 648 + const dismissModalBackdropIfPresent = async () => { 649 + const backdrop = page.locator('[aria-label*="click to close"]').last(); 650 + if (await backdrop.count()) { 651 + await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 652 + await wait(400); 653 + } 654 + }; 655 + 656 + const uploadProfileAvatar = async () => { 657 + const avatarFile = await ensureAvatarFixture(); 658 + let fileInputs = page.locator('input[type="file"]'); 659 + let count = await fileInputs.count(); 660 + 661 + if (count === 0) { 662 + const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 663 + if (await changeAvatar.count()) { 664 + await changeAvatar.click({ noWaitAfter: true }); 665 + await wait(500); 666 + const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 667 + if (await uploadFromFiles.count()) { 668 + const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 669 + await uploadFromFiles.click({ noWaitAfter: true }); 670 + const chooser = await chooserPromise; 671 + await chooser.setFiles(avatarFile); 672 + await wait(750); 673 + const editImageHeading = page.getByText(/^Edit image$/).last(); 674 + if (await editImageHeading.count()) { 675 + await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 676 + const cropSave = page.getByRole('button', { name: 'Save' }).last(); 677 + await cropSave.click({ noWaitAfter: true }); 678 + await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 679 + summary.notes.push('profile avatar crop saved'); 680 + } 681 + summary.notes.push('profile avatar uploaded via file chooser'); 682 + await wait(1500); 683 + return avatarFile; 684 + } 685 + } 686 + } 687 + 688 + if (count === 0) { 689 + throw new Error('profile avatar file input unavailable'); 690 + } 691 + 692 + await fileInputs.first().setInputFiles(avatarFile); 693 + await wait(1500); 694 + summary.notes.push(`edit profile file inputs: ${count}`); 695 + return avatarFile; 696 + }; 697 + 462 698 const editProfile = async () => { 463 699 const edit = page.getByRole('button', { name: /edit profile/i }); 464 700 if (!(await edit.count())) { ··· 466 702 } 467 703 await edit.click({ noWaitAfter: true }); 468 704 await wait(1000); 469 - const displayName = page.locator('input').filter({ has: page.locator('[aria-label="Name"]') }); 470 - const bio = page.locator('textarea,[contenteditable="true"],input').filter({ hasText: '' }); 471 - const bioField = page.getByLabel('Description').first(); 705 + await dismissModalBackdropIfPresent(); 706 + const avatarFile = await uploadProfileAvatar(); 707 + const bioField = page.locator('textarea[aria-label="Description"]').first(); 472 708 if (await bioField.count()) { 473 709 await bioField.fill(config.profileNote); 710 + const actual = await bioField.inputValue(); 711 + if (actual !== config.profileNote) { 712 + throw new Error(`profile description fill did not stick: ${actual}`); 713 + } 474 714 } 475 - const save = page.getByRole('button', { name: /save/i }).last(); 715 + const save = page.getByTestId('editProfileSaveBtn'); 716 + await save.waitFor({ state: 'visible', timeout: 15000 }); 717 + await page.waitForFunction(() => { 718 + const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 719 + return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 720 + }, undefined, { timeout: 15000 }); 476 721 await save.click({ noWaitAfter: true }); 722 + await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 723 + timeout: 15000, 724 + }); 477 725 await wait(3000); 726 + return { avatarFile, profileNote: config.profileNote }; 478 727 }; 479 728 480 729 try { ··· 486 735 await step('public-profile', verifyPublicProfile); 487 736 await step('public-author-feed', verifyPublicAuthorFeed); 488 737 } 489 - await step('own-profile', openOwnProfile); 738 + await step('own-profile', () => gotoProfile(config.handle)); 490 739 491 740 const ownPost = await step('find-own-post', async () => { 492 - await openOwnProfile(); 741 + await gotoProfile(config.handle); 493 742 await page.getByTestId('postsFeed').first().waitFor({ state: 'visible', timeout: 60000 }); 494 - const row = await findFeedItemByText(config.postText, 60000); 743 + const row = await findRowByPrimaryText(config.postText, 60000); 495 744 const rowTestId = await row.getAttribute('data-testid'); 496 745 return { note: 'found own post', rowFound: true, rowTestId }; 497 746 }); 498 747 499 748 if (ownPost) { 500 - const row = await findFeedItemByText(config.postText); 749 + const row = await findRowByPrimaryText(config.postText); 501 750 await step('like-own-post', () => ensureLiked(row), { optional: true }); 502 751 await step('repost-own-post', () => ensureReposted(row), { optional: true }); 503 752 await step('quote-own-post', () => clickQuote(row, config.quoteText), { optional: true }); 504 753 await step('reply-own-post', async () => { 505 - await openOwnProfile(); 754 + await gotoProfile(config.handle); 506 755 const refreshed = await findFeedItemByText(config.postText, 60000); 507 756 await clickReply(refreshed, config.replyText); 508 757 }, { optional: true }); 758 + await step('unlike-own-post', async () => { 759 + await gotoProfile(config.handle); 760 + const refreshed = await findRowByPrimaryText(config.postText, 60000); 761 + return ensureNotLiked(refreshed); 762 + }, { optional: true }); 763 + await step('undo-repost-own-post', async () => { 764 + await gotoProfile(config.handle); 765 + const refreshed = await findRowByPrimaryText(config.postText, 60000); 766 + return ensureNotReposted(refreshed); 767 + }, { optional: true }); 509 768 } 510 769 511 770 await step('target-profile', async () => { ··· 520 779 return { note: preview }; 521 780 }, { optional: true }); 522 781 782 + await step('bookmark-target-post', async () => { 783 + const row = await findFirstFeedItem(20000); 784 + return ensureBookmarked(row); 785 + }, { optional: true }); 786 + 787 + await step('saved-posts-page', async () => { 788 + await openSavedPosts(); 789 + const handleText = page.getByText(`@${config.targetHandle.replace(/^@/, '')}`).first(); 790 + await handleText.waitFor({ state: 'visible', timeout: 20000 }); 791 + return { note: `saved post by ${config.targetHandle}` }; 792 + }, { optional: true }); 793 + 523 794 await step('like-target-post', async () => { 795 + await gotoProfile(config.targetHandle); 524 796 const row = await findFirstFeedItem(20000); 525 797 return ensureLiked(row); 526 798 }, { optional: true }); 527 799 528 800 await step('repost-target-post', async () => { 801 + await gotoProfile(config.targetHandle); 529 802 const row = await findFirstFeedItem(20000); 530 803 return ensureReposted(row); 531 804 }, { optional: true }); ··· 544 817 return { note: 'replied to target post' }; 545 818 }, { optional: true }); 546 819 820 + await step('unlike-target-post', async () => { 821 + await gotoProfile(config.targetHandle); 822 + const row = await findFirstFeedItem(20000); 823 + return ensureNotLiked(row); 824 + }, { optional: true }); 825 + 826 + await step('undo-repost-target-post', async () => { 827 + await gotoProfile(config.targetHandle); 828 + const row = await findFirstFeedItem(20000); 829 + return ensureNotReposted(row); 830 + }, { optional: true }); 831 + 832 + await step('unbookmark-target-post', async () => { 833 + await gotoProfile(config.targetHandle); 834 + const row = await findFirstFeedItem(20000); 835 + return ensureNotBookmarked(row); 836 + }, { optional: true }); 837 + 838 + await step('unfollow-target', async () => { 839 + await gotoProfile(config.targetHandle); 840 + return maybeUnfollowTarget(); 841 + }, { optional: true }); 842 + 843 + await step('refollow-target', async () => { 844 + await gotoProfile(config.targetHandle); 845 + return maybeFollowTarget(); 846 + }, { optional: true }); 847 + 547 848 await step('notifications-page', async () => { 548 849 await openNotifications(); 549 850 const tab = page.getByRole('tab', { name: /all|priority/i }).first(); ··· 555 856 556 857 if (config.editProfile) { 557 858 await step('edit-profile', async () => { 558 - await openOwnProfile(); 859 + await gotoProfile(config.handle); 559 860 await editProfile(); 560 - }, { optional: true }); 861 + }); 862 + await step('local-profile-after-edit', verifyLocalProfileAfterEdit); 863 + if (config.publicChecks !== false) { 864 + await step('public-profile-after-edit', verifyPublicProfileAfterEdit); 865 + } 561 866 } 867 + 868 + await step('cleanup-own-posts-tab', async () => { 869 + await gotoProfile(config.handle); 870 + await openProfileTab('Posts'); 871 + return { note: 'opened own posts tab for cleanup' }; 872 + }, { optional: true }); 873 + 874 + await step('delete-own-target-quote', async () => { 875 + return maybeDeleteOwnPostByText( 876 + `${config.quoteText} to @${config.targetHandle.replace(/^@/, '')}`, 877 + 'deleted target quote post', 878 + ); 879 + }); 880 + 881 + await step('delete-own-quote-post', async () => { 882 + return maybeDeleteOwnPostByText(config.quoteText, 'deleted own quote post'); 883 + }); 884 + 885 + await step('delete-own-root-post', async () => { 886 + return maybeDeleteOwnPostByText(config.postText, 'deleted root smoke post'); 887 + }); 888 + 889 + await step('cleanup-own-replies-tab', async () => { 890 + await gotoProfile(config.handle); 891 + await openProfileTab('Replies'); 892 + return { note: 'opened own replies tab for cleanup' }; 893 + }, { optional: true }); 894 + 895 + await step('delete-own-target-reply', async () => { 896 + return maybeDeleteOwnPostByText( 897 + `${config.replyText} to @${config.targetHandle.replace(/^@/, '')}`, 898 + 'deleted target reply post', 899 + ); 900 + }); 901 + 902 + await step('delete-own-reply-post', async () => { 903 + return maybeDeleteOwnPostByText(config.replyText, 'deleted own reply post'); 904 + }); 562 905 } catch (error) { 563 906 summary.fatal = String(error?.message ?? error); 564 907 }