this repo has no description
32
fork

Configure Feed

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

Refactor shared browser actions and trim dead helpers

alice 390d216d 8e94abb3

+717 -901
+3 -7
src/adapters/bring-your-own.mjs
··· 36 36 }; 37 37 }; 38 38 39 - export const createBringYourOwnAccount = (account = {}) => { 40 - return createAccountConfig(account); 41 - }; 42 - 43 39 export const createBringYourOwnSingleConfig = ({ 44 40 account, 45 41 ...rest 46 42 } = {}) => { 47 43 return createSingleRunConfig({ 48 44 ...rest, 49 - account: createBringYourOwnAccount(account), 45 + account: createAccountConfig(account), 50 46 }); 51 47 }; 52 48 ··· 57 53 } = {}) => { 58 54 return createDualRunConfig({ 59 55 ...rest, 60 - primary: createBringYourOwnAccount(primary), 61 - secondary: createBringYourOwnAccount(secondary), 56 + primary: createAccountConfig(primary), 57 + secondary: createAccountConfig(secondary), 62 58 }); 63 59 }; 64 60
+31 -375
src/browser/lib/dual-actions/feed.mjs
··· 1 + import { createPageAuthActions } from '../page-auth-actions.mjs'; 2 + import { createPageFeedActions } from '../page-feed-actions.mjs'; 1 3 import { dismissBlockingOverlays } from '../runtime-utils.mjs'; 2 4 3 5 export const createDualFeedActions = ({ 6 + config, 4 7 appBaseUrl, 5 8 wait, 6 9 normalizeText, 7 10 buttonText, 8 11 }) => { 9 - const findRowByPrimaryText = async (page, needle, timeout = 60000) => { 10 - const started = Date.now(); 11 - while (Date.now() - started < timeout) { 12 - const rows = page.locator('[data-testid^="feedItem-by-"]'); 13 - const count = await rows.count(); 14 - for (let i = 0; i < count; i += 1) { 15 - const row = rows.nth(i); 16 - const primaryText = row.locator('[data-testid="postText"]').first(); 17 - if (!(await primaryText.count())) { 18 - continue; 19 - } 20 - const text = normalizeText(await primaryText.textContent()); 21 - if (text === needle) { 22 - await row.waitFor({ state: 'visible', timeout: 10000 }); 23 - return row; 24 - } 25 - } 26 - await wait(page, 1000); 27 - } 28 - throw new Error(`feed item with primary text not found: ${needle}`); 29 - }; 30 - 31 - const maybeFindRowByPrimaryText = async (page, needle, timeout = 10000) => { 32 - try { 33 - return await findRowByPrimaryText(page, needle, timeout); 34 - } catch { 35 - return null; 36 - } 37 - }; 38 - 39 - const findFirstFeedItem = async (page, timeout = 20000) => { 40 - const started = Date.now(); 41 - while (Date.now() - started < timeout) { 42 - const rows = page.locator('[data-testid^="feedItem-by-"]'); 43 - const count = await rows.count(); 44 - if (count > 0) { 45 - const row = rows.first(); 46 - await row.waitFor({ state: 'visible', timeout: 10000 }); 47 - return row; 48 - } 49 - await wait(page, 500); 50 - } 51 - throw new Error('feed item not found'); 52 - }; 53 - 54 - const clickLike = async (page, row) => { 55 - const btn = row.getByTestId('likeBtn').first(); 56 - await btn.click({ noWaitAfter: true }); 57 - await wait(page, 1500); 58 - }; 59 - 60 - const clickRepost = async (page, row, actionPattern = /^Repost$/i) => { 61 - await dismissBlockingOverlays(page); 62 - const btn = row.getByTestId('repostBtn').first(); 63 - await btn.click({ noWaitAfter: true }); 64 - await wait(page, 500); 65 - const repost = page.getByText(actionPattern).last(); 66 - if (await repost.count()) { 67 - await repost.click({ noWaitAfter: true }); 68 - await wait(page, 1500); 69 - await dismissBlockingOverlays(page); 70 - return; 71 - } 72 - await wait(page, 1500); 73 - }; 74 - 75 - const ensureLiked = async (page, row) => { 76 - const btn = row.getByTestId('likeBtn').first(); 77 - const before = await buttonText(btn); 78 - if (/unlike/i.test(before)) { 79 - return { note: 'already liked' }; 80 - } 81 - await clickLike(page, row); 82 - return { note: await buttonText(btn) }; 83 - }; 84 - 85 - const ensureNotLiked = async (page, row) => { 86 - const btn = row.getByTestId('likeBtn').first(); 87 - const before = await buttonText(btn); 88 - if (!/unlike/i.test(before)) { 89 - return { note: 'already not liked' }; 90 - } 91 - await clickLike(page, row); 92 - return { note: await buttonText(btn) }; 93 - }; 94 - 95 - const ensureReposted = async (page, row) => { 96 - const btn = row.getByTestId('repostBtn').first(); 97 - const before = await buttonText(btn); 98 - if (/undo repost|remove repost/i.test(before)) { 99 - return { note: 'already reposted' }; 100 - } 101 - await clickRepost(page, row); 102 - return { note: await buttonText(btn) }; 103 - }; 104 - 105 - const ensureNotReposted = async (page, row) => { 106 - const btn = row.getByTestId('repostBtn').first(); 107 - const before = await buttonText(btn); 108 - if (!/undo repost|remove repost/i.test(before)) { 109 - return { note: 'already not reposted' }; 110 - } 111 - await clickRepost(page, row, /^(?:Undo repost|Remove repost)$/i); 112 - return { note: await buttonText(btn) }; 113 - }; 114 - 115 - const ensureBookmarked = async (page, row) => { 116 - const btn = row.getByTestId('postBookmarkBtn').first(); 117 - const before = await buttonText(btn); 118 - if (/remove from saved posts/i.test(before)) { 119 - return { note: 'already bookmarked' }; 120 - } 121 - await btn.click({ noWaitAfter: true }); 122 - await wait(page, 1500); 123 - return { note: await buttonText(btn) }; 124 - }; 125 - 126 - const ensureNotBookmarked = async (page, row) => { 127 - const btn = row.getByTestId('postBookmarkBtn').first(); 128 - const before = await buttonText(btn); 129 - if (!/remove from saved posts/i.test(before)) { 130 - return { note: 'already not bookmarked' }; 131 - } 132 - await btn.click({ noWaitAfter: true }); 133 - await wait(page, 1500); 134 - return { note: await buttonText(btn) }; 135 - }; 136 - 137 - const visibleEditorLocator = (page) => 138 - page.locator( 139 - [ 140 - '[aria-label="Rich-Text Editor"]', 141 - '[contenteditable="true"][role="textbox"]', 142 - '[contenteditable="true"][aria-multiline="true"]', 143 - ].join(', '), 144 - ); 145 - 146 - const waitForVisibleEditor = async (page, timeout = 20000) => { 147 - const editors = visibleEditorLocator(page); 148 - const started = Date.now(); 149 - while (Date.now() - started < timeout) { 150 - const count = await editors.count(); 151 - for (let i = count - 1; i >= 0; i -= 1) { 152 - const editor = editors.nth(i); 153 - if (await editor.isVisible().catch(() => false)) { 154 - return editor; 155 - } 156 - } 157 - await wait(page, 250); 158 - } 159 - throw new Error('visible rich-text editor not found'); 160 - }; 161 - 162 - const firstVisibleLocator = async (locator) => { 163 - const count = await locator.count(); 164 - for (let i = count - 1; i >= 0; i -= 1) { 165 - const candidate = locator.nth(i); 166 - if (await candidate.isVisible().catch(() => false)) { 167 - return candidate; 168 - } 169 - } 170 - return null; 171 - }; 172 - 173 - const publishComposer = async (page, text, { applyWritesLabel, publishLabel }) => { 174 - const editor = await waitForVisibleEditor(page); 175 - await editor.click({ noWaitAfter: true }); 176 - await editor.fill(text); 177 - 178 - const publish = page.getByTestId('composerPublishBtn').last(); 179 - await publish.waitFor({ state: 'visible', timeout: 15000 }); 180 - const responsePromise = page.waitForResponse( 181 - (res) => 182 - res.url().includes('/xrpc/com.atproto.repo.applyWrites') && 183 - res.request().method() === 'POST', 184 - { timeout: 30000 }, 185 - ); 186 - await publish.click({ noWaitAfter: true }); 187 - const response = await responsePromise; 188 - if (response.status() !== 200) { 189 - throw new Error(`${applyWritesLabel} failed with status ${response.status()}`); 190 - } 191 - await wait(page, 4000); 192 - 193 - const buttonName = publishLabel instanceof RegExp ? publishLabel : /publish/i; 194 - await page.getByTestId('composerPublishBtn').getByRole('button', { name: buttonName }).waitFor({ 195 - state: 'detached', 196 - timeout: 15000, 197 - }).catch(() => undefined); 198 - }; 199 - 200 - const clickQuote = async (page, row, text) => { 201 - await dismissBlockingOverlays(page); 202 - const btn = row.getByTestId('repostBtn').first(); 203 - await btn.click({ noWaitAfter: true }); 204 - await wait(page, 500); 205 - const quote = page.getByText(/^Quote post$/).last(); 206 - if (!(await quote.count())) { 207 - throw new Error('quote option not available'); 208 - } 209 - await quote.click({ noWaitAfter: true }); 210 - await publishComposer(page, text, { 211 - applyWritesLabel: 'quote publish', 212 - publishLabel: /publish post/i, 213 - }); 214 - await dismissBlockingOverlays(page); 215 - }; 216 - 217 - const clickReply = async (page, row, text) => { 218 - const openReplyComposer = async (scope) => { 219 - const editor = await waitForVisibleEditor(page, 750).catch(() => null); 220 - if (editor) { 221 - return true; 222 - } 223 - 224 - const composeReply = await firstVisibleLocator(page.getByRole('button', { name: /compose reply/i })); 225 - if (composeReply) { 226 - await composeReply.click({ noWaitAfter: true }); 227 - await wait(page, 500); 228 - const afterComposeClick = await waitForVisibleEditor(page, 2000).catch(() => null); 229 - if (afterComposeClick) { 230 - return true; 231 - } 232 - } 233 - 234 - const writeYourReply = await firstVisibleLocator(page.getByText(/Write your reply/i)); 235 - if (writeYourReply) { 236 - await writeYourReply.click({ noWaitAfter: true, force: true }); 237 - await wait(page, 500); 238 - const afterInlineClick = await waitForVisibleEditor(page, 2000).catch(() => null); 239 - if (afterInlineClick) { 240 - return true; 241 - } 242 - } 243 - 244 - const btn = await firstVisibleLocator(scope.getByTestId('replyBtn')); 245 - if (!btn) { 246 - return false; 247 - } 248 - 249 - await btn.scrollIntoViewIfNeeded().catch(() => undefined); 250 - await btn.click({ noWaitAfter: true, force: true }); 251 - await wait(page, 1000); 252 - await dismissBlockingOverlays(page); 253 - return true; 254 - }; 255 - 256 - await dismissBlockingOverlays(page); 257 - 258 - await openReplyComposer(row); 259 - const firstAttempt = await waitForVisibleEditor(page, 4000).catch(() => null); 260 - if (!firstAttempt) { 261 - const postText = row.getByTestId('postText').first(); 262 - if (await postText.count()) { 263 - await postText.click({ noWaitAfter: true, force: true }).catch(() => undefined); 264 - await wait(page, 1500); 265 - await dismissBlockingOverlays(page); 266 - } 267 - await openReplyComposer(page); 268 - } 269 - 270 - await publishComposer(page, text, { 271 - applyWritesLabel: 'reply publish', 272 - publishLabel: /publish reply|reply/i, 273 - }); 274 - await dismissBlockingOverlays(page); 275 - }; 276 - 277 - const openPostOptions = async (page, row) => { 278 - const btn = row.getByTestId('postDropdownBtn').first(); 279 - await btn.click({ noWaitAfter: true }); 280 - const menu = page.locator('[role="menu"]').last(); 281 - await menu.waitFor({ state: 'visible', timeout: 10000 }); 282 - return menu; 283 - }; 284 - 285 - const maybeFollow = async (page) => { 286 - const follow = page.getByTestId('followBtn').first(); 287 - if (await follow.count()) { 288 - const label = (await follow.getAttribute('aria-label')) ?? ''; 289 - if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 290 - return { note: 'already following' }; 291 - } 292 - await follow.click({ noWaitAfter: true }); 293 - await wait(page, 2000); 294 - return { note: 'follow attempted' }; 295 - } 296 - const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 297 - if (!(await roleFollow.count())) { 298 - return { note: 'follow button unavailable' }; 299 - } 300 - const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 301 - if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 302 - return { note: 'already following' }; 303 - } 304 - await roleFollow.click({ noWaitAfter: true }); 305 - await wait(page, 2000); 306 - return { note: 'follow attempted via role button' }; 307 - }; 308 - 309 - const maybeUnfollow = async (page) => { 310 - const btn = page.getByTestId('unfollowBtn').first(); 311 - if (!(await btn.count())) { 312 - return { note: 'already not following' }; 313 - } 314 - await btn.click({ noWaitAfter: true }); 315 - await wait(page, 2000); 316 - return { note: 'unfollow attempted' }; 317 - }; 318 - 319 - const openNotifications = async (page) => { 320 - await page.goto(`${appBaseUrl}/notifications`, { 321 - waitUntil: 'domcontentloaded', 322 - timeout: 60000, 323 - }); 324 - await wait(page, 3000); 325 - const heading = page.getByText(/^Notifications$/).first(); 326 - if (await heading.count()) { 327 - await heading.waitFor({ state: 'visible', timeout: 15000 }); 328 - } 329 - }; 330 - 331 - const openSavedPosts = async (page) => { 332 - await page.goto(`${appBaseUrl}/saved`, { 333 - waitUntil: 'domcontentloaded', 334 - timeout: 60000, 335 - }); 336 - await wait(page, 3000); 337 - }; 12 + const authActions = createPageAuthActions({ 13 + appUrl: config.appUrl, 14 + appBaseUrl, 15 + wait, 16 + loginToBlueskyApp: async () => undefined, 17 + }); 18 + const feedActions = createPageFeedActions({ 19 + wait, 20 + normalizeText, 21 + buttonText, 22 + dismissBlockingOverlays, 23 + }); 338 24 339 25 const waitForNotificationsFeed = async (page) => { 340 26 const feed = page.getByTestId('notifsFeed').first(); ··· 345 31 return null; 346 32 }; 347 33 348 - const openProfileTab = async (page, name) => { 349 - const tab = page.getByRole('tab', { name }).first(); 350 - await tab.waitFor({ state: 'visible', timeout: 15000 }); 351 - await tab.click({ noWaitAfter: true }); 352 - await wait(page, 2000); 353 - }; 354 - 355 - const deletePostRow = async (page, row) => { 356 - await openPostOptions(page, row); 357 - const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 358 - await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 359 - await deleteItem.click({ noWaitAfter: true }); 360 - const dialog = page.locator('[role="dialog"]').last(); 361 - await dialog.waitFor({ state: 'visible', timeout: 10000 }); 362 - const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 363 - await confirm.click({ noWaitAfter: true }); 364 - await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 365 - await wait(page, 3000); 366 - }; 367 - 368 - const maybeDeleteOwnPostByText = async (page, text, successNote) => { 369 - const row = await maybeFindRowByPrimaryText(page, text, 10000); 370 - if (!row) { 371 - return { note: `not surfaced for cleanup: ${text}` }; 372 - } 373 - await deletePostRow(page, row); 374 - return { note: successNote }; 375 - }; 376 - 377 34 const openReportPostDraft = async (page, row) => { 378 - await openPostOptions(page, row); 35 + await feedActions.openPostOptions(page, row); 379 36 await page.getByRole('menuitem', { name: /report post/i }).click({ noWaitAfter: true }); 380 37 const dialog = page.locator('[role="dialog"]').last(); 381 38 await dialog.waitFor({ state: 'visible', timeout: 10000 }); ··· 399 56 }; 400 57 401 58 return { 402 - findRowByPrimaryText, 403 - findFirstFeedItem, 404 - ensureLiked, 405 - ensureNotLiked, 406 - ensureReposted, 407 - ensureNotReposted, 408 - ensureBookmarked, 409 - ensureNotBookmarked, 410 - clickQuote, 411 - clickReply, 412 - maybeFollow, 413 - maybeUnfollow, 414 - openNotifications, 415 - openSavedPosts, 59 + findRowByPrimaryText: feedActions.findRowByPrimaryText, 60 + ensureLiked: feedActions.ensureLiked, 61 + ensureNotLiked: feedActions.ensureNotLiked, 62 + ensureReposted: feedActions.ensureReposted, 63 + ensureNotReposted: feedActions.ensureNotReposted, 64 + ensureBookmarked: feedActions.ensureBookmarked, 65 + ensureNotBookmarked: feedActions.ensureNotBookmarked, 66 + clickQuote: feedActions.clickQuote, 67 + clickReply: feedActions.clickReply, 68 + maybeFollow: authActions.maybeFollow, 69 + maybeUnfollow: authActions.maybeUnfollow, 70 + openNotifications: authActions.openNotifications, 71 + openSavedPosts: authActions.openSavedPosts, 416 72 waitForNotificationsFeed, 417 - openProfileTab, 418 - maybeDeleteOwnPostByText, 73 + openProfileTab: authActions.openProfileTab, 74 + maybeDeleteOwnPostByText: feedActions.maybeDeleteOwnPostByText, 419 75 openReportPostDraft, 420 76 }; 421 77 };
+45 -113
src/browser/lib/dual-actions/profile.mjs
··· 3 3 loginToBlueskyApp, 4 4 pollJsonUntil, 5 5 } from '../runtime-utils.mjs'; 6 - import fs from 'node:fs/promises'; 7 - import path from 'node:path'; 6 + import { createPageAuthActions } from '../page-auth-actions.mjs'; 7 + import { createPageFeedActions } from '../page-feed-actions.mjs'; 8 + import { createPageProfileEditActions } from '../page-profile-edit-actions.mjs'; 8 9 9 10 export const createDualProfileActions = ({ 10 11 appBaseUrl, ··· 16 17 fetchStatus, 17 18 avatarPngBase64, 18 19 }) => { 20 + const authActions = createPageAuthActions({ 21 + appUrl: config.appUrl, 22 + appBaseUrl, 23 + wait, 24 + loginToBlueskyApp, 25 + }); 26 + const feedActions = createPageFeedActions({ 27 + wait, 28 + normalizeText: (text) => (text || '').replace(/\s+/g, ' ').trim(), 29 + buttonText: async (locator) => { 30 + const label = await locator.getAttribute('aria-label'); 31 + if (label && label.trim()) { 32 + return label.trim(); 33 + } 34 + const text = await locator.innerText().catch(() => ''); 35 + return text.trim(); 36 + }, 37 + dismissBlockingOverlays, 38 + }); 39 + const profileEditActions = createPageProfileEditActions({ 40 + artifactsDir: config.artifactsDir, 41 + wait, 42 + dismissBlockingOverlays, 43 + avatarPngBase64, 44 + notes: summary.notes, 45 + }); 46 + 19 47 const parseCompactCount = (raw) => { 20 48 if (typeof raw !== 'string') { 21 49 return undefined; ··· 31 59 return Math.round(base * multiplier); 32 60 }; 33 61 34 - const ensureAvatarFixture = async () => { 35 - const file = path.join(config.artifactsDir, 'avatar-fixture.png'); 36 - await fs.writeFile(file, Buffer.from(avatarPngBase64, 'base64')); 37 - return file; 38 - }; 39 - 40 62 const login = async (page, account) => { 41 63 const loginIdentifier = account.loginIdentifier || account.handle; 42 - await loginToBlueskyApp({ 43 - page, 44 - appUrl: config.appUrl, 64 + await authActions.login(page, { 45 65 pdsHost: account.pdsHost || config.pdsHost, 46 66 loginIdentifier, 47 67 password: account.password, ··· 50 70 }); 51 71 }; 52 72 53 - const completeAgeAssuranceIfNeeded = async (page, account) => { 54 - const addBirthdate = page.getByRole('button', { name: /(?:update|add) your birthdate/i }); 55 - if (await addBirthdate.count()) { 56 - await addBirthdate.click({ noWaitAfter: true }); 57 - await wait(page, 800); 58 - await page.getByTestId('birthdayInput').fill(account.birthdate); 59 - await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 60 - await wait(page, 3000); 61 - summary.notes.push(`Completed age-assurance birthdate gate for ${account.handle}`); 62 - } 63 - }; 64 - 65 - const gotoProfile = async (page, handle) => { 66 - await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 67 - waitUntil: 'domcontentloaded', 68 - timeout: 60000, 73 + const completeAgeAssuranceIfNeeded = async (page, account) => 74 + authActions.completeAgeAssuranceIfNeeded(page, { 75 + birthdate: account.birthdate, 76 + notes: summary.notes, 77 + noteText: `Completed age-assurance birthdate gate for ${account.handle}`, 69 78 }); 70 - await wait(page, 3000); 71 - }; 79 + 80 + const gotoProfile = authActions.gotoProfile; 72 81 73 - const waitForProfileHandle = async (page, handle, timeout = 20000) => { 74 - const shortHandle = handle.replace(/^@/, ''); 75 - const handleText = `@${shortHandle}`; 76 - await page.getByText(handleText).first().waitFor({ state: 'visible', timeout }); 77 - }; 82 + const waitForProfileHandle = authActions.waitForProfileHandle; 78 83 79 84 const readRenderedProfileCounts = async (page) => { 80 85 const raw = await page.evaluate(() => { ··· 184 189 throw lastError || new Error(`failed to read profile counts for ${profileHandle}`); 185 190 }; 186 191 187 - const composePost = async (page, text) => { 188 - await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 189 - await wait(page, 800); 190 - const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 191 - await editor.click({ noWaitAfter: true }); 192 - await editor.fill(text); 193 - await wait(page, 300); 194 - await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 195 - await wait(page, 4000); 196 - }; 192 + const composePost = feedActions.composePost; 197 193 198 194 const uploadComposerMedia = async (page) => { 199 - const mediaFile = await ensureAvatarFixture(); 195 + const mediaFile = await profileEditActions.ensureAvatarFixture(); 200 196 const openMedia = page.getByTestId('openMediaBtn').last(); 201 197 if (!(await openMedia.count())) { 202 198 throw new Error('composer media button unavailable'); ··· 222 218 return { mediaFile }; 223 219 }; 224 220 225 - const uploadProfileAvatar = async (page) => { 226 - const avatarFile = await ensureAvatarFixture(); 227 - const fileInputs = page.locator('input[type="file"]'); 228 - const count = await fileInputs.count(); 229 - 230 - if (count === 0) { 231 - const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 232 - if (await changeAvatar.count()) { 233 - await changeAvatar.click({ noWaitAfter: true }); 234 - await wait(page, 500); 235 - const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 236 - if (await uploadFromFiles.count()) { 237 - const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 238 - await uploadFromFiles.click({ noWaitAfter: true }); 239 - const chooser = await chooserPromise; 240 - await chooser.setFiles(avatarFile); 241 - await wait(page, 750); 242 - const editImageHeading = page.getByText(/^Edit image$/).last(); 243 - if (await editImageHeading.count()) { 244 - await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 245 - const cropSave = page.getByRole('button', { name: 'Save' }).last(); 246 - await cropSave.click({ noWaitAfter: true }); 247 - await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 248 - } 249 - await wait(page, 1500); 250 - return avatarFile; 251 - } 252 - } 253 - } 254 - 255 - if (count === 0) { 256 - throw new Error('profile avatar file input unavailable'); 257 - } 258 - 259 - await fileInputs.first().setInputFiles(avatarFile); 260 - await wait(page, 1500); 261 - return avatarFile; 262 - }; 263 - 264 - const editProfile = async (page, account) => { 265 - const edit = page.getByRole('button', { name: /edit profile/i }); 266 - if (!(await edit.count())) { 267 - throw new Error(`edit profile button unavailable for ${account.handle}`); 268 - } 269 - await edit.click({ noWaitAfter: true }); 270 - await wait(page, 1000); 271 - await dismissBlockingOverlays(page); 272 - const avatarFile = await uploadProfileAvatar(page); 273 - const bioField = page.locator('textarea[aria-label="Description"]').first(); 274 - if (await bioField.count()) { 275 - await bioField.fill(account.profileNote); 276 - const actual = await bioField.inputValue(); 277 - if (actual !== account.profileNote) { 278 - throw new Error(`profile description fill did not stick for ${account.handle}: ${actual}`); 279 - } 280 - } 281 - const save = page.getByTestId('editProfileSaveBtn'); 282 - await save.waitFor({ state: 'visible', timeout: 15000 }); 283 - await page.waitForFunction(() => { 284 - const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 285 - return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 286 - }, undefined, { timeout: 15000 }); 287 - await save.click({ noWaitAfter: true }); 288 - await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 289 - timeout: 15000, 221 + const editProfile = async (page, account) => 222 + profileEditActions.editProfile(page, { 223 + profileNote: account.profileNote, 224 + handle: account.handle, 290 225 }); 291 - await wait(page, 3000); 292 - return { avatarFile, profileNote: account.profileNote }; 293 - }; 294 226 295 227 const verifyLocalProfileAfterEdit = async (account) => { 296 228 const didResult = await xrpcJson('com.atproto.identity.resolveHandle', {
+2 -11
src/browser/lib/dual-api.mjs
··· 3 3 fetchStatusWithTimeout, 4 4 sleep, 5 5 } from './runtime-utils.mjs'; 6 + import { derivePdsHost } from '../../config.mjs'; 6 7 7 8 export const createDualApiHelpers = ({ config }) => { 8 - const deriveHost = (pdsUrl) => { 9 - try { 10 - return new URL(pdsUrl).host; 11 - } catch { 12 - return undefined; 13 - } 14 - }; 15 - 16 9 const fetchJson = (url, options = {}) => fetchJsonWithTimeout(url, options); 17 10 18 11 const fetchStatus = (url, options = {}) => fetchStatusWithTimeout(url, options); ··· 262 255 const accountFromConfig = (entry) => ({ 263 256 ...entry, 264 257 pdsUrl: entry.pdsUrl || config.pdsUrl, 265 - pdsHost: entry.pdsHost || deriveHost(entry.pdsUrl || config.pdsUrl), 258 + pdsHost: entry.pdsHost || derivePdsHost(entry.pdsUrl || config.pdsUrl), 266 259 loginIdentifier: entry.loginIdentifier || entry.handle, 267 260 mediaPostText: entry.mediaPostText || `${entry.postText} image`, 268 261 shortHandle: entry.handle.replace(/^@/, ''), ··· 324 317 fetchStatus, 325 318 xrpcJson, 326 319 listOwnRecords, 327 - listOwnPosts, 328 320 deleteOwnRecord, 329 321 purgeOwnRecords, 330 322 waitForOwnRecord, ··· 336 328 recordRkey, 337 329 createSession, 338 330 pollNotifications, 339 - accountFromConfig, 340 331 prepareAccounts, 341 332 cleanupStaleSmokeArtifacts, 342 333 };
+3 -9
src/browser/lib/dual-browser.mjs
··· 5 5 buttonText, 6 6 closeBrowserSafely, 7 7 createProgressEmitter, 8 - dismissBlockingOverlays, 9 8 finalizeSummary, 10 9 launchBrowserWithFallback, 11 10 normalizeText, ··· 63 62 }); 64 63 }; 65 64 66 - const isIgnoredConsole = isIgnoredConsoleEntry; 67 - const isIgnoredRequestFailure = isIgnoredRequestFailureEntry; 68 - const isIgnoredHttpFailure = isIgnoredHttpFailureEntry; 69 - 70 65 const step = async (name, fn, { optional = false, pageNames = [], timeoutMs } = {}) => { 71 66 const effectiveTimeoutMs = Number(timeoutMs || stepTimeoutMs); 72 67 emitProgress('start', name); ··· 117 112 screenshot, 118 113 recordStep, 119 114 normalizeText, 120 - isIgnoredConsole, 121 - isIgnoredRequestFailure, 122 - isIgnoredHttpFailure, 115 + isIgnoredConsole: isIgnoredConsoleEntry, 116 + isIgnoredRequestFailure: isIgnoredRequestFailureEntry, 117 + isIgnoredHttpFailure: isIgnoredHttpFailureEntry, 123 118 step, 124 119 wait, 125 120 sleep, 126 121 buttonText, 127 - dismissBlockingOverlays, 128 122 }; 129 123 }; 130 124
+7 -4
src/browser/lib/dual-scenario/phases.mjs
··· 1 + const remoteReplyHandleFromUrl = (postUrl) => { 2 + const match = postUrl?.match(/\/profile\/([^/]+)\/post\//); 3 + return match ? decodeURIComponent(match[1]) : undefined; 4 + }; 5 + 1 6 export const runDualSetupPhase = async ({ 2 7 config, 3 8 step, ··· 335 340 336 341 if (config.remoteReplyPostUrl) { 337 342 const remoteReplyText = `${primary.replyText} remote`; 338 - const remoteReplyHandleMatch = config.remoteReplyPostUrl.match(/\/profile\/([^/]+)\/post\//); 339 - const remoteReplyHandle = remoteReplyHandleMatch ? decodeURIComponent(remoteReplyHandleMatch[1]) : undefined; 343 + const remoteReplyHandle = remoteReplyHandleFromUrl(config.remoteReplyPostUrl); 340 344 341 345 if (remoteReplyHandle) { 342 346 await step('primary-prepare-configured-remote-reply-target', async () => { ··· 602 606 }, { optional: true, pageNames: ['secondary'] }); 603 607 604 608 if (config.remoteReplyPostUrl && primary.remoteReplyWasFollowingTarget === false) { 605 - const remoteReplyHandleMatch = config.remoteReplyPostUrl.match(/\/profile\/([^/]+)\/post\//); 606 - const remoteReplyHandle = remoteReplyHandleMatch ? decodeURIComponent(remoteReplyHandleMatch[1]) : undefined; 609 + const remoteReplyHandle = remoteReplyHandleFromUrl(config.remoteReplyPostUrl); 607 610 if (remoteReplyHandle) { 608 611 await step('primary-cleanup-remote-reply-target-follow', async () => { 609 612 await gotoProfile(primaryPage, remoteReplyHandle);
-10
src/browser/lib/lists.mjs
··· 188 188 }; 189 189 190 190 return { 191 - openLists, 192 - waitForListPageReady, 193 191 openListPage, 194 - waitForListTitle, 195 - fillListEditor, 196 - saveListEditor, 197 192 createList, 198 - openCurrentListOptions, 199 193 editCurrentList, 200 194 deleteCurrentList, 201 - openListPeopleTab, 202 - openAddPeopleToList, 203 - closeAddPeopleToList, 204 - searchAddPeopleList, 205 195 addUserToCurrentList, 206 196 removeUserFromCurrentList, 207 197 };
+126
src/browser/lib/page-auth-actions.mjs
··· 1 + export const createPageAuthActions = ({ 2 + appUrl, 3 + appBaseUrl, 4 + wait, 5 + loginToBlueskyApp, 6 + }) => { 7 + const login = async ( 8 + page, 9 + { 10 + pdsHost, 11 + loginIdentifier, 12 + password, 13 + notes, 14 + noteTarget, 15 + }, 16 + ) => 17 + loginToBlueskyApp({ 18 + page, 19 + appUrl, 20 + pdsHost, 21 + loginIdentifier, 22 + password, 23 + notes, 24 + noteTarget, 25 + }); 26 + 27 + const completeAgeAssuranceIfNeeded = async (page, { birthdate, notes, noteText }) => { 28 + const addBirthdate = page.getByRole('button', { name: /(?:update|add) your birthdate/i }); 29 + if (await addBirthdate.count()) { 30 + await addBirthdate.click({ noWaitAfter: true }); 31 + await wait(page, 800); 32 + await page.getByTestId('birthdayInput').fill(birthdate); 33 + await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 34 + await wait(page, 3000); 35 + if (Array.isArray(notes) && noteText) { 36 + notes.push(noteText); 37 + } 38 + } 39 + }; 40 + 41 + const gotoProfile = async (page, handle) => { 42 + await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 43 + waitUntil: 'domcontentloaded', 44 + timeout: 60000, 45 + }); 46 + await wait(page, 3000); 47 + }; 48 + 49 + const waitForProfileHandle = async (page, handle, timeout = 20000) => { 50 + const shortHandle = handle.replace(/^@/, ''); 51 + await page.getByText(`@${shortHandle}`).first().waitFor({ state: 'visible', timeout }); 52 + }; 53 + 54 + const maybeFollow = async (page) => { 55 + const follow = page.getByTestId('followBtn').first(); 56 + if (await follow.count()) { 57 + const label = (await follow.getAttribute('aria-label')) ?? ''; 58 + if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 59 + return { note: 'already following' }; 60 + } 61 + await follow.click({ noWaitAfter: true }); 62 + await wait(page, 2000); 63 + return { note: 'follow attempted' }; 64 + } 65 + const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 66 + if (!(await roleFollow.count())) { 67 + return { note: 'follow button unavailable' }; 68 + } 69 + const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 70 + if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 71 + return { note: 'already following' }; 72 + } 73 + await roleFollow.click({ noWaitAfter: true }); 74 + await wait(page, 2000); 75 + return { note: 'follow attempted via role button' }; 76 + }; 77 + 78 + const maybeUnfollow = async (page) => { 79 + const btn = page.getByTestId('unfollowBtn').first(); 80 + if (!(await btn.count())) { 81 + return { note: 'already not following' }; 82 + } 83 + await btn.click({ noWaitAfter: true }); 84 + await wait(page, 2000); 85 + return { note: 'unfollow attempted' }; 86 + }; 87 + 88 + const openNotifications = async (page) => { 89 + await page.goto(`${appBaseUrl}/notifications`, { 90 + waitUntil: 'domcontentloaded', 91 + timeout: 60000, 92 + }); 93 + await wait(page, 3000); 94 + const heading = page.getByText(/^Notifications$/).first(); 95 + if (await heading.count()) { 96 + await heading.waitFor({ state: 'visible', timeout: 15000 }); 97 + } 98 + }; 99 + 100 + const openSavedPosts = async (page) => { 101 + await page.goto(`${appBaseUrl}/saved`, { 102 + waitUntil: 'domcontentloaded', 103 + timeout: 60000, 104 + }); 105 + await wait(page, 3000); 106 + }; 107 + 108 + const openProfileTab = async (page, name) => { 109 + const tab = page.getByRole('tab', { name }).first(); 110 + await tab.waitFor({ state: 'visible', timeout: 15000 }); 111 + await tab.click({ noWaitAfter: true }); 112 + await wait(page, 2000); 113 + }; 114 + 115 + return { 116 + login, 117 + completeAgeAssuranceIfNeeded, 118 + gotoProfile, 119 + waitForProfileHandle, 120 + maybeFollow, 121 + maybeUnfollow, 122 + openNotifications, 123 + openSavedPosts, 124 + openProfileTab, 125 + }; 126 + };
+339
src/browser/lib/page-feed-actions.mjs
··· 1 + export const createPageFeedActions = ({ 2 + wait, 3 + normalizeText, 4 + buttonText, 5 + dismissBlockingOverlays, 6 + }) => { 7 + const composePost = async (page, text) => { 8 + await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 9 + await wait(page, 800); 10 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 11 + await editor.click({ noWaitAfter: true }); 12 + await editor.fill(text); 13 + await wait(page, 300); 14 + await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 15 + await wait(page, 4000); 16 + }; 17 + 18 + const findRowByPrimaryText = async (page, needle, timeout = 60000) => { 19 + const started = Date.now(); 20 + while (Date.now() - started < timeout) { 21 + const rows = page.locator('[data-testid^="feedItem-by-"]'); 22 + const count = await rows.count(); 23 + for (let i = 0; i < count; i += 1) { 24 + const row = rows.nth(i); 25 + const primaryText = row.locator('[data-testid="postText"]').first(); 26 + if (!(await primaryText.count())) { 27 + continue; 28 + } 29 + const text = normalizeText(await primaryText.textContent()); 30 + if (text === needle) { 31 + await row.waitFor({ state: 'visible', timeout: 10000 }); 32 + return row; 33 + } 34 + } 35 + await wait(page, 1000); 36 + } 37 + throw new Error(`feed item with primary text not found: ${needle}`); 38 + }; 39 + 40 + const maybeFindRowByPrimaryText = async (page, needle, timeout = 10000) => { 41 + try { 42 + return await findRowByPrimaryText(page, needle, timeout); 43 + } catch { 44 + return null; 45 + } 46 + }; 47 + 48 + const findFirstFeedItem = async (page, timeout = 20000) => { 49 + const started = Date.now(); 50 + while (Date.now() - started < timeout) { 51 + const rows = page.locator('[data-testid^="feedItem-by-"]'); 52 + const count = await rows.count(); 53 + if (count > 0) { 54 + const row = rows.first(); 55 + await row.waitFor({ state: 'visible', timeout: 10000 }); 56 + return row; 57 + } 58 + await wait(page, 500); 59 + } 60 + throw new Error('feed item not found'); 61 + }; 62 + 63 + const clickLike = async (page, row) => { 64 + const btn = row.getByTestId('likeBtn').first(); 65 + await btn.click({ noWaitAfter: true }); 66 + await wait(page, 1500); 67 + }; 68 + 69 + const clickRepost = async (page, row, actionPattern = /^Repost$/i) => { 70 + await dismissBlockingOverlays(page); 71 + const btn = row.getByTestId('repostBtn').first(); 72 + await btn.click({ noWaitAfter: true }); 73 + await wait(page, 500); 74 + const repost = page.getByText(actionPattern).last(); 75 + if (await repost.count()) { 76 + await repost.click({ noWaitAfter: true }); 77 + await wait(page, 1500); 78 + await dismissBlockingOverlays(page); 79 + return; 80 + } 81 + await wait(page, 1500); 82 + }; 83 + 84 + const ensureLiked = async (page, row) => { 85 + const btn = row.getByTestId('likeBtn').first(); 86 + const before = await buttonText(btn); 87 + if (/unlike/i.test(before)) { 88 + return { note: 'already liked' }; 89 + } 90 + await clickLike(page, row); 91 + return { note: await buttonText(btn) }; 92 + }; 93 + 94 + const ensureNotLiked = async (page, row) => { 95 + const btn = row.getByTestId('likeBtn').first(); 96 + const before = await buttonText(btn); 97 + if (!/unlike/i.test(before)) { 98 + return { note: 'already not liked' }; 99 + } 100 + await clickLike(page, row); 101 + return { note: await buttonText(btn) }; 102 + }; 103 + 104 + const ensureReposted = async (page, row) => { 105 + const btn = row.getByTestId('repostBtn').first(); 106 + const before = await buttonText(btn); 107 + if (/undo repost|remove repost/i.test(before)) { 108 + return { note: 'already reposted' }; 109 + } 110 + await clickRepost(page, row); 111 + return { note: await buttonText(btn) }; 112 + }; 113 + 114 + const ensureNotReposted = async (page, row) => { 115 + const btn = row.getByTestId('repostBtn').first(); 116 + const before = await buttonText(btn); 117 + if (!/undo repost|remove repost/i.test(before)) { 118 + return { note: 'already not reposted' }; 119 + } 120 + await clickRepost(page, row, /^(?:Undo repost|Remove repost)$/i); 121 + return { note: await buttonText(btn) }; 122 + }; 123 + 124 + const ensureBookmarked = async (page, row) => { 125 + const btn = row.getByTestId('postBookmarkBtn').first(); 126 + const before = await buttonText(btn); 127 + if (/remove from saved posts/i.test(before)) { 128 + return { note: 'already bookmarked' }; 129 + } 130 + await btn.click({ noWaitAfter: true }); 131 + await wait(page, 1500); 132 + return { note: await buttonText(btn) }; 133 + }; 134 + 135 + const ensureNotBookmarked = async (page, row) => { 136 + const btn = row.getByTestId('postBookmarkBtn').first(); 137 + const before = await buttonText(btn); 138 + if (!/remove from saved posts/i.test(before)) { 139 + return { note: 'already not bookmarked' }; 140 + } 141 + await btn.click({ noWaitAfter: true }); 142 + await wait(page, 1500); 143 + return { note: await buttonText(btn) }; 144 + }; 145 + 146 + const visibleEditorLocator = (page) => 147 + page.locator( 148 + [ 149 + '[aria-label="Rich-Text Editor"]', 150 + '[contenteditable="true"][role="textbox"]', 151 + '[contenteditable="true"][aria-multiline="true"]', 152 + ].join(', '), 153 + ); 154 + 155 + const waitForVisibleEditor = async (page, timeout = 20000) => { 156 + const editors = visibleEditorLocator(page); 157 + const started = Date.now(); 158 + while (Date.now() - started < timeout) { 159 + const count = await editors.count(); 160 + for (let i = count - 1; i >= 0; i -= 1) { 161 + const editor = editors.nth(i); 162 + if (await editor.isVisible().catch(() => false)) { 163 + return editor; 164 + } 165 + } 166 + await wait(page, 250); 167 + } 168 + throw new Error('visible rich-text editor not found'); 169 + }; 170 + 171 + const firstVisibleLocator = async (locator) => { 172 + const count = await locator.count(); 173 + for (let i = count - 1; i >= 0; i -= 1) { 174 + const candidate = locator.nth(i); 175 + if (await candidate.isVisible().catch(() => false)) { 176 + return candidate; 177 + } 178 + } 179 + return null; 180 + }; 181 + 182 + const publishComposer = async (page, text, { applyWritesLabel, publishLabel }) => { 183 + const editor = await waitForVisibleEditor(page); 184 + await editor.click({ noWaitAfter: true }); 185 + await editor.fill(text); 186 + 187 + const publish = page.getByTestId('composerPublishBtn').last(); 188 + await publish.waitFor({ state: 'visible', timeout: 15000 }); 189 + const responsePromise = page.waitForResponse( 190 + (res) => 191 + res.url().includes('/xrpc/com.atproto.repo.applyWrites') && 192 + res.request().method() === 'POST', 193 + { timeout: 30000 }, 194 + ); 195 + await publish.click({ noWaitAfter: true }); 196 + const response = await responsePromise; 197 + if (response.status() !== 200) { 198 + throw new Error(`${applyWritesLabel} failed with status ${response.status()}`); 199 + } 200 + await wait(page, 4000); 201 + 202 + const buttonName = publishLabel instanceof RegExp ? publishLabel : /publish/i; 203 + await page 204 + .getByTestId('composerPublishBtn') 205 + .getByRole('button', { name: buttonName }) 206 + .waitFor({ 207 + state: 'detached', 208 + timeout: 15000, 209 + }) 210 + .catch(() => undefined); 211 + }; 212 + 213 + const clickQuote = async (page, row, text) => { 214 + await dismissBlockingOverlays(page); 215 + const btn = row.getByTestId('repostBtn').first(); 216 + await btn.click({ noWaitAfter: true }); 217 + await wait(page, 500); 218 + const quote = page.getByText(/^Quote post$/).last(); 219 + if (!(await quote.count())) { 220 + throw new Error('quote option not available'); 221 + } 222 + await quote.click({ noWaitAfter: true }); 223 + await publishComposer(page, text, { 224 + applyWritesLabel: 'quote publish', 225 + publishLabel: /publish post/i, 226 + }); 227 + await dismissBlockingOverlays(page); 228 + }; 229 + 230 + const clickReply = async (page, row, text) => { 231 + const openReplyComposer = async (scope) => { 232 + const editor = await waitForVisibleEditor(page, 750).catch(() => null); 233 + if (editor) { 234 + return true; 235 + } 236 + 237 + const composeReply = await firstVisibleLocator(page.getByRole('button', { name: /compose reply/i })); 238 + if (composeReply) { 239 + await composeReply.click({ noWaitAfter: true }); 240 + await wait(page, 500); 241 + const afterComposeClick = await waitForVisibleEditor(page, 2000).catch(() => null); 242 + if (afterComposeClick) { 243 + return true; 244 + } 245 + } 246 + 247 + const writeYourReply = await firstVisibleLocator(page.getByText(/Write your reply/i)); 248 + if (writeYourReply) { 249 + await writeYourReply.click({ noWaitAfter: true, force: true }); 250 + await wait(page, 500); 251 + const afterInlineClick = await waitForVisibleEditor(page, 2000).catch(() => null); 252 + if (afterInlineClick) { 253 + return true; 254 + } 255 + } 256 + 257 + const btn = await firstVisibleLocator(scope.getByTestId('replyBtn')); 258 + if (!btn) { 259 + return false; 260 + } 261 + 262 + await btn.scrollIntoViewIfNeeded().catch(() => undefined); 263 + await btn.click({ noWaitAfter: true, force: true }); 264 + await wait(page, 1000); 265 + await dismissBlockingOverlays(page); 266 + return true; 267 + }; 268 + 269 + await dismissBlockingOverlays(page); 270 + 271 + await openReplyComposer(row); 272 + const firstAttempt = await waitForVisibleEditor(page, 4000).catch(() => null); 273 + if (!firstAttempt) { 274 + const postText = row.getByTestId('postText').first(); 275 + if (await postText.count()) { 276 + await postText.click({ noWaitAfter: true, force: true }).catch(() => undefined); 277 + await wait(page, 1500); 278 + await dismissBlockingOverlays(page); 279 + } 280 + await openReplyComposer(page); 281 + } 282 + 283 + await publishComposer(page, text, { 284 + applyWritesLabel: 'reply publish', 285 + publishLabel: /publish reply|reply/i, 286 + }); 287 + await dismissBlockingOverlays(page); 288 + }; 289 + 290 + const openPostOptions = async (page, row) => { 291 + const btn = row.getByTestId('postDropdownBtn').first(); 292 + await btn.click({ noWaitAfter: true }); 293 + const menu = page.locator('[role="menu"]').last(); 294 + await menu.waitFor({ state: 'visible', timeout: 10000 }); 295 + return menu; 296 + }; 297 + 298 + const deletePostRow = async (page, row) => { 299 + await openPostOptions(page, row); 300 + const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 301 + await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 302 + await deleteItem.click({ noWaitAfter: true }); 303 + const dialog = page.locator('[role="dialog"]').last(); 304 + await dialog.waitFor({ state: 'visible', timeout: 10000 }); 305 + const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 306 + await confirm.click({ noWaitAfter: true }); 307 + await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 308 + await wait(page, 3000); 309 + }; 310 + 311 + const maybeDeleteOwnPostByText = async (page, text, successNote) => { 312 + const row = await maybeFindRowByPrimaryText(page, text, 10000); 313 + if (!row) { 314 + return { note: `not surfaced for cleanup: ${text}` }; 315 + } 316 + await deletePostRow(page, row); 317 + return { note: successNote }; 318 + }; 319 + 320 + return { 321 + composePost, 322 + findRowByPrimaryText, 323 + maybeFindRowByPrimaryText, 324 + findFirstFeedItem, 325 + clickLike, 326 + clickRepost, 327 + clickQuote, 328 + clickReply, 329 + ensureLiked, 330 + ensureNotLiked, 331 + ensureReposted, 332 + ensureNotReposted, 333 + ensureBookmarked, 334 + ensureNotBookmarked, 335 + openPostOptions, 336 + deletePostRow, 337 + maybeDeleteOwnPostByText, 338 + }; 339 + };
+103
src/browser/lib/page-profile-edit-actions.mjs
··· 1 + import fs from 'node:fs/promises'; 2 + import path from 'node:path'; 3 + 4 + export const createPageProfileEditActions = ({ 5 + artifactsDir, 6 + wait, 7 + dismissBlockingOverlays, 8 + avatarPngBase64, 9 + notes, 10 + }) => { 11 + const ensureAvatarFixture = async () => { 12 + const file = path.join(artifactsDir, 'avatar-fixture.png'); 13 + await fs.writeFile(file, Buffer.from(avatarPngBase64, 'base64')); 14 + return file; 15 + }; 16 + 17 + const uploadProfileAvatar = async (page) => { 18 + const avatarFile = await ensureAvatarFixture(); 19 + const fileInputs = page.locator('input[type="file"]'); 20 + const count = await fileInputs.count(); 21 + 22 + if (count === 0) { 23 + const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 24 + if (await changeAvatar.count()) { 25 + await changeAvatar.click({ noWaitAfter: true }); 26 + await wait(page, 500); 27 + const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 28 + if (await uploadFromFiles.count()) { 29 + const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 30 + await uploadFromFiles.click({ noWaitAfter: true }); 31 + const chooser = await chooserPromise; 32 + await chooser.setFiles(avatarFile); 33 + await wait(page, 750); 34 + const editImageHeading = page.getByText(/^Edit image$/).last(); 35 + if (await editImageHeading.count()) { 36 + await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 37 + const cropSave = page.getByRole('button', { name: 'Save' }).last(); 38 + await cropSave.click({ noWaitAfter: true }); 39 + await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 40 + if (Array.isArray(notes)) { 41 + notes.push('profile avatar crop saved'); 42 + } 43 + } 44 + if (Array.isArray(notes)) { 45 + notes.push('profile avatar uploaded via file chooser'); 46 + } 47 + await wait(page, 1500); 48 + return avatarFile; 49 + } 50 + } 51 + } 52 + 53 + if (count === 0) { 54 + throw new Error('profile avatar file input unavailable'); 55 + } 56 + 57 + await fileInputs.first().setInputFiles(avatarFile); 58 + await wait(page, 1500); 59 + if (Array.isArray(notes)) { 60 + notes.push(`edit profile file inputs: ${count}`); 61 + } 62 + return avatarFile; 63 + }; 64 + 65 + const editProfile = async (page, { profileNote, handle }) => { 66 + const edit = page.getByRole('button', { name: /edit profile/i }); 67 + if (!(await edit.count())) { 68 + const detail = handle ? ` for ${handle}` : ''; 69 + throw new Error(`edit profile button unavailable${detail}`); 70 + } 71 + await edit.click({ noWaitAfter: true }); 72 + await wait(page, 1000); 73 + await dismissBlockingOverlays(page); 74 + const avatarFile = await uploadProfileAvatar(page); 75 + const bioField = page.locator('textarea[aria-label="Description"]').first(); 76 + if (await bioField.count()) { 77 + await bioField.fill(profileNote); 78 + const actual = await bioField.inputValue(); 79 + if (actual !== profileNote) { 80 + const detail = handle ? ` for ${handle}` : ''; 81 + throw new Error(`profile description fill did not stick${detail}: ${actual}`); 82 + } 83 + } 84 + const save = page.getByTestId('editProfileSaveBtn'); 85 + await save.waitFor({ state: 'visible', timeout: 15000 }); 86 + await page.waitForFunction(() => { 87 + const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 88 + return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 89 + }, undefined, { timeout: 15000 }); 90 + await save.click({ noWaitAfter: true }); 91 + await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 92 + timeout: 15000, 93 + }); 94 + await wait(page, 3000); 95 + return { avatarFile, profileNote }; 96 + }; 97 + 98 + return { 99 + ensureAvatarFixture, 100 + uploadProfileAvatar, 101 + editProfile, 102 + }; 103 + };
-3
src/browser/lib/settings.mjs
··· 48 48 }; 49 49 50 50 return { 51 - openSettingRoute, 52 - roleSetting, 53 - settingState, 54 51 setCheckboxSetting, 55 52 setRadioSetting, 56 53 };
+25 -77
src/browser/lib/single-actions/auth.mjs
··· 1 1 import { loginToBlueskyApp } from '../runtime-utils.mjs'; 2 + import { createPageAuthActions } from '../page-auth-actions.mjs'; 2 3 3 4 export const createSingleAuthActions = ({ 4 5 config, ··· 7 8 appBaseUrl, 8 9 wait, 9 10 }) => { 11 + const actions = createPageAuthActions({ 12 + appUrl: config.appUrl, 13 + appBaseUrl, 14 + wait: (_page, ms) => wait(ms), 15 + loginToBlueskyApp, 16 + }); 17 + 10 18 const login = async () => { 11 19 const loginIdentifier = config.loginIdentifier || config.handle; 12 - await loginToBlueskyApp({ 13 - page, 14 - appUrl: config.appUrl, 20 + await actions.login(page, { 15 21 pdsHost: config.pdsHost, 16 22 loginIdentifier, 17 23 password: config.password, ··· 20 26 }); 21 27 }; 22 28 23 - const completeAgeAssuranceIfNeeded = async () => { 24 - const addBirthdate = page.getByRole('button', { name: /(?:update|add) your birthdate/i }); 25 - if (await addBirthdate.count()) { 26 - await addBirthdate.click({ noWaitAfter: true }); 27 - await wait(800); 28 - await page.getByTestId('birthdayInput').fill(config.birthdate); 29 - await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 30 - await wait(3000); 31 - summary.notes.push('Completed age-assurance birthdate gate'); 32 - } 33 - }; 34 - 35 - const gotoProfile = async (handle) => { 36 - await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 37 - waitUntil: 'domcontentloaded', 38 - timeout: 60000, 29 + const completeAgeAssuranceIfNeeded = async () => 30 + actions.completeAgeAssuranceIfNeeded(page, { 31 + birthdate: config.birthdate, 32 + notes: summary.notes, 33 + noteText: 'Completed age-assurance birthdate gate', 39 34 }); 40 - await wait(3000); 41 - }; 42 35 43 - const maybeFollowTarget = async () => { 44 - const follow = page.getByTestId('followBtn').first(); 45 - if (!(await follow.count())) { 46 - const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 47 - if (!(await roleFollow.count())) { 48 - return { note: 'follow button unavailable' }; 49 - } 50 - const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 51 - if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 52 - return { note: 'already following target' }; 53 - } 54 - await roleFollow.click({ noWaitAfter: true }); 55 - await wait(2000); 56 - return { note: 'follow attempted via role button' }; 57 - } 58 - const label = (await follow.getAttribute('aria-label')) ?? ''; 59 - if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 60 - return { note: 'already following target' }; 61 - } 62 - await follow.click({ noWaitAfter: true }); 63 - await wait(2000); 64 - return { note: 'follow attempted' }; 65 - }; 36 + const gotoProfile = (handle) => actions.gotoProfile(page, handle); 66 37 67 - const maybeUnfollowTarget = async () => { 68 - const btn = page.getByTestId('unfollowBtn').first(); 69 - if (!(await btn.count())) { 70 - return { note: 'already not following target' }; 71 - } 72 - await btn.click({ noWaitAfter: true }); 73 - await wait(2000); 74 - return { note: 'unfollow attempted' }; 75 - }; 38 + const waitForProfileHandle = (handle, timeout) => 39 + actions.waitForProfileHandle(page, handle, timeout); 40 + 41 + const maybeFollowTarget = () => actions.maybeFollow(page); 42 + 43 + const maybeUnfollowTarget = () => actions.maybeUnfollow(page); 76 44 77 - const openNotifications = async () => { 78 - await page.goto(`${appBaseUrl}/notifications`, { 79 - waitUntil: 'domcontentloaded', 80 - timeout: 60000, 81 - }); 82 - await wait(3000); 83 - const heading = page.getByText(/^Notifications$/).first(); 84 - if (await heading.count()) { 85 - await heading.waitFor({ state: 'visible', timeout: 15000 }); 86 - } 87 - }; 45 + const openNotifications = () => actions.openNotifications(page); 88 46 89 - const openSavedPosts = async () => { 90 - await page.goto(`${appBaseUrl}/saved`, { 91 - waitUntil: 'domcontentloaded', 92 - timeout: 60000, 93 - }); 94 - await wait(3000); 95 - }; 47 + const openSavedPosts = () => actions.openSavedPosts(page); 96 48 97 - const openProfileTab = async (name) => { 98 - const tab = page.getByRole('tab', { name }).first(); 99 - await tab.waitFor({ state: 'visible', timeout: 15000 }); 100 - await tab.click({ noWaitAfter: true }); 101 - await wait(2000); 102 - }; 49 + const openProfileTab = (name) => actions.openProfileTab(page, name); 103 50 104 51 return { 105 52 login, 106 53 completeAgeAssuranceIfNeeded, 107 54 gotoProfile, 55 + waitForProfileHandle, 108 56 maybeFollowTarget, 109 57 maybeUnfollowTarget, 110 58 openNotifications,
+20 -205
src/browser/lib/single-actions/feed.mjs
··· 1 + import { createPageFeedActions } from '../page-feed-actions.mjs'; 2 + 1 3 export const createSingleFeedActions = ({ 2 4 page, 3 5 wait, 4 6 normalizeText, 5 7 buttonText, 6 8 }) => { 7 - const composePost = async (text) => { 8 - await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 9 - await wait(800); 10 - const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 11 - await editor.click({ noWaitAfter: true }); 12 - await editor.fill(text); 13 - await wait(300); 14 - await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 15 - await wait(4000); 16 - }; 17 - 18 - const waitForProfileHandle = async (handle, timeout = 20000) => { 19 - const shortHandle = handle.replace(/^@/, ''); 20 - const handleText = `@${shortHandle}`; 21 - await page.getByText(handleText).first().waitFor({ state: 'visible', timeout }); 22 - }; 23 - 24 - const findRowByPrimaryText = async (needle, timeout = 60000) => { 25 - const started = Date.now(); 26 - while (Date.now() - started < timeout) { 27 - const rows = page.locator('[data-testid^="feedItem-by-"]'); 28 - const count = await rows.count(); 29 - for (let i = 0; i < count; i += 1) { 30 - const row = rows.nth(i); 31 - const primary = row.locator('[data-testid="postText"]').first(); 32 - if (!(await primary.count())) { 33 - continue; 34 - } 35 - const text = normalizeText(await primary.textContent()); 36 - if (text === needle) { 37 - await row.waitFor({ state: 'visible', timeout: 10000 }); 38 - return row; 39 - } 40 - } 41 - await wait(1000); 42 - } 43 - throw new Error(`feed item with primary text not found: ${needle}`); 44 - }; 45 - 46 - const maybeFindRowByPrimaryText = async (needle, timeout = 5000) => { 47 - try { 48 - return await findRowByPrimaryText(needle, timeout); 49 - } catch { 50 - return null; 51 - } 52 - }; 53 - 54 - const findFirstFeedItem = async (timeout = 60000) => { 55 - const row = page.locator('[data-testid^="feedItem-by-"]').first(); 56 - await row.waitFor({ state: 'visible', timeout }); 57 - return row; 58 - }; 59 - 60 - const clickLike = async (row) => { 61 - const btn = row.getByTestId('likeBtn').first(); 62 - await btn.click({ noWaitAfter: true }); 63 - await wait(1500); 64 - }; 65 - 66 - const clickRepost = async (row, actionPattern = /^Repost$/i) => { 67 - const btn = row.getByTestId('repostBtn').first(); 68 - await btn.click({ noWaitAfter: true }); 69 - await wait(500); 70 - const repost = page.getByText(actionPattern).last(); 71 - if (await repost.count()) { 72 - await repost.click({ noWaitAfter: true }); 73 - await wait(1500); 74 - return; 75 - } 76 - await wait(1500); 77 - }; 78 - 79 - const clickQuote = async (row, text) => { 80 - const btn = row.getByTestId('repostBtn').first(); 81 - await btn.click({ noWaitAfter: true }); 82 - await wait(500); 83 - const quote = page.getByText(/^Quote post$/).last(); 84 - if (!(await quote.count())) { 85 - throw new Error('quote option not available'); 86 - } 87 - await quote.click({ noWaitAfter: true }); 88 - await wait(1000); 89 - const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 90 - await editor.click({ noWaitAfter: true }); 91 - await editor.fill(text); 92 - await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 93 - await wait(4000); 94 - }; 95 - 96 - const clickReply = async (row, text) => { 97 - const btn = row.getByTestId('replyBtn').first(); 98 - await btn.click({ noWaitAfter: true }); 99 - await wait(1000); 100 - const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 101 - await editor.click({ noWaitAfter: true }); 102 - await editor.fill(text); 103 - const publishReply = page.getByRole('button', { name: /publish reply|reply/i }).last(); 104 - await publishReply.click({ noWaitAfter: true }); 105 - await wait(4000); 106 - }; 107 - 108 - const ensureBookmarked = async (row) => { 109 - const btn = row.getByTestId('postBookmarkBtn').first(); 110 - const before = await buttonText(btn); 111 - if (/remove from saved posts/i.test(before)) { 112 - return { note: 'already bookmarked' }; 113 - } 114 - await btn.click({ noWaitAfter: true }); 115 - await wait(1500); 116 - return { note: await buttonText(btn) }; 117 - }; 118 - 119 - const ensureNotBookmarked = async (row) => { 120 - const btn = row.getByTestId('postBookmarkBtn').first(); 121 - const before = await buttonText(btn); 122 - if (!/remove from saved posts/i.test(before)) { 123 - return { note: 'already not bookmarked' }; 124 - } 125 - await btn.click({ noWaitAfter: true }); 126 - await wait(1500); 127 - return { note: await buttonText(btn) }; 128 - }; 129 - 130 - const ensureLiked = async (row) => { 131 - const btn = row.getByTestId('likeBtn').first(); 132 - const before = await buttonText(btn); 133 - if (/unlike/i.test(before)) { 134 - return { note: 'already liked' }; 135 - } 136 - await clickLike(row); 137 - return { note: await buttonText(btn) }; 138 - }; 139 - 140 - const ensureNotLiked = async (row) => { 141 - const btn = row.getByTestId('likeBtn').first(); 142 - const before = await buttonText(btn); 143 - if (!/unlike/i.test(before)) { 144 - return { note: 'already not liked' }; 145 - } 146 - await clickLike(row); 147 - return { note: await buttonText(btn) }; 148 - }; 149 - 150 - const ensureReposted = async (row) => { 151 - const btn = row.getByTestId('repostBtn').first(); 152 - const before = await buttonText(btn); 153 - if (/undo repost|remove repost/i.test(before)) { 154 - return { note: 'already reposted' }; 155 - } 156 - await clickRepost(row); 157 - return { note: await buttonText(btn) }; 158 - }; 159 - 160 - const ensureNotReposted = async (row) => { 161 - const btn = row.getByTestId('repostBtn').first(); 162 - const before = await buttonText(btn); 163 - if (!/undo repost|remove repost/i.test(before)) { 164 - return { note: 'already not reposted' }; 165 - } 166 - await clickRepost(row, /^(?:Undo repost|Remove repost)$/i); 167 - return { note: await buttonText(btn) }; 168 - }; 169 - 170 - const openPostOptions = async (row) => { 171 - const btn = row.getByTestId('postDropdownBtn').first(); 172 - await btn.click({ noWaitAfter: true }); 173 - const menu = page.locator('[role="menu"]').last(); 174 - await menu.waitFor({ state: 'visible', timeout: 10000 }); 175 - return menu; 176 - }; 177 - 178 - const deletePostRow = async (row) => { 179 - await openPostOptions(row); 180 - const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 181 - await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 182 - await deleteItem.click({ noWaitAfter: true }); 183 - const dialog = page.locator('[role="dialog"]').last(); 184 - await dialog.waitFor({ state: 'visible', timeout: 10000 }); 185 - const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 186 - await confirm.click({ noWaitAfter: true }); 187 - await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 188 - await wait(3000); 189 - }; 190 - 191 - const maybeDeleteOwnPostByText = async (text, successNote) => { 192 - const row = await maybeFindRowByPrimaryText(text, 10000); 193 - if (!row) { 194 - return { note: `not surfaced for cleanup: ${text}` }; 195 - } 196 - await deletePostRow(row); 197 - return { note: successNote }; 198 - }; 9 + const actions = createPageFeedActions({ 10 + wait: (_page, ms) => wait(ms), 11 + normalizeText, 12 + buttonText, 13 + dismissBlockingOverlays: async () => undefined, 14 + }); 199 15 200 16 return { 201 - composePost, 202 - waitForProfileHandle, 203 - findRowByPrimaryText, 204 - findFirstFeedItem, 205 - clickQuote, 206 - clickReply, 207 - ensureBookmarked, 208 - ensureNotBookmarked, 209 - ensureLiked, 210 - ensureNotLiked, 211 - ensureReposted, 212 - ensureNotReposted, 213 - maybeDeleteOwnPostByText, 17 + composePost: (text) => actions.composePost(page, text), 18 + findRowByPrimaryText: (needle, timeout) => actions.findRowByPrimaryText(page, needle, timeout), 19 + findFirstFeedItem: (timeout) => actions.findFirstFeedItem(page, timeout || 60000), 20 + clickQuote: (row, text) => actions.clickQuote(page, row, text), 21 + clickReply: (row, text) => actions.clickReply(page, row, text), 22 + ensureBookmarked: (row) => actions.ensureBookmarked(page, row), 23 + ensureNotBookmarked: (row) => actions.ensureNotBookmarked(page, row), 24 + ensureLiked: (row) => actions.ensureLiked(page, row), 25 + ensureNotLiked: (row) => actions.ensureNotLiked(page, row), 26 + ensureReposted: (row) => actions.ensureReposted(page, row), 27 + ensureNotReposted: (row) => actions.ensureNotReposted(page, row), 28 + maybeDeleteOwnPostByText: (text, successNote) => actions.maybeDeleteOwnPostByText(page, text, successNote), 214 29 }; 215 30 };
+9 -79
src/browser/lib/single-actions/profile.mjs
··· 1 1 import { 2 2 dismissBlockingOverlays, 3 3 } from '../runtime-utils.mjs'; 4 - import fs from 'node:fs/promises'; 5 - import path from 'node:path'; 4 + import { createPageProfileEditActions } from '../page-profile-edit-actions.mjs'; 6 5 7 6 export const createSingleProfileActions = ({ 8 7 config, ··· 13 12 pollJson, 14 13 avatarPngBase64, 15 14 }) => { 16 - const ensureAvatarFixture = async () => { 17 - const file = path.join(config.artifactsDir, 'avatar-fixture.png'); 18 - await fs.writeFile(file, Buffer.from(avatarPngBase64, 'base64')); 19 - return file; 20 - }; 15 + const pageActions = createPageProfileEditActions({ 16 + artifactsDir: config.artifactsDir, 17 + wait: (_page, ms) => wait(ms), 18 + dismissBlockingOverlays, 19 + avatarPngBase64, 20 + notes: summary.notes, 21 + }); 21 22 22 23 const verifyPublicHandleResolution = async () => { 23 24 const result = await pollJson( ··· 108 109 }; 109 110 }; 110 111 111 - const uploadProfileAvatar = async () => { 112 - const avatarFile = await ensureAvatarFixture(); 113 - const fileInputs = page.locator('input[type="file"]'); 114 - const count = await fileInputs.count(); 115 - 116 - if (count === 0) { 117 - const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 118 - if (await changeAvatar.count()) { 119 - await changeAvatar.click({ noWaitAfter: true }); 120 - await wait(500); 121 - const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 122 - if (await uploadFromFiles.count()) { 123 - const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 124 - await uploadFromFiles.click({ noWaitAfter: true }); 125 - const chooser = await chooserPromise; 126 - await chooser.setFiles(avatarFile); 127 - await wait(750); 128 - const editImageHeading = page.getByText(/^Edit image$/).last(); 129 - if (await editImageHeading.count()) { 130 - await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 131 - const cropSave = page.getByRole('button', { name: 'Save' }).last(); 132 - await cropSave.click({ noWaitAfter: true }); 133 - await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 134 - summary.notes.push('profile avatar crop saved'); 135 - } 136 - summary.notes.push('profile avatar uploaded via file chooser'); 137 - await wait(1500); 138 - return avatarFile; 139 - } 140 - } 141 - } 142 - 143 - if (count === 0) { 144 - throw new Error('profile avatar file input unavailable'); 145 - } 146 - 147 - await fileInputs.first().setInputFiles(avatarFile); 148 - await wait(1500); 149 - summary.notes.push(`edit profile file inputs: ${count}`); 150 - return avatarFile; 151 - }; 152 - 153 - const editProfile = async () => { 154 - const edit = page.getByRole('button', { name: /edit profile/i }); 155 - if (!(await edit.count())) { 156 - throw new Error('edit profile button unavailable'); 157 - } 158 - await edit.click({ noWaitAfter: true }); 159 - await wait(1000); 160 - await dismissBlockingOverlays(page); 161 - const avatarFile = await uploadProfileAvatar(); 162 - const bioField = page.locator('textarea[aria-label="Description"]').first(); 163 - if (await bioField.count()) { 164 - await bioField.fill(config.profileNote); 165 - const actual = await bioField.inputValue(); 166 - if (actual !== config.profileNote) { 167 - throw new Error(`profile description fill did not stick: ${actual}`); 168 - } 169 - } 170 - const save = page.getByTestId('editProfileSaveBtn'); 171 - await save.waitFor({ state: 'visible', timeout: 15000 }); 172 - await page.waitForFunction(() => { 173 - const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 174 - return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 175 - }, undefined, { timeout: 15000 }); 176 - await save.click({ noWaitAfter: true }); 177 - await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 178 - timeout: 15000, 179 - }); 180 - await wait(3000); 181 - return { avatarFile, profileNote: config.profileNote }; 182 - }; 112 + const editProfile = () => pageActions.editProfile(page, { profileNote: config.profileNote }); 183 113 184 114 return { 185 115 verifyPublicHandleResolution,
+3 -7
src/browser/run-single.mjs
··· 75 75 }); 76 76 }; 77 77 78 - const isIgnoredConsole = isIgnoredConsoleEntry; 79 - const isIgnoredRequestFailure = isIgnoredRequestFailureEntry; 80 - const isIgnoredHttpFailure = isIgnoredHttpFailureEntry; 81 - 82 78 const step = async (name, fn, { optional = false } = {}) => { 83 79 emitProgress('start', name); 84 80 try { ··· 203 199 finalizeSummary({ 204 200 summary, 205 201 strictErrors: config.strictErrors, 206 - isIgnoredConsole, 207 - isIgnoredRequestFailure, 208 - isIgnoredHttpFailure, 202 + isIgnoredConsole: isIgnoredConsoleEntry, 203 + isIgnoredRequestFailure: isIgnoredRequestFailureEntry, 204 + isIgnoredHttpFailure: isIgnoredHttpFailureEntry, 209 205 }); 210 206 await screenshot('final').catch(() => undefined); 211 207 await fs.writeFile(
+1 -1
src/config.mjs
··· 64 64 .filter(Boolean); 65 65 }; 66 66 67 - const derivePdsHost = (pdsUrl) => { 67 + export const derivePdsHost = (pdsUrl) => { 68 68 try { 69 69 return new URL(pdsUrl).host; 70 70 } catch {