perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

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

Improve browser smoke coverage and diagnostics

alice f6adb523 218f4909

+82 -23
+82 -23
tools/browser-automation/smoke.mjs
··· 19 19 targetHandle: config.targetHandle, 20 20 steps: [], 21 21 console: [], 22 + pageErrors: [], 22 23 requestFailures: [], 23 24 httpFailures: [], 25 + xrpc: [], 24 26 notes: [], 25 27 }; 26 28 ··· 37 39 }); 38 40 }); 39 41 42 + page.on('pageerror', (error) => { 43 + summary.pageErrors.push({ 44 + message: String(error?.message ?? error), 45 + stack: error?.stack, 46 + }); 47 + }); 48 + 40 49 page.on('requestfailed', (req) => { 41 50 summary.requestFailures.push({ 42 51 url: req.url(), ··· 47 56 48 57 page.on('response', (res) => { 49 58 const status = res.status(); 59 + if (res.url().includes('/xrpc/')) { 60 + summary.xrpc.push({ 61 + url: res.url(), 62 + status, 63 + method: res.request().method(), 64 + }); 65 + if (summary.xrpc.length > 200) { 66 + summary.xrpc.shift(); 67 + } 68 + } 50 69 if (status >= 400) { 51 70 summary.httpFailures.push({ 52 71 url: res.url(), ··· 139 158 }; 140 159 141 160 const maybeFollowTarget = async () => { 142 - const follow = page.getByTestId('followBtn'); 161 + const follow = page.getByTestId('followBtn').first(); 143 162 if (!(await follow.count())) { 144 - return { note: 'follow button unavailable' }; 163 + const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 164 + if (!(await roleFollow.count())) { 165 + return { note: 'follow button unavailable' }; 166 + } 167 + const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 168 + if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 169 + return { note: 'already following target' }; 170 + } 171 + await roleFollow.click({ noWaitAfter: true }); 172 + await wait(2000); 173 + return { note: 'follow attempted via role button' }; 145 174 } 146 175 const label = (await follow.getAttribute('aria-label')) ?? ''; 147 176 if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { ··· 167 196 await gotoProfile(config.handle); 168 197 }; 169 198 170 - const findOwnPostArticle = async (needle, timeout = 60000) => { 171 - const article = page.locator('article').filter({ hasText: needle }).first(); 172 - await article.waitFor({ state: 'visible', timeout }); 173 - return article; 199 + const waitForProfileHandle = async (handle, timeout = 20000) => { 200 + const shortHandle = handle.replace(/^@/, ''); 201 + const handleText = shortHandle.startsWith('@') ? shortHandle : `@${shortHandle}`; 202 + await page.getByText(handleText).first().waitFor({ state: 'visible', timeout }); 203 + }; 204 + 205 + const findFeedItemByText = async (needle, timeout = 60000) => { 206 + const row = page.locator('[data-testid^="feedItem-by-"]').filter({ hasText: needle }).first(); 207 + await row.waitFor({ state: 'visible', timeout }); 208 + return row; 209 + }; 210 + 211 + const findFirstFeedItem = async (timeout = 60000) => { 212 + const row = page.locator('[data-testid^="feedItem-by-"]').first(); 213 + await row.waitFor({ state: 'visible', timeout }); 214 + return row; 174 215 }; 175 216 176 - const likeOwnPost = async (article) => { 177 - const btn = article.getByTestId('likeBtn').first(); 217 + const likeOwnPost = async (row) => { 218 + const btn = row.getByTestId('likeBtn').first(); 178 219 await btn.click({ noWaitAfter: true }); 179 220 await wait(1500); 180 221 }; 181 222 182 - const repostOwnPost = async (article) => { 183 - const btn = article.getByTestId('repostBtn').first(); 223 + const repostOwnPost = async (row) => { 224 + const btn = row.getByTestId('repostBtn').first(); 184 225 await btn.click({ noWaitAfter: true }); 185 226 await wait(500); 186 227 const repost = page.getByText(/^Repost$/).last(); ··· 190 231 } 191 232 }; 192 233 193 - const quoteOwnPost = async (article, text) => { 194 - const btn = article.getByTestId('repostBtn').first(); 234 + const quoteOwnPost = async (row, text) => { 235 + const btn = row.getByTestId('repostBtn').first(); 195 236 await btn.click({ noWaitAfter: true }); 196 237 await wait(500); 197 238 const quote = page.getByText(/^Quote post$/).last(); ··· 207 248 await wait(4000); 208 249 }; 209 250 210 - const replyToOwnPost = async (article, text) => { 211 - const btn = article.getByTestId('replyBtn').first(); 251 + const replyToOwnPost = async (row, text) => { 252 + const btn = row.getByTestId('replyBtn').first(); 212 253 await btn.click({ noWaitAfter: true }); 213 254 await wait(1000); 214 255 const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); ··· 245 286 246 287 const ownPost = await step('find-own-post', async () => { 247 288 await openOwnProfile(); 248 - const article = await findOwnPostArticle(config.postText, 60000); 249 - return { note: 'found own post', articleFound: true }; 289 + await page.getByTestId('postsFeed').first().waitFor({ state: 'visible', timeout: 60000 }); 290 + const row = await findFeedItemByText(config.postText, 60000); 291 + const rowTestId = await row.getAttribute('data-testid'); 292 + return { note: 'found own post', rowFound: true, rowTestId }; 250 293 }); 251 294 252 295 if (ownPost) { 253 - const article = await findOwnPostArticle(config.postText); 254 - await step('like-own-post', () => likeOwnPost(article), { optional: true }); 255 - await step('repost-own-post', () => repostOwnPost(article), { optional: true }); 256 - await step('quote-own-post', () => quoteOwnPost(article, config.quoteText), { optional: true }); 257 - await step('reply-own-post', () => replyToOwnPost(article, config.replyText), { optional: true }); 296 + const row = await findFeedItemByText(config.postText); 297 + await step('like-own-post', () => likeOwnPost(row), { optional: true }); 298 + await step('repost-own-post', () => repostOwnPost(row), { optional: true }); 299 + await step('quote-own-post', () => quoteOwnPost(row, config.quoteText), { optional: true }); 300 + await step('reply-own-post', async () => { 301 + await openOwnProfile(); 302 + const refreshed = await findFeedItemByText(config.postText, 60000); 303 + await replyToOwnPost(refreshed, config.replyText); 304 + }, { optional: true }); 258 305 } 259 306 260 - await step('target-profile', () => gotoProfile(config.targetHandle)); 307 + await step('target-profile', async () => { 308 + await gotoProfile(config.targetHandle); 309 + await waitForProfileHandle(config.targetHandle, 20000); 310 + }); 261 311 await step('follow-target', maybeFollowTarget, { optional: true }); 262 312 313 + await step('inspect-target-post', async () => { 314 + const row = await findFirstFeedItem(20000); 315 + const preview = ((await row.textContent()) || '').replace(/\s+/g, ' ').slice(0, 160); 316 + return { note: preview }; 317 + }, { optional: true }); 318 + 263 319 if (config.editProfile) { 264 - await step('edit-profile', editProfile, { optional: true }); 320 + await step('edit-profile', async () => { 321 + await openOwnProfile(); 322 + await editProfile(); 323 + }, { optional: true }); 265 324 } 266 325 } catch (error) { 267 326 summary.fatal = String(error?.message ?? error);