this repo has no description
32
fork

Configure Feed

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

Strengthen perlsky browser smoke coverage

alice 574ecb8b aa1cb7aa

+477 -21
+1
examples/perlsky-dual.json
··· 2 2 "pdsUrl": "https://perlsky.mosphere.at", 3 3 "artifactsDir": "data/browser-smoke/perlsky-dual", 4 4 "targetHandle": "alice.mosphere.at", 5 + "remoteReplyPostUrl": "https://bsky.app/profile/alice.mosphere.at/post/3mgu5lgnsnk22", 5 6 "strictErrors": true, 6 7 "primary": { 7 8 "handle": "smoke-primary.perlsky.mosphere.at",
+24
src/adapters/perlsky.mjs
··· 12 12 'perlsky browser secondary ', 13 13 ]); 14 14 15 + export const PERLSKY_REMOTE_REPLY_POST_URL = 16 + 'https://bsky.app/profile/alice.mosphere.at/post/3mgu5lgnsnk22'; 17 + 18 + const perlskyRoleDefaults = (role) => { 19 + if (role === 'secondary') { 20 + return { 21 + postText: 'perlsky browser secondary post', 22 + quoteText: 'perlsky browser secondary quote', 23 + replyText: 'perlsky browser secondary reply', 24 + profileNote: 'perlsky browser secondary profile edit', 25 + }; 26 + } 27 + 28 + return { 29 + postText: 'perlsky browser smoke post', 30 + quoteText: 'perlsky browser smoke quote', 31 + replyText: 'perlsky browser smoke reply', 32 + profileNote: 'perlsky browser smoke profile edit', 33 + }; 34 + }; 35 + 15 36 const createPerlskyExampleConfig = ({ mode }) => { 16 37 const base = { 17 38 pdsUrl: 'https://perlsky.mosphere.at', 18 39 artifactsDir: `data/browser-smoke/perlsky-${mode}`, 19 40 targetHandle: 'alice.mosphere.at', 41 + remoteReplyPostUrl: PERLSKY_REMOTE_REPLY_POST_URL, 20 42 strictErrors: true, 21 43 }; 22 44 ··· 54 76 55 77 return createAccountConfig({ 56 78 cleanupPostPrefixes, 79 + ...perlskyRoleDefaults(role), 57 80 ...account, 58 81 }); 59 82 }; ··· 78 101 ...rest 79 102 } = {}) => { 80 103 return createDualRunConfig({ 104 + remoteReplyPostUrl: PERLSKY_REMOTE_REPLY_POST_URL, 81 105 ...rest, 82 106 adapter: 'perlsky', 83 107 primary: createPerlskyAccountConfig({
+200 -15
src/browser/lib/dual-actions.mjs
··· 14 14 xrpcJson, 15 15 avatarPngBase64, 16 16 }) => { 17 + const parseCompactCount = (raw) => { 18 + if (typeof raw !== 'string') { 19 + return undefined; 20 + } 21 + const normalized = raw.replace(/,/g, '').trim(); 22 + const match = normalized.match(/^([0-9]+(?:\.[0-9]+)?)([KMB])?$/i); 23 + if (!match) { 24 + return undefined; 25 + } 26 + const base = Number(match[1]); 27 + const suffix = (match[2] || '').toUpperCase(); 28 + const multiplier = suffix === 'K' ? 1_000 : suffix === 'M' ? 1_000_000 : suffix === 'B' ? 1_000_000_000 : 1; 29 + return Math.round(base * multiplier); 30 + }; 31 + 17 32 const ensureAvatarFixture = async () => { 18 33 const file = path.join(config.artifactsDir, 'avatar-fixture.png'); 19 34 await fs.writeFile(file, Buffer.from(avatarPngBase64, 'base64')); ··· 68 83 await page.getByText(handleText).first().waitFor({ state: 'visible', timeout }); 69 84 }; 70 85 86 + const readRenderedProfileCounts = async (page) => { 87 + const raw = await page.evaluate(() => { 88 + const normalize = (text) => (text || '').replace(/\s+/g, ' ').trim(); 89 + const entries = Array.from(document.querySelectorAll('a[href]')).map((node) => ({ 90 + href: node.getAttribute('href') || '', 91 + text: normalize(node.textContent || ''), 92 + })); 93 + const pick = (pattern) => entries.find((entry) => pattern.test(entry.href))?.text; 94 + return { 95 + followersText: pick(/\/followers(?:[/?#]|$)/i), 96 + followsText: pick(/\/follows(?:[/?#]|$)/i), 97 + }; 98 + }); 99 + 100 + const parseLinkedCount = (text, label) => { 101 + if (typeof text !== 'string' || !text.length) { 102 + throw new Error(`rendered ${label} link text not found`); 103 + } 104 + const normalized = text.replace(/\s+/g, ' ').trim(); 105 + const match = normalized.match(/([0-9][0-9.,]*\s*[KMB]?)/i); 106 + if (!match) { 107 + throw new Error(`unable to parse rendered ${label} count from "${normalized}"`); 108 + } 109 + const value = parseCompactCount(match[1].replace(/\s+/g, '')); 110 + if (value === undefined) { 111 + throw new Error(`unable to normalize rendered ${label} count from "${normalized}"`); 112 + } 113 + return value; 114 + }; 115 + 116 + return { 117 + followersCount: parseLinkedCount(raw.followersText, 'followers'), 118 + followsCount: parseLinkedCount(raw.followsText, 'follows'), 119 + raw, 120 + }; 121 + }; 122 + 123 + const verifyProfileCountsAfterReload = async (page, viewerAccount, profileHandle, expected, timeoutMs = 30000) => { 124 + const started = Date.now(); 125 + let lastRendered; 126 + let lastApi; 127 + while (Date.now() - started < timeoutMs) { 128 + await gotoProfile(page, profileHandle); 129 + await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); 130 + await wait(page, 3000); 131 + await waitForProfileHandle(page, profileHandle); 132 + lastRendered = await readRenderedProfileCounts(page); 133 + const apiResult = await xrpcJson('app.bsky.actor.getProfile', { 134 + token: viewerAccount?.accessJwt, 135 + params: { actor: profileHandle }, 136 + timeoutMs: 15000, 137 + }); 138 + if (apiResult.ok) { 139 + lastApi = { 140 + followersCount: apiResult.json?.followersCount, 141 + followsCount: apiResult.json?.followsCount, 142 + }; 143 + const matches = Object.entries(expected).every(([key, value]) => 144 + lastRendered?.[key] === value && lastApi?.[key] === value); 145 + if (matches) { 146 + return { 147 + rendered: lastRendered, 148 + api: lastApi, 149 + }; 150 + } 151 + } 152 + await wait(page, 2000); 153 + } 154 + 155 + throw new Error( 156 + `profile counts for ${profileHandle} did not converge; expected=${JSON.stringify(expected)} rendered=${JSON.stringify(lastRendered)} api=${JSON.stringify(lastApi)}`, 157 + ); 158 + }; 159 + 160 + const readProfileCountsAfterReload = async (page, viewerAccount, profileHandle) => { 161 + await gotoProfile(page, profileHandle); 162 + await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); 163 + await wait(page, 3000); 164 + await waitForProfileHandle(page, profileHandle); 165 + const rendered = await readRenderedProfileCounts(page); 166 + const apiResult = await xrpcJson('app.bsky.actor.getProfile', { 167 + token: viewerAccount?.accessJwt, 168 + params: { actor: profileHandle }, 169 + timeoutMs: 15000, 170 + }); 171 + if (!apiResult.ok) { 172 + throw new Error(`failed to read profile counts for ${profileHandle}`); 173 + } 174 + return { 175 + rendered, 176 + api: { 177 + followersCount: apiResult.json?.followersCount, 178 + followsCount: apiResult.json?.followsCount, 179 + }, 180 + }; 181 + }; 182 + 71 183 const composePost = async (page, text) => { 72 184 await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 73 185 await wait(page, 800); ··· 273 385 } 274 386 }; 275 387 388 + const findFirstFeedItem = async (page, timeout = 20000) => { 389 + const started = Date.now(); 390 + while (Date.now() - started < timeout) { 391 + const rows = page.locator('[data-testid^="feedItem-by-"]'); 392 + const count = await rows.count(); 393 + if (count > 0) { 394 + const row = rows.first(); 395 + await row.waitFor({ state: 'visible', timeout: 10000 }); 396 + return row; 397 + } 398 + await wait(page, 500); 399 + } 400 + throw new Error('feed item not found'); 401 + }; 402 + 276 403 const clickLike = async (page, row) => { 277 404 const btn = row.getByTestId('likeBtn').first(); 278 405 await btn.click({ noWaitAfter: true }); ··· 374 501 return { note: await buttonText(btn) }; 375 502 }; 376 503 377 - const waitForVisibleEditor = async (page) => { 378 - const editors = page.locator('[aria-label="Rich-Text Editor"]'); 504 + const visibleEditorLocator = (page) => 505 + page.locator( 506 + [ 507 + '[aria-label="Rich-Text Editor"]', 508 + '[contenteditable="true"][role="textbox"]', 509 + '[contenteditable="true"][aria-multiline="true"]', 510 + ].join(', '), 511 + ); 512 + 513 + const waitForVisibleEditor = async (page, timeout = 20000) => { 514 + const editors = visibleEditorLocator(page); 379 515 const started = Date.now(); 380 - while (Date.now() - started < 20000) { 516 + while (Date.now() - started < timeout) { 381 517 const count = await editors.count(); 382 518 for (let i = count - 1; i >= 0; i -= 1) { 383 519 const editor = editors.nth(i); ··· 388 524 await wait(page, 250); 389 525 } 390 526 throw new Error('visible rich-text editor not found'); 527 + }; 528 + 529 + const firstVisibleLocator = async (locator) => { 530 + const count = await locator.count(); 531 + for (let i = count - 1; i >= 0; i -= 1) { 532 + const candidate = locator.nth(i); 533 + if (await candidate.isVisible().catch(() => false)) { 534 + return candidate; 535 + } 536 + } 537 + return null; 391 538 }; 392 539 393 540 const publishComposer = async (page, text, { applyWritesLabel, publishLabel }) => { ··· 435 582 }; 436 583 437 584 const clickReply = async (page, row, text) => { 438 - await dismissBlockingOverlays(page); 439 - const btn = row.getByTestId('replyBtn').first(); 440 - await btn.click({ noWaitAfter: true }); 441 - await wait(page, 1000); 585 + const openReplyComposer = async (scope) => { 586 + const editor = await waitForVisibleEditor(page, 750).catch(() => null); 587 + if (editor) { 588 + return true; 589 + } 590 + 591 + const composeReply = await firstVisibleLocator(page.getByRole('button', { name: /compose reply/i })); 592 + if (composeReply) { 593 + await composeReply.click({ noWaitAfter: true }); 594 + await wait(page, 500); 595 + const afterComposeClick = await waitForVisibleEditor(page, 2000).catch(() => null); 596 + if (afterComposeClick) { 597 + return true; 598 + } 599 + } 442 600 443 - const composeReply = page.getByRole('button', { name: /compose reply/i }).last(); 444 - if (await composeReply.count()) { 445 - await composeReply.click({ noWaitAfter: true }); 446 - await wait(page, 500); 447 - } else { 448 - const writeYourReply = page.getByText(/^Write your reply$/).last(); 449 - if (await writeYourReply.count()) { 450 - await writeYourReply.click({ noWaitAfter: true }); 601 + const writeYourReply = await firstVisibleLocator(page.getByText(/Write your reply/i)); 602 + if (writeYourReply) { 603 + await writeYourReply.click({ noWaitAfter: true, force: true }); 451 604 await wait(page, 500); 605 + const afterInlineClick = await waitForVisibleEditor(page, 2000).catch(() => null); 606 + if (afterInlineClick) { 607 + return true; 608 + } 452 609 } 610 + 611 + const btn = await firstVisibleLocator(scope.getByTestId('replyBtn')); 612 + if (!btn) { 613 + return false; 614 + } 615 + 616 + await btn.scrollIntoViewIfNeeded().catch(() => undefined); 617 + await btn.click({ noWaitAfter: true, force: true }); 618 + await wait(page, 1000); 619 + await dismissBlockingOverlays(page); 620 + return true; 621 + }; 622 + 623 + await dismissBlockingOverlays(page); 624 + 625 + await openReplyComposer(row); 626 + const firstAttempt = await waitForVisibleEditor(page, 4000).catch(() => null); 627 + if (!firstAttempt) { 628 + const postText = row.getByTestId('postText').first(); 629 + if (await postText.count()) { 630 + await postText.click({ noWaitAfter: true, force: true }).catch(() => undefined); 631 + await wait(page, 1500); 632 + await dismissBlockingOverlays(page); 633 + } 634 + await openReplyComposer(page); 453 635 } 454 636 455 637 await publishComposer(page, text, { ··· 688 870 completeAgeAssuranceIfNeeded, 689 871 gotoProfile, 690 872 waitForProfileHandle, 873 + verifyProfileCountsAfterReload, 874 + readProfileCountsAfterReload, 875 + findFirstFeedItem, 691 876 composePost, 692 877 composePostWithImage, 693 878 editProfile,
+23 -3
src/browser/lib/dual-browser.mjs
··· 14 14 { url: /workers\.dev\/api\/config/i, error: /ERR_ABORTED/i }, 15 15 { url: /app-config\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 16 16 { url: /live-events\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 17 + { url: /cdn\.bsky\.app\/img\/avatar_thumbnail\//i, error: /ERR_ABORTED/i }, 17 18 { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 18 19 { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 19 20 { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts|.*\.vtt)/i, error: /ERR_ABORTED/i }, 20 21 { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 21 22 { url: /\/xrpc\/app\.bsky\.graph\.(?:muteActor|unmuteActor)/i, error: /ERR_ABORTED/i }, 23 + { url: /\/xrpc\/com\.atproto\.identity\.resolveHandle/i, error: /ERR_ABORTED/i }, 24 + { url: /\/xrpc\/app\.bsky\.feed\.getAuthorFeed/i, error: /ERR_ABORTED/i }, 25 + { url: /\/xrpc\/app\.bsky\.graph\.getSuggestedFollowsByActor/i, error: /ERR_ABORTED/i }, 26 + { url: /\/xrpc\/chat\.bsky\.convo\.getConvoAvailability/i, error: /ERR_ABORTED/i }, 22 27 ]; 23 28 24 29 const ignoredHttpFailure = [ 25 30 { url: /c\.1password\.com\/richicons/i, status: 404 }, 26 31 { url: /\/xrpc\/app\.bsky\.graph\.getList\?/, status: 400 }, 32 + { url: /\/xrpc\/app\.bsky\.feed\.getAuthorFeed\?/, status: 400 }, 27 33 ]; 28 34 29 35 const browserCandidates = async (config) => { ··· 146 152 147 153 export const createDualStepHelpers = ({ config, summary, primaryPage, secondaryPage }) => { 148 154 const stepTimeoutMs = Number(config.stepTimeoutMs || 120000); 155 + const progressEnabled = config.progress !== false; 149 156 const pageFor = (name) => (name === 'primary' ? primaryPage : secondaryPage); 157 + 158 + const emitProgress = (status, name, detail = '') => { 159 + if (!progressEnabled) { 160 + return; 161 + } 162 + const timestamp = new Date().toISOString(); 163 + const suffix = detail ? ` ${detail}` : ''; 164 + console.error(`[${timestamp}] [${status}] ${name}${suffix}`); 165 + }; 150 166 151 167 const screenshot = async (pageName, name) => { 152 168 const page = pageFor(pageName); ··· 179 195 (rule) => rule.url.test(entry.url || '') && (!rule.status || rule.status === entry.status), 180 196 ); 181 197 182 - const step = async (name, fn, { optional = false, pageNames = [] } = {}) => { 198 + const step = async (name, fn, { optional = false, pageNames = [], timeoutMs } = {}) => { 199 + const effectiveTimeoutMs = Number(timeoutMs || stepTimeoutMs); 200 + emitProgress('start', name); 183 201 try { 184 202 const result = await Promise.race([ 185 203 fn(), 186 204 new Promise((_, reject) => { 187 205 setTimeout(() => { 188 - reject(new Error(`step timed out after ${stepTimeoutMs}ms`)); 189 - }, stepTimeoutMs); 206 + reject(new Error(`step timed out after ${effectiveTimeoutMs}ms`)); 207 + }, effectiveTimeoutMs); 190 208 }), 191 209 ]); 192 210 const screenshots = {}; ··· 194 212 screenshots[pageName] = await screenshot(pageName, name); 195 213 } 196 214 recordStep(name, 'ok', { screenshots, ...(result ?? {}) }); 215 + emitProgress('ok', name); 197 216 return result; 198 217 } catch (error) { 199 218 const screenshots = {}; ··· 204 223 screenshots, 205 224 error: String(error?.message ?? error), 206 225 }); 226 + emitProgress(optional ? 'skip' : 'fail', name, String(error?.message ?? error)); 207 227 if (!optional) { 208 228 throw error; 209 229 }
+153 -2
src/browser/lib/dual-scenario.mjs
··· 1 1 export const runDualScenario = async ({ 2 + config, 2 3 step, 3 4 primaryPage, 4 5 secondaryPage, ··· 12 13 waitForOwnPostRecord, 13 14 gotoProfile, 14 15 waitForProfileHandle, 16 + verifyProfileCountsAfterReload, 17 + readProfileCountsAfterReload, 15 18 findRowByPrimaryText, 16 19 composePostWithImage, 17 20 editProfile, ··· 73 76 await step('primary-preclean-stale-artifacts', async () => cleanupStaleSmokeArtifacts(primary)); 74 77 await step('secondary-preclean-stale-artifacts', async () => cleanupStaleSmokeArtifacts(secondary)); 75 78 79 + await step('primary-preclean-reset-follow-secondary', async () => { 80 + await gotoProfile(primaryPage, secondary.handle); 81 + await waitForProfileHandle(primaryPage, secondary.handle); 82 + return maybeUnfollow(primaryPage); 83 + }, { optional: true, pageNames: ['primary'] }); 84 + 85 + await step('secondary-preclean-reset-follow-primary', async () => { 86 + await gotoProfile(secondaryPage, primary.handle); 87 + await waitForProfileHandle(secondaryPage, primary.handle); 88 + return maybeUnfollow(secondaryPage); 89 + }, { optional: true, pageNames: ['secondary'] }); 90 + 76 91 await step('primary-compose-root-post', () => composePost(primaryPage, primary.postText), { 77 92 pageNames: ['primary'], 78 93 }); ··· 87 102 return { rowTestId }; 88 103 }, { pageNames: ['primary'] }); 89 104 105 + await step('primary-own-profile-reload', async () => { 106 + await gotoProfile(primaryPage, primary.handle); 107 + await primaryPage.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); 108 + await waitForProfileHandle(primaryPage, primary.handle); 109 + const row = await findRowByPrimaryText(primaryPage, primary.postText, 60000); 110 + const rowTestId = await row.getAttribute('data-testid'); 111 + return { rowTestId }; 112 + }, { pageNames: ['primary'] }); 113 + 90 114 await step('primary-compose-image-post', async () => composePostWithImage(primaryPage, primary.mediaPostText), { 91 115 pageNames: ['primary'], 92 116 }); ··· 118 142 return { rowTestId }; 119 143 }, { pageNames: ['secondary'] }); 120 144 145 + await step('secondary-own-profile-reload', async () => { 146 + await gotoProfile(secondaryPage, secondary.handle); 147 + await secondaryPage.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); 148 + await waitForProfileHandle(secondaryPage, secondary.handle); 149 + const row = await findRowByPrimaryText(secondaryPage, secondary.postText, 60000); 150 + const rowTestId = await row.getAttribute('data-testid'); 151 + return { rowTestId }; 152 + }, { pageNames: ['secondary'] }); 153 + 121 154 await step('primary-edit-profile', () => editProfile(primaryPage, primary), { 122 155 pageNames: ['primary'], 123 156 }); 124 157 125 158 await step('primary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(primary)); 126 - await step('primary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(primary)); 159 + await step('primary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(primary), { 160 + timeoutMs: Math.max(Number(config.publicCheckTimeoutMs || 180000) + 15000, 195000), 161 + }); 127 162 128 163 await step('secondary-edit-profile', () => editProfile(secondaryPage, secondary), { 129 164 pageNames: ['secondary'], 130 165 }); 131 166 132 167 await step('secondary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(secondary)); 133 - await step('secondary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(secondary)); 168 + await step('secondary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(secondary), { 169 + timeoutMs: Math.max(Number(config.publicCheckTimeoutMs || 180000) + 15000, 195000), 170 + }); 171 + 172 + await step('primary-baseline-profile-counts', async () => { 173 + primary.baselineCounts = await readProfileCountsAfterReload(primaryPage, primary, primary.handle); 174 + return primary.baselineCounts; 175 + }, { pageNames: ['primary'] }); 176 + 177 + await step('secondary-baseline-profile-counts', async () => { 178 + secondary.baselineCounts = await readProfileCountsAfterReload(secondaryPage, secondary, secondary.handle); 179 + return secondary.baselineCounts; 180 + }, { pageNames: ['secondary'] }); 134 181 135 182 await step('primary-create-list', async () => { 136 183 return createList(primaryPage, primary.listName, primary.listDescription); ··· 229 276 return { uri: record.uri }; 230 277 }); 231 278 279 + await step('primary-own-profile-counts-after-follow', async () => { 280 + return verifyProfileCountsAfterReload(primaryPage, primary, primary.handle, { 281 + followsCount: (primary.baselineCounts?.api?.followsCount ?? 0) + 1, 282 + }); 283 + }, { pageNames: ['primary'] }); 284 + 285 + await step('secondary-own-profile-counts-after-being-followed', async () => { 286 + return verifyProfileCountsAfterReload(secondaryPage, secondary, secondary.handle, { 287 + followersCount: (secondary.baselineCounts?.api?.followersCount ?? 0) + 1, 288 + }); 289 + }, { pageNames: ['secondary'] }); 290 + 232 291 await step('primary-like-secondary-post', async () => { 292 + await gotoProfile(primaryPage, secondary.handle); 233 293 const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 234 294 return ensureLiked(primaryPage, row); 235 295 }, { pageNames: ['primary'] }); 236 296 237 297 await step('primary-bookmark-secondary-post', async () => { 298 + await gotoProfile(primaryPage, secondary.handle); 238 299 const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 239 300 return ensureBookmarked(primaryPage, row); 240 301 }, { pageNames: ['primary'] }); ··· 270 331 return { replyText: primary.replyText, uri: primary.replyPost.uri }; 271 332 }, { pageNames: ['primary'] }); 272 333 334 + if (config.remoteReplyPostUrl) { 335 + const remoteReplyText = `${primary.replyText} remote`; 336 + const remoteReplyHandleMatch = config.remoteReplyPostUrl.match(/\/profile\/([^/]+)\/post\//); 337 + const remoteReplyHandle = remoteReplyHandleMatch ? decodeURIComponent(remoteReplyHandleMatch[1]) : undefined; 338 + 339 + if (remoteReplyHandle) { 340 + await step('primary-prepare-configured-remote-reply-target', async () => { 341 + await gotoProfile(primaryPage, remoteReplyHandle); 342 + await waitForProfileHandle(primaryPage, remoteReplyHandle); 343 + const wasFollowing = (await primaryPage.getByTestId('unfollowBtn').first().count()) > 0; 344 + primary.remoteReplyWasFollowingTarget = wasFollowing; 345 + if (!wasFollowing) { 346 + await maybeFollow(primaryPage); 347 + } 348 + return { 349 + remoteReplyHandle, 350 + wasFollowing, 351 + nowFollowing: (await primaryPage.getByTestId('unfollowBtn').first().count()) > 0, 352 + }; 353 + }, { pageNames: ['primary'] }); 354 + } 355 + 356 + await step('primary-reply-configured-remote-post', async () => { 357 + await primaryPage.goto(config.remoteReplyPostUrl, { 358 + waitUntil: 'domcontentloaded', 359 + timeout: 60000, 360 + }); 361 + await primaryPage.getByTestId('replyBtn').first().waitFor({ 362 + state: 'visible', 363 + timeout: 20000, 364 + }); 365 + await clickReply(primaryPage, primaryPage, remoteReplyText); 366 + primary.remoteReplyPost = await waitForOwnPostRecord(primary, remoteReplyText); 367 + return { 368 + replyText: remoteReplyText, 369 + uri: primary.remoteReplyPost.uri, 370 + remoteReplyPostUrl: config.remoteReplyPostUrl, 371 + remoteReplyHandle, 372 + }; 373 + }, { pageNames: ['primary'] }); 374 + } 375 + 273 376 await step('secondary-notification-api-primary-engagement-wave', async () => { 274 377 const result = await pollNotifications({ 275 378 account: secondary, ··· 309 412 return { uri: record.uri }; 310 413 }); 311 414 415 + await step('secondary-own-profile-counts-after-follow', async () => { 416 + return verifyProfileCountsAfterReload(secondaryPage, secondary, secondary.handle, { 417 + followersCount: (secondary.baselineCounts?.api?.followersCount ?? 0) + 1, 418 + followsCount: (secondary.baselineCounts?.api?.followsCount ?? 0) + 1, 419 + }); 420 + }, { pageNames: ['secondary'] }); 421 + 422 + await step('primary-own-profile-counts-after-being-followed', async () => { 423 + return verifyProfileCountsAfterReload(primaryPage, primary, primary.handle, { 424 + followersCount: (primary.baselineCounts?.api?.followersCount ?? 0) + 1, 425 + followsCount: (primary.baselineCounts?.api?.followsCount ?? 0) + 1, 426 + }); 427 + }, { pageNames: ['primary'] }); 428 + 312 429 await step('primary-notification-api-secondary-follow', async () => { 313 430 const result = await pollNotifications({ 314 431 account: primary, ··· 438 555 return maybeUnfollow(secondaryPage); 439 556 }, { optional: true, pageNames: ['secondary'] }); 440 557 558 + if (config.remoteReplyPostUrl && primary.remoteReplyWasFollowingTarget === false) { 559 + const remoteReplyHandleMatch = config.remoteReplyPostUrl.match(/\/profile\/([^/]+)\/post\//); 560 + const remoteReplyHandle = remoteReplyHandleMatch ? decodeURIComponent(remoteReplyHandleMatch[1]) : undefined; 561 + if (remoteReplyHandle) { 562 + await step('primary-cleanup-remote-reply-target-follow', async () => { 563 + await gotoProfile(primaryPage, remoteReplyHandle); 564 + await waitForProfileHandle(primaryPage, remoteReplyHandle); 565 + return maybeUnfollow(primaryPage); 566 + }, { optional: true, pageNames: ['primary'] }); 567 + } 568 + } 569 + 570 + await step('primary-own-profile-counts-after-unfollow-cleanup', async () => { 571 + return verifyProfileCountsAfterReload(primaryPage, primary, primary.handle, { 572 + followersCount: primary.baselineCounts?.api?.followersCount ?? 0, 573 + followsCount: primary.baselineCounts?.api?.followsCount ?? 0, 574 + }); 575 + }, { pageNames: ['primary'] }); 576 + 577 + await step('secondary-own-profile-counts-after-unfollow-cleanup', async () => { 578 + return verifyProfileCountsAfterReload(secondaryPage, secondary, secondary.handle, { 579 + followersCount: secondary.baselineCounts?.api?.followersCount ?? 0, 580 + followsCount: secondary.baselineCounts?.api?.followsCount ?? 0, 581 + }); 582 + }, { pageNames: ['secondary'] }); 583 + 441 584 await step('primary-cleanup-delete-quote', async () => { 442 585 await gotoProfile(primaryPage, primary.handle); 443 586 await openProfileTab(primaryPage, 'Posts'); ··· 455 598 await openProfileTab(primaryPage, 'Replies'); 456 599 return maybeDeleteOwnPostByText(primaryPage, primary.replyText, 'deleted reply post'); 457 600 }, { optional: true, pageNames: ['primary'] }); 601 + 602 + if (config.remoteReplyPostUrl) { 603 + await step('primary-cleanup-delete-remote-reply', async () => { 604 + await gotoProfile(primaryPage, primary.handle); 605 + await openProfileTab(primaryPage, 'Replies'); 606 + return maybeDeleteOwnPostByText(primaryPage, `${primary.replyText} remote`, 'deleted remote reply post'); 607 + }, { optional: true, pageNames: ['primary'] }); 608 + } 458 609 459 610 await step('secondary-cleanup-delete-root-post', async () => { 460 611 await gotoProfile(secondaryPage, secondary.handle);
+16 -1
src/browser/lib/lists.mjs
··· 1 1 export const createListHelpers = ({ appBaseUrl, wait }) => { 2 + const waitForListPageReady = async (page, timeout = 30000) => { 3 + const started = Date.now(); 4 + while (Date.now() - started < timeout) { 5 + const moreOptions = page.getByTestId('moreOptionsBtn').first(); 6 + if (await moreOptions.isVisible().catch(() => false)) { 7 + return; 8 + } 9 + await wait(page, 1500); 10 + await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => undefined); 11 + await wait(page, 2000); 12 + } 13 + throw new Error('list page did not become interactive'); 14 + }; 15 + 2 16 const openLists = async (page) => { 3 17 await page.goto(`${appBaseUrl}/lists`, { 4 18 waitUntil: 'domcontentloaded', ··· 20 34 }, 21 35 ); 22 36 await wait(page, 3000); 37 + await waitForListPageReady(page); 23 38 }; 24 39 25 40 const waitForListTitle = async (page, title, timeout = 20000) => { ··· 140 155 141 156 const removeUserFromCurrentList = async (page, handle) => { 142 157 await openListPeopleTab(page); 143 - await page.getByText(`@${handle.replace(/^@/, '')}`).first().waitFor({ state: 'visible', timeout: 15000 }); 144 158 const edit = page.getByTestId(`user-${handle}-editBtn`).first(); 145 159 await edit.waitFor({ state: 'visible', timeout: 15000 }); 146 160 await edit.click({ noWaitAfter: true }); ··· 161 175 162 176 return { 163 177 openLists, 178 + waitForListPageReady, 164 179 openListPage, 165 180 waitForListTitle, 166 181 fillListEditor,
+6
src/browser/run-dual.mjs
··· 18 18 pdsUrl: config.pdsUrl, 19 19 publicApiUrl: config.publicApiUrl, 20 20 targetHandle: config.targetHandle, 21 + remoteReplyPostUrl: config.remoteReplyPostUrl, 21 22 primaryHandle: config.primary?.handle, 22 23 secondaryHandle: config.secondary?.handle, 23 24 steps: [], ··· 86 87 completeAgeAssuranceIfNeeded, 87 88 gotoProfile, 88 89 waitForProfileHandle, 90 + verifyProfileCountsAfterReload, 91 + readProfileCountsAfterReload, 89 92 composePost, 90 93 composePostWithImage, 91 94 editProfile, ··· 128 131 129 132 try { 130 133 await runDualScenario({ 134 + config, 131 135 step, 132 136 primaryPage, 133 137 secondaryPage, ··· 141 145 waitForOwnPostRecord, 142 146 gotoProfile, 143 147 waitForProfileHandle, 148 + verifyProfileCountsAfterReload, 149 + readProfileCountsAfterReload, 144 150 findRowByPrimaryText, 145 151 composePostWithImage, 146 152 editProfile,
+19
src/browser/run-single.mjs
··· 36 36 { url: /workers\.dev\/api\/config/i, error: /ERR_ABORTED/i }, 37 37 { url: /app-config\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 38 38 { url: /live-events\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 39 + { url: /cdn\.bsky\.app\/img\/avatar_thumbnail\//i, error: /ERR_ABORTED/i }, 39 40 { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 40 41 { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 41 42 { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts)/i, error: /ERR_ABORTED/i }, 42 43 { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 44 + { url: /\/xrpc\/com\.atproto\.identity\.resolveHandle/i, error: /ERR_ABORTED/i }, 45 + { url: /\/xrpc\/app\.bsky\.feed\.getAuthorFeed/i, error: /ERR_ABORTED/i }, 46 + { url: /\/xrpc\/app\.bsky\.graph\.getSuggestedFollowsByActor/i, error: /ERR_ABORTED/i }, 47 + { url: /\/xrpc\/chat\.bsky\.convo\.getConvoAvailability/i, error: /ERR_ABORTED/i }, 43 48 ]; 44 49 45 50 const ignoredHttpFailure = [ 46 51 { url: /c\.1password\.com\/richicons/i, status: 404 }, 52 + { url: /\/xrpc\/app\.bsky\.feed\.getAuthorFeed\?/, status: 400 }, 47 53 ]; 54 + const progressEnabled = config.progress !== false; 55 + 56 + const emitProgress = (status, name, detail = '') => { 57 + if (!progressEnabled) { 58 + return; 59 + } 60 + const timestamp = new Date().toISOString(); 61 + const suffix = detail ? ` ${detail}` : ''; 62 + console.error(`[${timestamp}] [${status}] ${name}${suffix}`); 63 + }; 48 64 49 65 const AVATAR_PNG_BASE64 = 50 66 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAV0lEQVR4nO3PQQ0AIBDAMMC/58MCP7KkVbDX1pk5A6gWUC2gWkC1gGoB1QKqBVQLqBZQLaBaQLWAagHVAqoFVAuoFlAtoFpAtYBqAdUCqgVUC6gWUC2gWkD1B4a2AX/y3CvgAAAAAElFTkSuQmCC'; ··· 178 194 ); 179 195 180 196 const step = async (name, fn, { optional = false } = {}) => { 197 + emitProgress('start', name); 181 198 try { 182 199 const result = await fn(); 183 200 const shot = await screenshot(name); 184 201 recordStep(name, 'ok', { screenshot: shot, ...(result ?? {}) }); 202 + emitProgress('ok', name); 185 203 return result; 186 204 } catch (error) { 187 205 const shot = await screenshot(`${name}-error`).catch(() => undefined); ··· 189 207 screenshot: shot, 190 208 error: String(error?.message ?? error), 191 209 }); 210 + emitProgress(optional ? 'skip' : 'fail', name, String(error?.message ?? error)); 192 211 if (!optional) { 193 212 throw error; 194 213 }
+9
src/cli.mjs
··· 12 12 Notes: 13 13 - bring-your-own is the default adapter 14 14 - v1 is browser-first against bsky.app 15 + - run commands print step progress to stderr by default 16 + - add --json-only to suppress progress and keep stdout machine-only 15 17 - direct API/AppView contract layers are documented as a later v2 expansion 16 18 `; 17 19 ··· 41 43 } 42 44 if (arg === '--help' || arg === '-h' || arg === 'help') { 43 45 result.help = true; 46 + continue; 47 + } 48 + if (arg === '--json-only') { 49 + result.jsonOnly = true; 44 50 continue; 45 51 } 46 52 throw new Error(`unknown argument: ${arg}`); ··· 142 148 143 149 const raw = await loadJsonConfig(args.configPath); 144 150 const config = normalizeConfig({ mode, adapter: adapter.name, raw }); 151 + if (args.command === 'run-single' || args.command === 'run-dual') { 152 + config.progress = !args.jsonOnly; 153 + } 145 154 146 155 if (args.command === 'validate') { 147 156 console.log(JSON.stringify(config, null, 2));
+26
src/config.mjs
··· 27 27 return trimmed === '' ? undefined : trimmed; 28 28 }; 29 29 30 + const optionalPostUrl = (value, label) => { 31 + const maybe = optionalString(value); 32 + if (!maybe) { 33 + return undefined; 34 + } 35 + let url; 36 + try { 37 + url = new URL(maybe); 38 + } catch { 39 + throw new Error(`${label} must be a valid URL when provided`); 40 + } 41 + if (!/^https?:$/.test(url.protocol)) { 42 + throw new Error(`${label} must use http or https`); 43 + } 44 + if (!/\/profile\/[^/]+\/post\/[^/?#]+/.test(url.pathname)) { 45 + throw new Error(`${label} must point at a post URL`); 46 + } 47 + return url.toString(); 48 + }; 49 + 30 50 const normalizeCleanupPrefixes = (prefixes) => { 31 51 if (prefixes === undefined) { 32 52 return []; ··· 107 127 publicCheckTimeoutMs = DEFAULTS.publicCheckTimeoutMs, 108 128 stepTimeoutMs = DEFAULTS.stepTimeoutMs, 109 129 targetHandle, 130 + remoteReplyPostUrl, 110 131 headless = DEFAULTS.headless, 111 132 strictErrors = DEFAULTS.strictErrors, 112 133 publicChecks = DEFAULTS.publicChecks, ··· 135 156 const maybeTarget = optionalString(targetHandle); 136 157 if (maybeTarget) { 137 158 normalized.targetHandle = maybeTarget; 159 + } 160 + 161 + const maybeRemoteReplyPostUrl = optionalPostUrl(remoteReplyPostUrl, 'remoteReplyPostUrl'); 162 + if (maybeRemoteReplyPostUrl) { 163 + normalized.remoteReplyPostUrl = maybeRemoteReplyPostUrl; 138 164 } 139 165 140 166 const maybeBrowserExecutablePath = optionalString(browserExecutablePath);