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.

Extract browser smoke runtime into package staging

alice aa7b206a 3495ed31

+2821 -2156
+2 -2
README.md
··· 58 58 - DMs are intentionally deferred from the current browser-smoke tranche; see `docs/BROWSER_SMOKE.md` for current scope and rationale. 59 59 - Fresh-account creation is still available through the explicit `bootstrap-*` commands, but it is no longer the normal path for repeated browser smoke runs. 60 60 - Detailed browser-smoke workflow, current interaction coverage, and the env-gated `prove` wrapper live in `docs/BROWSER_SMOKE.md`. 61 - - Extraction work toward a cross-PDS standalone package now starts in `pds-smoke-suite/`, with bring-your-own-account and `perlsky` adapter helpers defining the neutral config boundary. 62 - - For now, `script/perlsky-browser-smoke` remains the active `perlsky` adapter and runtime entrypoint while the generic package boundary stabilizes. 61 + - Extraction work toward a cross-PDS standalone package now lives in `pds-smoke-suite/`, which owns the browser runtime, package CLI, example configs, and bring-your-own-account plus `perlsky` adapter helpers. 62 + - For now, `script/perlsky-browser-smoke` remains the active `perlsky` adapter entrypoint in this repo, forwarding into the generic package while the external package boundary stabilizes. 63 63 64 64 Moderation and labels: 65 65
+15
docs/BROWSER_SMOKE.md
··· 84 84 That wrapper still uses `script/perlsky-browser-smoke`, so the script remains the canonical entrypoint. 85 85 It also fails the test run if the browser harness finishes with `summary.ok = false`, so late browser/runtime regressions are no longer silently accepted. 86 86 87 + ## Extraction 88 + 89 + The generic runtime now lives under [pds-smoke-suite](../pds-smoke-suite/README.md). 90 + `perlsky` still keeps [script/perlsky-browser-smoke](/Users/sarah/src/tries/2026-03-10-perlds/script/perlsky-browser-smoke) 91 + as the ergonomic adapter for this repo, but the standalone package now owns: 92 + 93 + - browser runtime entrypoints 94 + - reusable generic config builders 95 + - bring-your-own and `perlsky` adapter helpers 96 + - package-owned examples and README 97 + 98 + This keeps the current `perlsky` workflow stable while making extraction to a 99 + repo-independent package much more straightforward. 100 + 87 101 ## Notes 88 102 89 103 - The reusable dual-account path is intentionally conservative about account creation. Fresh actors are only created through explicit `bootstrap-*` commands. ··· 92 106 - The current broad smoke automates only dedicated smoke accounts. It does not log into `@alice.mosphere.at`. 93 107 - The smoke accounts can still visit and interact with `@alice.mosphere.at` as a public target, but the harness does not authenticate as that account. 94 108 - DMs are intentionally out of scope for now. If we revisit them later, they should be added as a separate documented tranche rather than silently folded into the existing smoke. 109 + - V2 should add direct PDS/AppView contract checks under this browser layer rather than replacing the browser layer outright.
+43 -8
pds-smoke-suite/README.md
··· 2 2 3 3 This directory is the extraction staging area for a standalone `bsky.app` 4 4 compatibility smoke suite that can be used by multiple PDS implementations, not 5 - just `perlsky`. 5 + just `perlsky`. The browser runtime now lives here, while the old 6 + `tools/browser-automation/*` paths remain as thin compatibility wrappers for 7 + the current `perlsky` workflow. 6 8 7 9 ## Current Scope 8 10 ··· 29 31 2. A bring-your-own-accounts mode with minimal configuration 30 32 3. Thin per-PDS adapters for provisioning and implementation-specific defaults 31 33 32 - Right now, the runtime still lives under `tools/browser-automation/`, while this 33 - directory captures the neutral config and adapter surface we want to preserve 34 - during extraction. 34 + The generic runtime, config builders, and adapter helpers now live here. The 35 + older `tools/browser-automation/` entrypoints simply forward into this package 36 + so the existing repo scripts keep working during the extraction. 37 + 38 + ## Current CLI 39 + 40 + The package now has its own CLI entrypoint: 41 + 42 + ```sh 43 + node pds-smoke-suite/bin/pds-smoke-suite.mjs print-example --mode dual 44 + node pds-smoke-suite/bin/pds-smoke-suite.mjs validate --mode dual --config pds-smoke-suite/examples/bring-your-own-dual.json 45 + node pds-smoke-suite/bin/pds-smoke-suite.mjs run-dual --config pds-smoke-suite/examples/bring-your-own-dual.json 46 + ``` 47 + 48 + Examples live in [examples/](./examples): 49 + 50 + - `bring-your-own-single.json` 51 + - `bring-your-own-dual.json` 52 + - `perlsky-dual.json` 35 53 36 54 ## Minimal Configuration Goal 37 55 ··· 65 83 `handle`, `password`, `birthdate`, `postText`, `mediaPostText`, `quoteText`, 66 84 `replyText`, `profileNote`, `cleanupPostPrefixes` 67 85 86 + `pdsHost` is derived automatically from `pdsUrl`, so callers do not need any 87 + perlsky-specific host-setting knowledge just to point the browser at a custom 88 + PDS. 89 + 90 + ## V2 Ideas 91 + 92 + The long-term direction is a test pyramid, not a browser-only harness and not a 93 + pure endpoint-only harness: 94 + 95 + 1. direct PDS/AppView contract tests 96 + 2. cross-service integration checks 97 + 3. a thinner `bsky.app` smoke on top 98 + 99 + The browser layer stays because it catches real `social-app` assumptions and 100 + AppView proxying issues. The direct API/AppView layers belong underneath it so 101 + regressions become easier to debug and less brittle when the UI changes. 102 + 68 103 ## Planned Next Steps 69 104 70 - - move the actual browser runtime from `tools/browser-automation/` into this 71 - package 72 - - add package-owned CLI entrypoints for single-account and dual-account runs 73 - - keep `script/perlsky-browser-smoke` as a thin `perlsky` adapter over the 105 + - keep `script/perlsky-browser-smoke` as a thin `perlsky` adapter over this 74 106 generic package 107 + - add a repo-independent install story once the extracted package boundary 108 + settles 109 + - add direct API/AppView contract tests as the first major v2 expansion 75 110 - revisit a JS-to-TS migration later, after the standalone package boundary is 76 111 stable
+10
pds-smoke-suite/bin/pds-smoke-suite.mjs
··· 1 + #!/usr/bin/env node 2 + import { runCliFromArgv } from '../src/cli.mjs'; 3 + 4 + try { 5 + const exitCode = await runCliFromArgv(process.argv); 6 + process.exitCode = exitCode; 7 + } catch (error) { 8 + console.error(String(error?.message ?? error)); 9 + process.exitCode = 1; 10 + }
+14
pds-smoke-suite/examples/bring-your-own-dual.json
··· 1 + { 2 + "pdsUrl": "https://your-pds.example", 3 + "artifactsDir": "data/browser-smoke/bring-your-own-dual", 4 + "targetHandle": "alice.mosphere.at", 5 + "strictErrors": true, 6 + "primary": { 7 + "handle": "smoke-primary.your-pds.example", 8 + "password": "replace-me" 9 + }, 10 + "secondary": { 11 + "handle": "smoke-secondary.your-pds.example", 12 + "password": "replace-me-too" 13 + } 14 + }
+11
pds-smoke-suite/examples/bring-your-own-single.json
··· 1 + { 2 + "pdsUrl": "https://your-pds.example", 3 + "artifactsDir": "data/browser-smoke/bring-your-own-single", 4 + "targetHandle": "alice.mosphere.at", 5 + "strictErrors": true, 6 + "editProfile": true, 7 + "account": { 8 + "handle": "smoke-primary.your-pds.example", 9 + "password": "replace-me" 10 + } 11 + }
+14
pds-smoke-suite/examples/perlsky-dual.json
··· 1 + { 2 + "pdsUrl": "https://perlsky.mosphere.at", 3 + "artifactsDir": "data/browser-smoke/perlsky-dual", 4 + "targetHandle": "alice.mosphere.at", 5 + "strictErrors": true, 6 + "primary": { 7 + "handle": "smoke-primary.perlsky.mosphere.at", 8 + "password": "replace-me" 9 + }, 10 + "secondary": { 11 + "handle": "smoke-secondary.perlsky.mosphere.at", 12 + "password": "replace-me-too" 13 + } 14 + }
+7 -1
pds-smoke-suite/package.json
··· 2 2 "name": "pds-smoke-suite", 3 3 "private": true, 4 4 "type": "module", 5 + "bin": { 6 + "pds-smoke-suite": "./bin/pds-smoke-suite.mjs" 7 + }, 5 8 "exports": { 6 9 ".": "./src/index.mjs", 7 10 "./config": "./src/config.mjs", 8 11 "./adapters/bring-your-own": "./src/adapters/bring-your-own.mjs", 9 - "./adapters/perlsky": "./src/adapters/perlsky.mjs" 12 + "./adapters/perlsky": "./src/adapters/perlsky.mjs", 13 + "./browser/run-single": "./src/browser/run-single.mjs", 14 + "./browser/run-dual": "./src/browser/run-dual.mjs", 15 + "./cli": "./src/cli.mjs" 10 16 } 11 17 }
+719
pds-smoke-suite/src/browser/lib/dual-actions.mjs
··· 1 + import fs from 'node:fs/promises'; 2 + import path from 'node:path'; 3 + 4 + export const createDualActions = ({ 5 + config, 6 + summary, 7 + appBaseUrl, 8 + wait, 9 + sleep, 10 + normalizeText, 11 + buttonText, 12 + fetchJson, 13 + fetchStatus, 14 + xrpcJson, 15 + avatarPngBase64, 16 + }) => { 17 + const ensureAvatarFixture = async () => { 18 + const file = path.join(config.artifactsDir, 'avatar-fixture.png'); 19 + await fs.writeFile(file, Buffer.from(avatarPngBase64, 'base64')); 20 + return file; 21 + }; 22 + 23 + const login = async (page, account) => { 24 + await page.goto(config.appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); 25 + await page.getByRole('button', { name: 'Sign in' }).nth(0).click({ noWaitAfter: true }); 26 + await wait(page, 1000); 27 + await page.getByRole('button', { name: 'Bluesky Social' }).evaluate((el) => el.click()); 28 + await wait(page, 500); 29 + await page.getByText('Custom').evaluate((el) => el.click()); 30 + await wait(page, 500); 31 + await page.getByPlaceholder('my-server.com').fill(config.pdsHost); 32 + await page.getByRole('button', { name: 'Done' }).evaluate((el) => el.click()); 33 + await wait(page, 500); 34 + const close = page.getByRole('button', { name: 'Close welcome modal' }); 35 + if (await close.count()) { 36 + await close.evaluate((el) => el.click()); 37 + await wait(page, 300); 38 + } 39 + await page.getByPlaceholder('Username or email address').fill(account.handle); 40 + await page.getByPlaceholder('Password').fill(account.password); 41 + await page.getByTestId('loginNextButton').click({ noWaitAfter: true }); 42 + await wait(page, 3000); 43 + }; 44 + 45 + const completeAgeAssuranceIfNeeded = async (page, account) => { 46 + const addBirthdate = page.getByRole('button', { name: /update your birthdate/i }); 47 + if (await addBirthdate.count()) { 48 + await addBirthdate.click({ noWaitAfter: true }); 49 + await wait(page, 800); 50 + await page.getByTestId('birthdayInput').fill(account.birthdate); 51 + await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 52 + await wait(page, 3000); 53 + summary.notes.push(`Completed age-assurance birthdate gate for ${account.handle}`); 54 + } 55 + }; 56 + 57 + const gotoProfile = async (page, handle) => { 58 + await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 59 + waitUntil: 'domcontentloaded', 60 + timeout: 60000, 61 + }); 62 + await wait(page, 3000); 63 + }; 64 + 65 + const waitForProfileHandle = async (page, handle, timeout = 20000) => { 66 + const shortHandle = handle.replace(/^@/, ''); 67 + const handleText = shortHandle.startsWith('@') ? shortHandle : `@${shortHandle}`; 68 + await page.getByText(handleText).first().waitFor({ state: 'visible', timeout }); 69 + }; 70 + 71 + const composePost = async (page, text) => { 72 + await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 73 + await wait(page, 800); 74 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 75 + await editor.click({ noWaitAfter: true }); 76 + await editor.fill(text); 77 + await wait(page, 300); 78 + await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 79 + await wait(page, 4000); 80 + }; 81 + 82 + const uploadComposerMedia = async (page) => { 83 + const mediaFile = await ensureAvatarFixture(); 84 + const openMedia = page.getByTestId('openMediaBtn').last(); 85 + if (!(await openMedia.count())) { 86 + throw new Error('composer media button unavailable'); 87 + } 88 + const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 89 + await openMedia.click({ noWaitAfter: true }); 90 + const chooser = await chooserPromise; 91 + await chooser.setFiles(mediaFile); 92 + await wait(page, 2000); 93 + return mediaFile; 94 + }; 95 + 96 + const composePostWithImage = async (page, text) => { 97 + await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 98 + await wait(page, 800); 99 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 100 + await editor.click({ noWaitAfter: true }); 101 + await editor.fill(text); 102 + const mediaFile = await uploadComposerMedia(page); 103 + await wait(page, 500); 104 + await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 105 + await wait(page, 5000); 106 + return { mediaFile }; 107 + }; 108 + 109 + const dismissModalBackdropIfPresent = async (page) => { 110 + const backdrop = page.locator('[aria-label*="click to close"]').last(); 111 + if (await backdrop.count()) { 112 + await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 113 + await wait(page, 400); 114 + } 115 + }; 116 + 117 + const uploadProfileAvatar = async (page) => { 118 + const avatarFile = await ensureAvatarFixture(); 119 + let fileInputs = page.locator('input[type="file"]'); 120 + let count = await fileInputs.count(); 121 + 122 + if (count === 0) { 123 + const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 124 + if (await changeAvatar.count()) { 125 + await changeAvatar.click({ noWaitAfter: true }); 126 + await wait(page, 500); 127 + const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 128 + if (await uploadFromFiles.count()) { 129 + const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 130 + await uploadFromFiles.click({ noWaitAfter: true }); 131 + const chooser = await chooserPromise; 132 + await chooser.setFiles(avatarFile); 133 + await wait(page, 750); 134 + const editImageHeading = page.getByText(/^Edit image$/).last(); 135 + if (await editImageHeading.count()) { 136 + await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 137 + const cropSave = page.getByRole('button', { name: 'Save' }).last(); 138 + await cropSave.click({ noWaitAfter: true }); 139 + await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 140 + } 141 + await wait(page, 1500); 142 + return avatarFile; 143 + } 144 + } 145 + } 146 + 147 + if (count === 0) { 148 + throw new Error('profile avatar file input unavailable'); 149 + } 150 + 151 + await fileInputs.first().setInputFiles(avatarFile); 152 + await wait(page, 1500); 153 + return avatarFile; 154 + }; 155 + 156 + const editProfile = async (page, account) => { 157 + const edit = page.getByRole('button', { name: /edit profile/i }); 158 + if (!(await edit.count())) { 159 + throw new Error(`edit profile button unavailable for ${account.handle}`); 160 + } 161 + await edit.click({ noWaitAfter: true }); 162 + await wait(page, 1000); 163 + await dismissModalBackdropIfPresent(page); 164 + const avatarFile = await uploadProfileAvatar(page); 165 + const bioField = page.locator('textarea[aria-label="Description"]').first(); 166 + if (await bioField.count()) { 167 + await bioField.fill(account.profileNote); 168 + const actual = await bioField.inputValue(); 169 + if (actual !== account.profileNote) { 170 + throw new Error(`profile description fill did not stick for ${account.handle}: ${actual}`); 171 + } 172 + } 173 + const save = page.getByTestId('editProfileSaveBtn'); 174 + await save.waitFor({ state: 'visible', timeout: 15000 }); 175 + await page.waitForFunction(() => { 176 + const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 177 + return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 178 + }, undefined, { timeout: 15000 }); 179 + await save.click({ noWaitAfter: true }); 180 + await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 181 + timeout: 15000, 182 + }); 183 + await wait(page, 3000); 184 + return { avatarFile, profileNote: account.profileNote }; 185 + }; 186 + 187 + const verifyLocalProfileAfterEdit = async (account) => { 188 + const didResult = await xrpcJson('com.atproto.identity.resolveHandle', { 189 + params: { handle: account.handle }, 190 + }); 191 + if (!didResult.ok || didResult.json?.did !== account.did) { 192 + throw new Error(`handle did mismatch for ${account.handle}`); 193 + } 194 + const result = await xrpcJson('com.atproto.repo.getRecord', { 195 + params: { 196 + repo: account.did, 197 + collection: 'app.bsky.actor.profile', 198 + rkey: 'self', 199 + }, 200 + }); 201 + if (!result.ok) { 202 + throw new Error(`profile record lookup failed for ${account.handle}: ${result.status} ${result.text}`); 203 + } 204 + const avatarCid = result.json?.value?.avatar?.ref?.$link; 205 + const description = result.json?.value?.description; 206 + if (description !== account.profileNote || typeof avatarCid !== 'string' || !avatarCid.length) { 207 + throw new Error(`profile record did not contain expected avatar/description for ${account.handle}`); 208 + } 209 + return { avatarCid, description }; 210 + }; 211 + 212 + const verifyPublicProfileAfterEdit = async (account) => { 213 + const started = Date.now(); 214 + let result; 215 + while (Date.now() - started < (config.publicCheckTimeoutMs ?? 180000)) { 216 + result = await fetchJson( 217 + `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(account.handle)}`, 218 + ); 219 + if ( 220 + result.ok && 221 + result.json?.description === account.profileNote && 222 + typeof result.json?.avatar === 'string' && 223 + result.json.avatar.length > 0 224 + ) { 225 + break; 226 + } 227 + await sleep(5000); 228 + } 229 + if (!result?.ok) { 230 + throw new Error(`public profile lookup failed for ${account.handle}: ${result?.status} ${result?.text}`); 231 + } 232 + if (result.json?.description !== account.profileNote || typeof result.json?.avatar !== 'string') { 233 + throw new Error(`public profile missing updated description/avatar for ${account.handle}`); 234 + } 235 + const avatarResult = await fetchStatus(result.json.avatar); 236 + if (!avatarResult.ok) { 237 + throw new Error(`public avatar URL returned ${avatarResult.status} for ${account.handle}`); 238 + } 239 + return { 240 + avatar: result.json.avatar, 241 + avatarStatus: avatarResult.status, 242 + description: result.json.description, 243 + }; 244 + }; 245 + 246 + const findRowByPrimaryText = async (page, needle, timeout = 60000) => { 247 + const started = Date.now(); 248 + while (Date.now() - started < timeout) { 249 + const rows = page.locator('[data-testid^="feedItem-by-"]'); 250 + const count = await rows.count(); 251 + for (let i = 0; i < count; i += 1) { 252 + const row = rows.nth(i); 253 + const primaryText = row.locator('[data-testid="postText"]').first(); 254 + if (!(await primaryText.count())) { 255 + continue; 256 + } 257 + const text = normalizeText(await primaryText.textContent()); 258 + if (text === needle) { 259 + await row.waitFor({ state: 'visible', timeout: 10000 }); 260 + return row; 261 + } 262 + } 263 + await wait(page, 1000); 264 + } 265 + throw new Error(`feed item with primary text not found: ${needle}`); 266 + }; 267 + 268 + const maybeFindRowByPrimaryText = async (page, needle, timeout = 10000) => { 269 + try { 270 + return await findRowByPrimaryText(page, needle, timeout); 271 + } catch { 272 + return null; 273 + } 274 + }; 275 + 276 + const clickLike = async (page, row) => { 277 + const btn = row.getByTestId('likeBtn').first(); 278 + await btn.click({ noWaitAfter: true }); 279 + await wait(page, 1500); 280 + }; 281 + 282 + const ensureLiked = async (page, row) => { 283 + const btn = row.getByTestId('likeBtn').first(); 284 + const before = await buttonText(btn); 285 + if (/unlike/i.test(before)) { 286 + return { note: 'already liked' }; 287 + } 288 + await clickLike(page, row); 289 + return { note: await buttonText(btn) }; 290 + }; 291 + 292 + const ensureNotLiked = async (page, row) => { 293 + const btn = row.getByTestId('likeBtn').first(); 294 + const before = await buttonText(btn); 295 + if (!/unlike/i.test(before)) { 296 + return { note: 'already not liked' }; 297 + } 298 + await clickLike(page, row); 299 + return { note: await buttonText(btn) }; 300 + }; 301 + 302 + const dismissBlockingOverlays = async (page) => { 303 + const backdrop = page.locator('[aria-label*="click to close"]').last(); 304 + if (await backdrop.count()) { 305 + await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 306 + await wait(page, 400); 307 + } 308 + 309 + const dialog = page.locator('[role="dialog"][aria-modal="true"]').last(); 310 + if (await dialog.count()) { 311 + const close = dialog.getByRole('button', { name: /close/i }).last(); 312 + if (await close.count()) { 313 + await close.click({ noWaitAfter: true }).catch(() => undefined); 314 + await wait(page, 400); 315 + } 316 + await page.keyboard.press('Escape').catch(() => undefined); 317 + await wait(page, 400); 318 + } 319 + }; 320 + 321 + const clickRepost = async (page, row) => { 322 + await dismissBlockingOverlays(page); 323 + const btn = row.getByTestId('repostBtn').first(); 324 + await btn.click({ noWaitAfter: true }); 325 + await wait(page, 500); 326 + const repost = page.getByText(/^Repost$/).last(); 327 + if (await repost.count()) { 328 + await repost.click({ noWaitAfter: true }); 329 + await wait(page, 1500); 330 + await dismissBlockingOverlays(page); 331 + } 332 + }; 333 + 334 + const ensureReposted = async (page, row) => { 335 + const btn = row.getByTestId('repostBtn').first(); 336 + const before = await buttonText(btn); 337 + if (/undo repost|remove repost/i.test(before)) { 338 + return { note: 'already reposted' }; 339 + } 340 + await clickRepost(page, row); 341 + return { note: await buttonText(btn) }; 342 + }; 343 + 344 + const ensureNotReposted = async (page, row) => { 345 + const btn = row.getByTestId('repostBtn').first(); 346 + const before = await buttonText(btn); 347 + if (!/undo repost|remove repost/i.test(before)) { 348 + return { note: 'already not reposted' }; 349 + } 350 + await btn.click({ noWaitAfter: true }); 351 + await wait(page, 1500); 352 + return { note: await buttonText(btn) }; 353 + }; 354 + 355 + const ensureBookmarked = async (page, row) => { 356 + const btn = row.getByTestId('postBookmarkBtn').first(); 357 + const before = await buttonText(btn); 358 + if (/remove from saved posts/i.test(before)) { 359 + return { note: 'already bookmarked' }; 360 + } 361 + await btn.click({ noWaitAfter: true }); 362 + await wait(page, 1500); 363 + return { note: await buttonText(btn) }; 364 + }; 365 + 366 + const ensureNotBookmarked = async (page, row) => { 367 + const btn = row.getByTestId('postBookmarkBtn').first(); 368 + const before = await buttonText(btn); 369 + if (!/remove from saved posts/i.test(before)) { 370 + return { note: 'already not bookmarked' }; 371 + } 372 + await btn.click({ noWaitAfter: true }); 373 + await wait(page, 1500); 374 + return { note: await buttonText(btn) }; 375 + }; 376 + 377 + const waitForVisibleEditor = async (page) => { 378 + const editors = page.locator('[aria-label="Rich-Text Editor"]'); 379 + const started = Date.now(); 380 + while (Date.now() - started < 20000) { 381 + const count = await editors.count(); 382 + for (let i = count - 1; i >= 0; i -= 1) { 383 + const editor = editors.nth(i); 384 + if (await editor.isVisible().catch(() => false)) { 385 + return editor; 386 + } 387 + } 388 + await wait(page, 250); 389 + } 390 + throw new Error('visible rich-text editor not found'); 391 + }; 392 + 393 + const publishComposer = async (page, text, { applyWritesLabel, publishLabel }) => { 394 + const editor = await waitForVisibleEditor(page); 395 + await editor.click({ noWaitAfter: true }); 396 + await editor.fill(text); 397 + 398 + const publish = page.getByTestId('composerPublishBtn').last(); 399 + await publish.waitFor({ state: 'visible', timeout: 15000 }); 400 + const responsePromise = page.waitForResponse( 401 + (res) => 402 + res.url().includes('/xrpc/com.atproto.repo.applyWrites') && 403 + res.request().method() === 'POST', 404 + { timeout: 30000 }, 405 + ); 406 + await publish.click({ noWaitAfter: true }); 407 + const response = await responsePromise; 408 + if (response.status() !== 200) { 409 + throw new Error(`${applyWritesLabel} failed with status ${response.status()}`); 410 + } 411 + await wait(page, 4000); 412 + 413 + const buttonName = publishLabel instanceof RegExp ? publishLabel : /publish/i; 414 + await page.getByTestId('composerPublishBtn').getByRole('button', { name: buttonName }).waitFor({ 415 + state: 'detached', 416 + timeout: 15000, 417 + }).catch(() => undefined); 418 + }; 419 + 420 + const clickQuote = async (page, row, text) => { 421 + await dismissBlockingOverlays(page); 422 + const btn = row.getByTestId('repostBtn').first(); 423 + await btn.click({ noWaitAfter: true }); 424 + await wait(page, 500); 425 + const quote = page.getByText(/^Quote post$/).last(); 426 + if (!(await quote.count())) { 427 + throw new Error('quote option not available'); 428 + } 429 + await quote.click({ noWaitAfter: true }); 430 + await publishComposer(page, text, { 431 + applyWritesLabel: 'quote publish', 432 + publishLabel: /publish post/i, 433 + }); 434 + await dismissBlockingOverlays(page); 435 + }; 436 + 437 + 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); 442 + 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 }); 451 + await wait(page, 500); 452 + } 453 + } 454 + 455 + await publishComposer(page, text, { 456 + applyWritesLabel: 'reply publish', 457 + publishLabel: /publish reply|reply/i, 458 + }); 459 + await dismissBlockingOverlays(page); 460 + }; 461 + 462 + const openProfileMenu = async (page) => { 463 + const btn = page.getByTestId('profileHeaderDropdownBtn').first(); 464 + await btn.waitFor({ state: 'visible', timeout: 15000 }); 465 + await btn.click({ noWaitAfter: true }); 466 + const menu = page.locator('[role="menu"]').last(); 467 + await menu.waitFor({ state: 'visible', timeout: 10000 }); 468 + return menu; 469 + }; 470 + 471 + const menuItems = async (page) => 472 + page.locator('[role="menuitem"]').evaluateAll((els) => 473 + els.map((el) => (el.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean), 474 + ); 475 + 476 + const closeActiveMenu = async (page) => { 477 + const backdrop = page.locator('[aria-label*="backdrop"]').last(); 478 + if (await backdrop.count()) { 479 + await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 480 + await wait(page, 400); 481 + return; 482 + } 483 + await page.keyboard.press('Escape').catch(() => undefined); 484 + await wait(page, 400); 485 + }; 486 + 487 + const ensureProfileMuted = async (page) => { 488 + await openProfileMenu(page); 489 + const items = await menuItems(page); 490 + if (items.some((item) => /unmute account/i.test(item))) { 491 + await closeActiveMenu(page); 492 + return { note: 'already muted' }; 493 + } 494 + await page.getByRole('menuitem', { name: /mute account/i }).click({ noWaitAfter: true }); 495 + await wait(page, 1500); 496 + await openProfileMenu(page); 497 + const after = await menuItems(page); 498 + await closeActiveMenu(page); 499 + if (!after.some((item) => /unmute account/i.test(item))) { 500 + throw new Error('mute account did not switch menu state'); 501 + } 502 + return { note: 'muted account' }; 503 + }; 504 + 505 + const ensureProfileUnmuted = async (page) => { 506 + await openProfileMenu(page); 507 + const items = await menuItems(page); 508 + if (!items.some((item) => /unmute account/i.test(item))) { 509 + await closeActiveMenu(page); 510 + return { note: 'already unmuted' }; 511 + } 512 + await page.getByRole('menuitem', { name: /unmute account/i }).click({ noWaitAfter: true }); 513 + await wait(page, 1500); 514 + await openProfileMenu(page); 515 + const after = await menuItems(page); 516 + await closeActiveMenu(page); 517 + if (!after.some((item) => /mute account/i.test(item))) { 518 + throw new Error('unmute account did not restore menu state'); 519 + } 520 + return { note: 'unmuted account' }; 521 + }; 522 + 523 + const blockProfile = async (page) => { 524 + await openProfileMenu(page); 525 + const items = await menuItems(page); 526 + if (items.some((item) => /unblock account/i.test(item))) { 527 + await closeActiveMenu(page); 528 + return { note: 'already blocked' }; 529 + } 530 + await page.getByRole('menuitem', { name: /block account/i }).click({ noWaitAfter: true }); 531 + const dialog = page.locator('[role="dialog"]').last(); 532 + await dialog.waitFor({ state: 'visible', timeout: 10000 }); 533 + await dialog.getByRole('button', { name: /^Block$/i }).click({ noWaitAfter: true }); 534 + await wait(page, 2500); 535 + const unblock = page.getByRole('button', { name: /unblock/i }).first(); 536 + if (!(await unblock.count())) { 537 + throw new Error('block account did not expose an unblock button'); 538 + } 539 + return { note: 'blocked account' }; 540 + }; 541 + 542 + const unblockProfile = async (page) => { 543 + const unblock = page.getByRole('button', { name: /unblock/i }).first(); 544 + if (!(await unblock.count())) { 545 + return { note: 'already unblocked' }; 546 + } 547 + await unblock.click({ noWaitAfter: true }); 548 + await wait(page, 1000); 549 + const dialog = page.locator('[role="dialog"]').last(); 550 + const confirm = dialog.getByRole('button', { name: /unblock/i }).last(); 551 + if (await confirm.count()) { 552 + await confirm.click({ noWaitAfter: true }); 553 + } 554 + await wait(page, 1500); 555 + const blockedBadge = page.getByText(/user blocked/i).first(); 556 + if (await blockedBadge.count()) { 557 + throw new Error('profile still appears blocked after unblock'); 558 + } 559 + return { note: 'unblocked account' }; 560 + }; 561 + 562 + const openPostOptions = async (page, row) => { 563 + const btn = row.getByTestId('postDropdownBtn').first(); 564 + await btn.click({ noWaitAfter: true }); 565 + const menu = page.locator('[role="menu"]').last(); 566 + await menu.waitFor({ state: 'visible', timeout: 10000 }); 567 + return menu; 568 + }; 569 + 570 + const openReportPostDraft = async (page, row) => { 571 + await openPostOptions(page, row); 572 + await page.getByRole('menuitem', { name: /report post/i }).click({ noWaitAfter: true }); 573 + const dialog = page.locator('[role="dialog"]').last(); 574 + await dialog.waitFor({ state: 'visible', timeout: 10000 }); 575 + await dialog.getByRole('button', { name: /create report for other/i }).click({ noWaitAfter: true }); 576 + await wait(page, 1000); 577 + const submit = dialog.getByRole('button', { name: /submit report/i }).last(); 578 + await submit.waitFor({ state: 'visible', timeout: 10000 }); 579 + const body = normalizeText(await dialog.textContent()); 580 + const close = dialog.getByRole('button', { name: /close active dialog/i }).last(); 581 + if (await close.count()) { 582 + await close.click({ noWaitAfter: true }); 583 + } else { 584 + await page.keyboard.press('Escape').catch(() => undefined); 585 + } 586 + await wait(page, 1000); 587 + return { 588 + note: 'opened report draft without submitting', 589 + submitVisible: true, 590 + body, 591 + }; 592 + }; 593 + 594 + const maybeFollow = async (page) => { 595 + const follow = page.getByTestId('followBtn').first(); 596 + if (await follow.count()) { 597 + const label = (await follow.getAttribute('aria-label')) ?? ''; 598 + if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 599 + return { note: 'already following' }; 600 + } 601 + await follow.click({ noWaitAfter: true }); 602 + await wait(page, 2000); 603 + return { note: 'follow attempted' }; 604 + } 605 + const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 606 + if (!(await roleFollow.count())) { 607 + return { note: 'follow button unavailable' }; 608 + } 609 + const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 610 + if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 611 + return { note: 'already following' }; 612 + } 613 + await roleFollow.click({ noWaitAfter: true }); 614 + await wait(page, 2000); 615 + return { note: 'follow attempted via role button' }; 616 + }; 617 + 618 + const maybeUnfollow = async (page) => { 619 + const btn = page.getByTestId('unfollowBtn').first(); 620 + if (!(await btn.count())) { 621 + return { note: 'already not following' }; 622 + } 623 + await btn.click({ noWaitAfter: true }); 624 + await wait(page, 2000); 625 + return { note: 'unfollow attempted' }; 626 + }; 627 + 628 + const openNotifications = async (page) => { 629 + await page.goto(`${appBaseUrl}/notifications`, { 630 + waitUntil: 'domcontentloaded', 631 + timeout: 60000, 632 + }); 633 + await wait(page, 3000); 634 + const heading = page.getByText(/^Notifications$/).first(); 635 + if (await heading.count()) { 636 + await heading.waitFor({ state: 'visible', timeout: 15000 }); 637 + } 638 + }; 639 + 640 + const openSavedPosts = async (page) => { 641 + await page.goto(`${appBaseUrl}/saved`, { 642 + waitUntil: 'domcontentloaded', 643 + timeout: 60000, 644 + }); 645 + await wait(page, 3000); 646 + }; 647 + 648 + const waitForNotificationsFeed = async (page) => { 649 + const feed = page.getByTestId('notifsFeed').first(); 650 + if (await feed.count()) { 651 + await feed.waitFor({ state: 'visible', timeout: 15000 }); 652 + return feed; 653 + } 654 + return null; 655 + }; 656 + 657 + const openProfileTab = async (page, name) => { 658 + const tab = page.getByRole('tab', { name }).first(); 659 + await tab.waitFor({ state: 'visible', timeout: 15000 }); 660 + await tab.click({ noWaitAfter: true }); 661 + await wait(page, 2000); 662 + }; 663 + 664 + const deletePostRow = async (page, row) => { 665 + await openPostOptions(page, row); 666 + const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 667 + await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 668 + await deleteItem.click({ noWaitAfter: true }); 669 + const dialog = page.locator('[role="dialog"]').last(); 670 + await dialog.waitFor({ state: 'visible', timeout: 10000 }); 671 + const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 672 + await confirm.click({ noWaitAfter: true }); 673 + await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 674 + await wait(page, 3000); 675 + }; 676 + 677 + const maybeDeleteOwnPostByText = async (page, text, successNote) => { 678 + const row = await maybeFindRowByPrimaryText(page, text, 10000); 679 + if (!row) { 680 + return { note: `not surfaced for cleanup: ${text}` }; 681 + } 682 + await deletePostRow(page, row); 683 + return { note: successNote }; 684 + }; 685 + 686 + return { 687 + login, 688 + completeAgeAssuranceIfNeeded, 689 + gotoProfile, 690 + waitForProfileHandle, 691 + composePost, 692 + composePostWithImage, 693 + editProfile, 694 + verifyLocalProfileAfterEdit, 695 + verifyPublicProfileAfterEdit, 696 + findRowByPrimaryText, 697 + ensureLiked, 698 + ensureNotLiked, 699 + ensureReposted, 700 + ensureNotReposted, 701 + ensureBookmarked, 702 + ensureNotBookmarked, 703 + clickQuote, 704 + clickReply, 705 + maybeFollow, 706 + maybeUnfollow, 707 + openNotifications, 708 + openSavedPosts, 709 + waitForNotificationsFeed, 710 + ensureProfileMuted, 711 + ensureProfileUnmuted, 712 + blockProfile, 713 + unblockProfile, 714 + openReportPostDraft, 715 + openProfileTab, 716 + maybeDeleteOwnPostByText, 717 + openListProfile: gotoProfile, 718 + }; 719 + };
+470
pds-smoke-suite/src/browser/lib/dual-scenario.mjs
··· 1 + export const runDualScenario = async ({ 2 + step, 3 + primaryPage, 4 + secondaryPage, 5 + primary, 6 + secondary, 7 + login, 8 + completeAgeAssuranceIfNeeded, 9 + createSession, 10 + cleanupStaleSmokeArtifacts, 11 + composePost, 12 + waitForOwnPostRecord, 13 + gotoProfile, 14 + waitForProfileHandle, 15 + findRowByPrimaryText, 16 + composePostWithImage, 17 + editProfile, 18 + verifyLocalProfileAfterEdit, 19 + verifyPublicProfileAfterEdit, 20 + createList, 21 + waitForOwnListRecord, 22 + recordRkey, 23 + openListPage, 24 + editCurrentList, 25 + addUserToCurrentList, 26 + waitForOwnListItemRecord, 27 + removeUserFromCurrentList, 28 + waitForNoOwnRecord, 29 + deleteCurrentList, 30 + maybeUnfollow, 31 + maybeFollow, 32 + waitForFollowRecord, 33 + ensureLiked, 34 + ensureBookmarked, 35 + openSavedPosts, 36 + ensureReposted, 37 + clickQuote, 38 + clickReply, 39 + pollNotifications, 40 + openNotifications, 41 + waitForNotificationsFeed, 42 + ensureProfileMuted, 43 + ensureProfileUnmuted, 44 + openReportPostDraft, 45 + blockProfile, 46 + unblockProfile, 47 + setRadioSetting, 48 + setCheckboxSetting, 49 + ensureNotLiked, 50 + ensureNotBookmarked, 51 + ensureNotReposted, 52 + openProfileTab, 53 + maybeDeleteOwnPostByText, 54 + }) => { 55 + await step('primary-login', () => login(primaryPage, primary), { pageNames: ['primary'] }); 56 + await step('primary-age-assurance', () => completeAgeAssuranceIfNeeded(primaryPage, primary), { 57 + optional: true, 58 + pageNames: ['primary'], 59 + }); 60 + await step('secondary-login', () => login(secondaryPage, secondary), { pageNames: ['secondary'] }); 61 + await step('secondary-age-assurance', () => completeAgeAssuranceIfNeeded(secondaryPage, secondary), { 62 + optional: true, 63 + pageNames: ['secondary'], 64 + }); 65 + 66 + primary.session = await createSession(primary.handle, primary.password); 67 + primary.accessJwt = primary.session.accessJwt; 68 + primary.did = primary.session.did; 69 + secondary.session = await createSession(secondary.handle, secondary.password); 70 + secondary.accessJwt = secondary.session.accessJwt; 71 + secondary.did = secondary.session.did; 72 + 73 + await step('primary-preclean-stale-artifacts', async () => cleanupStaleSmokeArtifacts(primary)); 74 + await step('secondary-preclean-stale-artifacts', async () => cleanupStaleSmokeArtifacts(secondary)); 75 + 76 + await step('primary-compose-root-post', () => composePost(primaryPage, primary.postText), { 77 + pageNames: ['primary'], 78 + }); 79 + 80 + primary.rootPost = await waitForOwnPostRecord(primary, primary.postText); 81 + 82 + await step('primary-own-profile', async () => { 83 + await gotoProfile(primaryPage, primary.handle); 84 + await waitForProfileHandle(primaryPage, primary.handle); 85 + const row = await findRowByPrimaryText(primaryPage, primary.postText, 60000); 86 + const rowTestId = await row.getAttribute('data-testid'); 87 + return { rowTestId }; 88 + }, { pageNames: ['primary'] }); 89 + 90 + await step('primary-compose-image-post', async () => composePostWithImage(primaryPage, primary.mediaPostText), { 91 + pageNames: ['primary'], 92 + }); 93 + 94 + await step('primary-image-post-record', async () => { 95 + primary.imagePost = await waitForOwnPostRecord(primary, primary.mediaPostText); 96 + const embed = primary.imagePost.value?.embed; 97 + if (embed?.$type !== 'app.bsky.embed.images' || !Array.isArray(embed.images) || embed.images.length < 1) { 98 + throw new Error('image post did not persist an app.bsky.embed.images record'); 99 + } 100 + return { 101 + uri: primary.imagePost.uri, 102 + imageCount: embed.images.length, 103 + mimeType: embed.images[0]?.image?.mimeType, 104 + }; 105 + }); 106 + 107 + await step('secondary-compose-root-post', () => composePost(secondaryPage, secondary.postText), { 108 + pageNames: ['secondary'], 109 + }); 110 + 111 + secondary.rootPost = await waitForOwnPostRecord(secondary, secondary.postText); 112 + 113 + await step('secondary-own-profile', async () => { 114 + await gotoProfile(secondaryPage, secondary.handle); 115 + await waitForProfileHandle(secondaryPage, secondary.handle); 116 + const row = await findRowByPrimaryText(secondaryPage, secondary.postText, 60000); 117 + const rowTestId = await row.getAttribute('data-testid'); 118 + return { rowTestId }; 119 + }, { pageNames: ['secondary'] }); 120 + 121 + await step('primary-edit-profile', () => editProfile(primaryPage, primary), { 122 + pageNames: ['primary'], 123 + }); 124 + 125 + await step('primary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(primary)); 126 + await step('primary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(primary)); 127 + 128 + await step('secondary-edit-profile', () => editProfile(secondaryPage, secondary), { 129 + pageNames: ['secondary'], 130 + }); 131 + 132 + await step('secondary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(secondary)); 133 + await step('secondary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(secondary)); 134 + 135 + await step('primary-create-list', async () => { 136 + return createList(primaryPage, primary.listName, primary.listDescription); 137 + }, { pageNames: ['primary'] }); 138 + 139 + await step('primary-list-record', async () => { 140 + primary.listRecord = await waitForOwnListRecord(primary, primary.listName); 141 + primary.listRkey = recordRkey(primary.listRecord); 142 + if (primary.listRecord.value?.description !== primary.listDescription) { 143 + throw new Error('list record description did not match after create'); 144 + } 145 + return { 146 + uri: primary.listRecord.uri, 147 + rkey: primary.listRkey, 148 + description: primary.listRecord.value?.description, 149 + }; 150 + }); 151 + 152 + await step('primary-edit-list', async () => { 153 + await openListPage(primaryPage, primary.handle, primary.listRkey); 154 + return editCurrentList(primaryPage, primary.listUpdatedName, primary.listUpdatedDescription); 155 + }, { pageNames: ['primary'] }); 156 + 157 + await step('primary-list-record-after-edit', async () => { 158 + primary.listRecord = await waitForOwnListRecord(primary, primary.listUpdatedName); 159 + primary.listRkey = recordRkey(primary.listRecord); 160 + if (primary.listRecord.value?.description !== primary.listUpdatedDescription) { 161 + throw new Error('list record description did not match after edit'); 162 + } 163 + return { 164 + uri: primary.listRecord.uri, 165 + rkey: primary.listRkey, 166 + description: primary.listRecord.value?.description, 167 + }; 168 + }); 169 + 170 + await step('primary-list-add-secondary-member', async () => { 171 + await openListPage(primaryPage, primary.handle, primary.listRkey); 172 + return addUserToCurrentList(primaryPage, secondary.handle); 173 + }, { pageNames: ['primary'] }); 174 + 175 + await step('primary-list-member-record', async () => { 176 + primary.listItemRecord = await waitForOwnListItemRecord(primary, primary.listRecord.uri, secondary.did); 177 + return { 178 + uri: primary.listItemRecord.uri, 179 + rkey: recordRkey(primary.listItemRecord), 180 + }; 181 + }); 182 + 183 + await step('primary-list-remove-secondary-member', async () => { 184 + await openListPage(primaryPage, primary.handle, primary.listRkey); 185 + return removeUserFromCurrentList(primaryPage, secondary.handle); 186 + }, { pageNames: ['primary'] }); 187 + 188 + await step('primary-list-member-record-removed', async () => { 189 + await waitForNoOwnRecord( 190 + primary, 191 + 'app.bsky.graph.listitem', 192 + (record) => 193 + record?.value?.list === primary.listRecord.uri && record?.value?.subject === secondary.did, 194 + ); 195 + return { listUri: primary.listRecord.uri, subject: secondary.did }; 196 + }); 197 + 198 + await step('primary-delete-list', async () => { 199 + await openListPage(primaryPage, primary.handle, primary.listRkey); 200 + return deleteCurrentList(primaryPage); 201 + }, { pageNames: ['primary'] }); 202 + 203 + await step('primary-list-record-removed', async () => { 204 + await waitForNoOwnRecord( 205 + primary, 206 + 'app.bsky.graph.list', 207 + (record) => recordRkey(record) === primary.listRkey, 208 + ); 209 + return { rkey: primary.listRkey }; 210 + }); 211 + 212 + const primaryWaveStarted = Date.now() - 1000; 213 + await step('primary-open-secondary-profile', async () => { 214 + await gotoProfile(primaryPage, secondary.handle); 215 + await waitForProfileHandle(primaryPage, secondary.handle); 216 + }, { pageNames: ['primary'] }); 217 + 218 + await step('primary-reset-follow-secondary', () => maybeUnfollow(primaryPage), { 219 + optional: true, 220 + pageNames: ['primary'], 221 + }); 222 + 223 + await step('primary-follow-secondary', () => maybeFollow(primaryPage), { 224 + pageNames: ['primary'], 225 + }); 226 + 227 + await step('primary-follow-secondary-record', async () => { 228 + const record = await waitForFollowRecord(primary, secondary.did); 229 + return { uri: record.uri }; 230 + }); 231 + 232 + await step('primary-like-secondary-post', async () => { 233 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 234 + return ensureLiked(primaryPage, row); 235 + }, { pageNames: ['primary'] }); 236 + 237 + await step('primary-bookmark-secondary-post', async () => { 238 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 239 + return ensureBookmarked(primaryPage, row); 240 + }, { pageNames: ['primary'] }); 241 + 242 + await step('primary-saved-posts-secondary', async () => { 243 + await openSavedPosts(primaryPage); 244 + await primaryPage.getByText(`@${secondary.handle.replace(/^@/, '')}`).first().waitFor({ 245 + state: 'visible', 246 + timeout: 20000, 247 + }); 248 + return { note: `saved post by ${secondary.handle}` }; 249 + }, { pageNames: ['primary'] }); 250 + 251 + await step('primary-repost-secondary-post', async () => { 252 + await gotoProfile(primaryPage, secondary.handle); 253 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 254 + return ensureReposted(primaryPage, row); 255 + }, { pageNames: ['primary'] }); 256 + 257 + await step('primary-quote-secondary-post', async () => { 258 + await gotoProfile(primaryPage, secondary.handle); 259 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 260 + await clickQuote(primaryPage, row, primary.quoteText); 261 + primary.quotePost = await waitForOwnPostRecord(primary, primary.quoteText); 262 + return { quoteText: primary.quoteText, uri: primary.quotePost.uri }; 263 + }, { pageNames: ['primary'] }); 264 + 265 + await step('primary-reply-secondary-post', async () => { 266 + await gotoProfile(primaryPage, secondary.handle); 267 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 268 + await clickReply(primaryPage, row, primary.replyText); 269 + primary.replyPost = await waitForOwnPostRecord(primary, primary.replyText); 270 + return { replyText: primary.replyText, uri: primary.replyPost.uri }; 271 + }, { pageNames: ['primary'] }); 272 + 273 + await step('secondary-notification-api-primary-engagement-wave', async () => { 274 + const result = await pollNotifications({ 275 + account: secondary, 276 + authorHandle: primary.handle, 277 + reasons: ['like', 'repost', 'quote', 'reply'], 278 + minIndexedAt: primaryWaveStarted, 279 + }); 280 + return { 281 + reasons: result.notifications.map((item) => item.reason), 282 + sample: result.allNotifications.slice(0, 5), 283 + }; 284 + }); 285 + 286 + await step('secondary-notifications-page', async () => { 287 + await openNotifications(secondaryPage); 288 + const feed = await waitForNotificationsFeed(secondaryPage); 289 + return { note: feed ? 'notifications feed visible' : 'notifications page visible without explicit feed testid' }; 290 + }, { pageNames: ['secondary'] }); 291 + 292 + const secondaryWaveStarted = Date.now() - 1000; 293 + await step('secondary-open-primary-profile', async () => { 294 + await gotoProfile(secondaryPage, primary.handle); 295 + await waitForProfileHandle(secondaryPage, primary.handle); 296 + }, { pageNames: ['secondary'] }); 297 + 298 + await step('secondary-reset-follow-primary', () => maybeUnfollow(secondaryPage), { 299 + optional: true, 300 + pageNames: ['secondary'], 301 + }); 302 + 303 + await step('secondary-follow-primary', () => maybeFollow(secondaryPage), { 304 + pageNames: ['secondary'], 305 + }); 306 + 307 + await step('secondary-follow-primary-record', async () => { 308 + const record = await waitForFollowRecord(secondary, primary.did); 309 + return { uri: record.uri }; 310 + }); 311 + 312 + await step('primary-notification-api-secondary-follow', async () => { 313 + const result = await pollNotifications({ 314 + account: primary, 315 + authorHandle: secondary.handle, 316 + reasons: ['follow'], 317 + minIndexedAt: secondaryWaveStarted, 318 + timeoutMs: 30000, 319 + }); 320 + return { 321 + reasons: result.notifications.map((item) => item.reason), 322 + sample: result.allNotifications.slice(0, 5), 323 + }; 324 + }, { optional: true }); 325 + 326 + await step('primary-notifications-page', async () => { 327 + await openNotifications(primaryPage); 328 + const feed = await waitForNotificationsFeed(primaryPage); 329 + return { note: feed ? 'notifications feed visible' : 'notifications page visible without explicit feed testid' }; 330 + }, { pageNames: ['primary'] }); 331 + 332 + await step('primary-mute-secondary', async () => { 333 + await gotoProfile(primaryPage, secondary.handle); 334 + return ensureProfileMuted(primaryPage); 335 + }, { pageNames: ['primary'] }); 336 + 337 + await step('primary-unmute-secondary', async () => { 338 + await gotoProfile(primaryPage, secondary.handle); 339 + return ensureProfileUnmuted(primaryPage); 340 + }, { pageNames: ['primary'] }); 341 + 342 + await step('secondary-report-primary-post-draft', async () => { 343 + await gotoProfile(secondaryPage, primary.handle); 344 + const row = await findRowByPrimaryText(secondaryPage, primary.postText, 60000); 345 + return openReportPostDraft(secondaryPage, row); 346 + }, { pageNames: ['secondary'] }); 347 + 348 + await step('secondary-block-primary', async () => { 349 + await gotoProfile(secondaryPage, primary.handle); 350 + return blockProfile(secondaryPage); 351 + }, { pageNames: ['secondary'] }); 352 + 353 + await step('secondary-unblock-primary', async () => { 354 + return unblockProfile(secondaryPage); 355 + }, { pageNames: ['secondary'] }); 356 + 357 + await step('primary-settings-likes-people-i-follow', async () => { 358 + return setRadioSetting(primaryPage, '/settings/notifications/likes', 'People I follow'); 359 + }, { pageNames: ['primary'] }); 360 + 361 + await step('primary-settings-likes-everyone', async () => { 362 + return setRadioSetting(primaryPage, '/settings/notifications/likes', 'Everyone'); 363 + }, { pageNames: ['primary'] }); 364 + 365 + await step('primary-settings-threads-oldest', async () => { 366 + return setRadioSetting(primaryPage, '/settings/threads', 'Oldest replies first'); 367 + }, { pageNames: ['primary'] }); 368 + 369 + await step('primary-settings-threads-tree-view-on', async () => { 370 + return setCheckboxSetting(primaryPage, '/settings/threads', 'Tree view', true); 371 + }, { pageNames: ['primary'] }); 372 + 373 + await step('primary-settings-threads-tree-view-off', async () => { 374 + return setCheckboxSetting(primaryPage, '/settings/threads', 'Tree view', false); 375 + }, { pageNames: ['primary'] }); 376 + 377 + await step('primary-settings-threads-top-replies', async () => { 378 + return setRadioSetting(primaryPage, '/settings/threads', 'Top replies first'); 379 + }, { pageNames: ['primary'] }); 380 + 381 + await step('primary-settings-following-feed-hide-replies', async () => { 382 + return setCheckboxSetting(primaryPage, '/settings/following-feed', 'Show replies', false); 383 + }, { pageNames: ['primary'] }); 384 + 385 + await step('primary-settings-following-feed-show-replies', async () => { 386 + return setCheckboxSetting(primaryPage, '/settings/following-feed', 'Show replies', true); 387 + }, { pageNames: ['primary'] }); 388 + 389 + await step('primary-settings-autoplay-off', async () => { 390 + return setCheckboxSetting(primaryPage, '/settings/content-and-media', 'Autoplay videos and GIFs', false); 391 + }, { pageNames: ['primary'] }); 392 + 393 + await step('primary-settings-autoplay-on', async () => { 394 + return setCheckboxSetting(primaryPage, '/settings/content-and-media', 'Autoplay videos and GIFs', true); 395 + }, { pageNames: ['primary'] }); 396 + 397 + await step('primary-settings-accessibility-require-alt-on', async () => { 398 + return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Require alt text before posting', true); 399 + }, { pageNames: ['primary'] }); 400 + 401 + await step('primary-settings-accessibility-require-alt-off', async () => { 402 + return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Require alt text before posting', false); 403 + }, { pageNames: ['primary'] }); 404 + 405 + await step('primary-settings-accessibility-large-badges-on', async () => { 406 + return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Display larger alt text badges', true); 407 + }, { pageNames: ['primary'] }); 408 + 409 + await step('primary-settings-accessibility-large-badges-off', async () => { 410 + return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Display larger alt text badges', false); 411 + }, { pageNames: ['primary'] }); 412 + 413 + await step('primary-cleanup-unlike-secondary-post', async () => { 414 + await gotoProfile(primaryPage, secondary.handle); 415 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 416 + return ensureNotLiked(primaryPage, row); 417 + }, { optional: true, pageNames: ['primary'] }); 418 + 419 + await step('primary-cleanup-unbookmark-secondary-post', async () => { 420 + await gotoProfile(primaryPage, secondary.handle); 421 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 422 + return ensureNotBookmarked(primaryPage, row); 423 + }, { optional: true, pageNames: ['primary'] }); 424 + 425 + await step('primary-cleanup-undo-repost-secondary-post', async () => { 426 + await gotoProfile(primaryPage, secondary.handle); 427 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 428 + return ensureNotReposted(primaryPage, row); 429 + }, { optional: true, pageNames: ['primary'] }); 430 + 431 + await step('primary-cleanup-unfollow-secondary', async () => { 432 + await gotoProfile(primaryPage, secondary.handle); 433 + return maybeUnfollow(primaryPage); 434 + }, { optional: true, pageNames: ['primary'] }); 435 + 436 + await step('secondary-cleanup-unfollow-primary', async () => { 437 + await gotoProfile(secondaryPage, primary.handle); 438 + return maybeUnfollow(secondaryPage); 439 + }, { optional: true, pageNames: ['secondary'] }); 440 + 441 + await step('primary-cleanup-delete-quote', async () => { 442 + await gotoProfile(primaryPage, primary.handle); 443 + await openProfileTab(primaryPage, 'Posts'); 444 + return maybeDeleteOwnPostByText(primaryPage, primary.quoteText, 'deleted quote post'); 445 + }, { pageNames: ['primary'] }); 446 + 447 + await step('primary-cleanup-delete-image-post', async () => { 448 + await gotoProfile(primaryPage, primary.handle); 449 + await openProfileTab(primaryPage, 'Posts'); 450 + return maybeDeleteOwnPostByText(primaryPage, primary.mediaPostText, 'deleted image post'); 451 + }, { pageNames: ['primary'] }); 452 + 453 + await step('primary-cleanup-delete-reply', async () => { 454 + await gotoProfile(primaryPage, primary.handle); 455 + await openProfileTab(primaryPage, 'Replies'); 456 + return maybeDeleteOwnPostByText(primaryPage, primary.replyText, 'deleted reply post'); 457 + }, { optional: true, pageNames: ['primary'] }); 458 + 459 + await step('secondary-cleanup-delete-root-post', async () => { 460 + await gotoProfile(secondaryPage, secondary.handle); 461 + await openProfileTab(secondaryPage, 'Posts'); 462 + return maybeDeleteOwnPostByText(secondaryPage, secondary.postText, 'deleted root post'); 463 + }, { pageNames: ['secondary'] }); 464 + 465 + await step('primary-cleanup-delete-root-post', async () => { 466 + await gotoProfile(primaryPage, primary.handle); 467 + await openProfileTab(primaryPage, 'Posts'); 468 + return maybeDeleteOwnPostByText(primaryPage, primary.postText, 'deleted root post'); 469 + }, { optional: true, pageNames: ['primary'] }); 470 + };
+9
pds-smoke-suite/src/browser/lib/playwright-runtime.mjs
··· 1 + let playwright; 2 + 3 + try { 4 + playwright = await import('playwright'); 5 + } catch { 6 + playwright = await import('../../../../tools/browser-automation/node_modules/playwright/index.mjs'); 7 + } 8 + 9 + export const { chromium } = playwright;
+517
pds-smoke-suite/src/browser/lib/single-actions.mjs
··· 1 + import fs from 'node:fs/promises'; 2 + import path from 'node:path'; 3 + 4 + export const createSingleActions = ({ 5 + config, 6 + summary, 7 + page, 8 + appBaseUrl, 9 + wait, 10 + sleep, 11 + normalizeText, 12 + buttonText, 13 + fetchStatus, 14 + pollJson, 15 + avatarPngBase64, 16 + }) => { 17 + const ensureAvatarFixture = async () => { 18 + const file = path.join(config.artifactsDir, 'avatar-fixture.png'); 19 + await fs.writeFile(file, Buffer.from(avatarPngBase64, 'base64')); 20 + return file; 21 + }; 22 + 23 + const login = async () => { 24 + await page.goto(config.appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); 25 + await page.getByRole('button', { name: 'Sign in' }).nth(0).click({ noWaitAfter: true }); 26 + await wait(1000); 27 + await page.getByRole('button', { name: 'Bluesky Social' }).evaluate((el) => el.click()); 28 + await wait(500); 29 + await page.getByText('Custom').evaluate((el) => el.click()); 30 + await wait(500); 31 + await page.getByPlaceholder('my-server.com').fill(config.pdsHost); 32 + await page.getByRole('button', { name: 'Done' }).evaluate((el) => el.click()); 33 + await wait(500); 34 + const close = page.getByRole('button', { name: 'Close welcome modal' }); 35 + if (await close.count()) { 36 + await close.evaluate((el) => el.click()); 37 + await wait(300); 38 + } 39 + await page.getByPlaceholder('Username or email address').fill(config.handle); 40 + await page.getByPlaceholder('Password').fill(config.password); 41 + await page.getByTestId('loginNextButton').click({ noWaitAfter: true }); 42 + await wait(3000); 43 + }; 44 + 45 + const completeAgeAssuranceIfNeeded = async () => { 46 + const addBirthdate = page.getByRole('button', { name: /update your birthdate/i }); 47 + if (await addBirthdate.count()) { 48 + await addBirthdate.click({ noWaitAfter: true }); 49 + await wait(800); 50 + await page.getByTestId('birthdayInput').fill(config.birthdate); 51 + await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 52 + await wait(3000); 53 + summary.notes.push('Completed age-assurance birthdate gate'); 54 + } 55 + }; 56 + 57 + const gotoProfile = async (handle) => { 58 + await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 59 + waitUntil: 'domcontentloaded', 60 + timeout: 60000, 61 + }); 62 + await wait(3000); 63 + }; 64 + 65 + const maybeFollowTarget = async () => { 66 + const follow = page.getByTestId('followBtn').first(); 67 + if (!(await follow.count())) { 68 + const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 69 + if (!(await roleFollow.count())) { 70 + return { note: 'follow button unavailable' }; 71 + } 72 + const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 73 + if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 74 + return { note: 'already following target' }; 75 + } 76 + await roleFollow.click({ noWaitAfter: true }); 77 + await wait(2000); 78 + return { note: 'follow attempted via role button' }; 79 + } 80 + const label = (await follow.getAttribute('aria-label')) ?? ''; 81 + if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 82 + return { note: 'already following target' }; 83 + } 84 + await follow.click({ noWaitAfter: true }); 85 + await wait(2000); 86 + return { note: 'follow attempted' }; 87 + }; 88 + 89 + const composePost = async (text) => { 90 + await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 91 + await wait(800); 92 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 93 + await editor.click({ noWaitAfter: true }); 94 + await editor.fill(text); 95 + await wait(300); 96 + await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 97 + await wait(4000); 98 + }; 99 + 100 + const waitForProfileHandle = async (handle, timeout = 20000) => { 101 + const shortHandle = handle.replace(/^@/, ''); 102 + const handleText = shortHandle.startsWith('@') ? shortHandle : `@${shortHandle}`; 103 + await page.getByText(handleText).first().waitFor({ state: 'visible', timeout }); 104 + }; 105 + 106 + const findRowByPrimaryText = async (needle, timeout = 60000) => { 107 + const started = Date.now(); 108 + while (Date.now() - started < timeout) { 109 + const rows = page.locator('[data-testid^="feedItem-by-"]'); 110 + const count = await rows.count(); 111 + for (let i = 0; i < count; i += 1) { 112 + const row = rows.nth(i); 113 + const primary = row.locator('[data-testid="postText"]').first(); 114 + if (!(await primary.count())) { 115 + continue; 116 + } 117 + const text = normalizeText(await primary.textContent()); 118 + if (text === needle) { 119 + await row.waitFor({ state: 'visible', timeout: 10000 }); 120 + return row; 121 + } 122 + } 123 + await wait(1000); 124 + } 125 + throw new Error(`feed item with primary text not found: ${needle}`); 126 + }; 127 + 128 + const maybeFindRowByPrimaryText = async (needle, timeout = 5000) => { 129 + try { 130 + return await findRowByPrimaryText(needle, timeout); 131 + } catch { 132 + return null; 133 + } 134 + }; 135 + 136 + const findFirstFeedItem = async (timeout = 60000) => { 137 + const row = page.locator('[data-testid^="feedItem-by-"]').first(); 138 + await row.waitFor({ state: 'visible', timeout }); 139 + return row; 140 + }; 141 + 142 + const clickLike = async (row) => { 143 + const btn = row.getByTestId('likeBtn').first(); 144 + await btn.click({ noWaitAfter: true }); 145 + await wait(1500); 146 + }; 147 + 148 + const clickRepost = async (row) => { 149 + const btn = row.getByTestId('repostBtn').first(); 150 + await btn.click({ noWaitAfter: true }); 151 + await wait(500); 152 + const repost = page.getByText(/^Repost$/).last(); 153 + if (await repost.count()) { 154 + await repost.click({ noWaitAfter: true }); 155 + await wait(1500); 156 + } 157 + }; 158 + 159 + const clickQuote = async (row, text) => { 160 + const btn = row.getByTestId('repostBtn').first(); 161 + await btn.click({ noWaitAfter: true }); 162 + await wait(500); 163 + const quote = page.getByText(/^Quote post$/).last(); 164 + if (!(await quote.count())) { 165 + throw new Error('quote option not available'); 166 + } 167 + await quote.click({ noWaitAfter: true }); 168 + await wait(1000); 169 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 170 + await editor.click({ noWaitAfter: true }); 171 + await editor.fill(text); 172 + await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 173 + await wait(4000); 174 + }; 175 + 176 + const clickReply = async (row, text) => { 177 + const btn = row.getByTestId('replyBtn').first(); 178 + await btn.click({ noWaitAfter: true }); 179 + await wait(1000); 180 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 181 + await editor.click({ noWaitAfter: true }); 182 + await editor.fill(text); 183 + const publishReply = page.getByRole('button', { name: /publish reply|reply/i }).last(); 184 + await publishReply.click({ noWaitAfter: true }); 185 + await wait(4000); 186 + }; 187 + 188 + const ensureBookmarked = async (row) => { 189 + const btn = row.getByTestId('postBookmarkBtn').first(); 190 + const before = await buttonText(btn); 191 + if (/remove from saved posts/i.test(before)) { 192 + return { note: 'already bookmarked' }; 193 + } 194 + await btn.click({ noWaitAfter: true }); 195 + await wait(1500); 196 + return { note: await buttonText(btn) }; 197 + }; 198 + 199 + const ensureNotBookmarked = async (row) => { 200 + const btn = row.getByTestId('postBookmarkBtn').first(); 201 + const before = await buttonText(btn); 202 + if (!/remove from saved posts/i.test(before)) { 203 + return { note: 'already not bookmarked' }; 204 + } 205 + await btn.click({ noWaitAfter: true }); 206 + await wait(1500); 207 + return { note: await buttonText(btn) }; 208 + }; 209 + 210 + const ensureLiked = async (row) => { 211 + const btn = row.getByTestId('likeBtn').first(); 212 + const before = await buttonText(btn); 213 + if (/unlike/i.test(before)) { 214 + return { note: 'already liked' }; 215 + } 216 + await clickLike(row); 217 + return { note: await buttonText(btn) }; 218 + }; 219 + 220 + const ensureNotLiked = async (row) => { 221 + const btn = row.getByTestId('likeBtn').first(); 222 + const before = await buttonText(btn); 223 + if (!/unlike/i.test(before)) { 224 + return { note: 'already not liked' }; 225 + } 226 + await clickLike(row); 227 + return { note: await buttonText(btn) }; 228 + }; 229 + 230 + const ensureReposted = async (row) => { 231 + const btn = row.getByTestId('repostBtn').first(); 232 + const before = await buttonText(btn); 233 + if (/undo repost|remove repost/i.test(before)) { 234 + return { note: 'already reposted' }; 235 + } 236 + await clickRepost(row); 237 + return { note: await buttonText(btn) }; 238 + }; 239 + 240 + const ensureNotReposted = async (row) => { 241 + const btn = row.getByTestId('repostBtn').first(); 242 + const before = await buttonText(btn); 243 + if (!/undo repost|remove repost/i.test(before)) { 244 + return { note: 'already not reposted' }; 245 + } 246 + await btn.click({ noWaitAfter: true }); 247 + await wait(1500); 248 + return { note: await buttonText(btn) }; 249 + }; 250 + 251 + const openProfileTab = async (name) => { 252 + const tab = page.getByRole('tab', { name }).first(); 253 + await tab.waitFor({ state: 'visible', timeout: 15000 }); 254 + await tab.click({ noWaitAfter: true }); 255 + await wait(2000); 256 + }; 257 + 258 + const maybeUnfollowTarget = async () => { 259 + const btn = page.getByTestId('unfollowBtn').first(); 260 + if (!(await btn.count())) { 261 + return { note: 'already not following target' }; 262 + } 263 + await btn.click({ noWaitAfter: true }); 264 + await wait(2000); 265 + return { note: 'unfollow attempted' }; 266 + }; 267 + 268 + const openPostOptions = async (row) => { 269 + const btn = row.getByTestId('postDropdownBtn').first(); 270 + await btn.click({ noWaitAfter: true }); 271 + const menu = page.locator('[role="menu"]').last(); 272 + await menu.waitFor({ state: 'visible', timeout: 10000 }); 273 + return menu; 274 + }; 275 + 276 + const deletePostRow = async (row) => { 277 + await openPostOptions(row); 278 + const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 279 + await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 280 + await deleteItem.click({ noWaitAfter: true }); 281 + const dialog = page.locator('[role="dialog"]').last(); 282 + await dialog.waitFor({ state: 'visible', timeout: 10000 }); 283 + const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 284 + await confirm.click({ noWaitAfter: true }); 285 + await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 286 + await wait(3000); 287 + }; 288 + 289 + const maybeDeleteOwnPostByText = async (text, successNote) => { 290 + const row = await maybeFindRowByPrimaryText(text, 10000); 291 + if (!row) { 292 + return { note: `not surfaced for cleanup: ${text}` }; 293 + } 294 + await deletePostRow(row); 295 + return { note: successNote }; 296 + }; 297 + 298 + const openNotifications = async () => { 299 + await page.goto(`${appBaseUrl}/notifications`, { 300 + waitUntil: 'domcontentloaded', 301 + timeout: 60000, 302 + }); 303 + await wait(3000); 304 + const heading = page.getByText(/^Notifications$/).first(); 305 + if (await heading.count()) { 306 + await heading.waitFor({ state: 'visible', timeout: 15000 }); 307 + } 308 + }; 309 + 310 + const openSavedPosts = async () => { 311 + await page.goto(`${appBaseUrl}/saved`, { 312 + waitUntil: 'domcontentloaded', 313 + timeout: 60000, 314 + }); 315 + await wait(3000); 316 + }; 317 + 318 + const verifyPublicHandleResolution = async () => { 319 + const result = await pollJson( 320 + 'public handle resolution', 321 + () => `${config.publicApiUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 322 + ({ ok, json }) => ok && typeof json?.did === 'string' && json.did.length > 0, 323 + config.publicCheckTimeoutMs ?? 180000, 324 + ); 325 + return { did: result.json.did }; 326 + }; 327 + 328 + const verifyPublicAuthorFeed = async () => { 329 + const result = await pollJson( 330 + 'public author feed indexing', 331 + () => `${config.publicApiUrl}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(config.handle)}&limit=20`, 332 + ({ ok, json }) => 333 + ok && Array.isArray(json?.feed) && json.feed.some((item) => item?.post?.record?.text === config.postText), 334 + config.publicCheckTimeoutMs ?? 180000, 335 + ); 336 + const matching = result.json.feed.find((item) => item?.post?.record?.text === config.postText); 337 + return { 338 + uri: matching?.post?.uri, 339 + cid: matching?.post?.cid, 340 + }; 341 + }; 342 + 343 + const verifyPublicProfile = async () => { 344 + const result = await pollJson( 345 + 'public profile indexing', 346 + () => `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 347 + ({ ok, json }) => ok && typeof json?.postsCount === 'number' && json.postsCount > 0, 348 + config.publicCheckTimeoutMs ?? 180000, 349 + ); 350 + return { 351 + postsCount: result.json.postsCount, 352 + followersCount: result.json.followersCount, 353 + followsCount: result.json.followsCount, 354 + avatar: result.json.avatar, 355 + description: result.json.description, 356 + }; 357 + }; 358 + 359 + const verifyPublicProfileAfterEdit = async () => { 360 + const result = await pollJson( 361 + 'public profile edit indexing', 362 + () => `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 363 + ({ ok, json }) => 364 + ok && 365 + json?.description === config.profileNote && 366 + typeof json?.avatar === 'string' && 367 + json.avatar.length > 0, 368 + config.publicCheckTimeoutMs ?? 180000, 369 + ); 370 + const avatarResult = await fetchStatus(result.json.avatar); 371 + if (!avatarResult.ok) { 372 + throw new Error(`public avatar URL returned ${avatarResult.status}`); 373 + } 374 + return { 375 + avatar: result.json.avatar, 376 + avatarStatus: avatarResult.status, 377 + description: result.json.description, 378 + }; 379 + }; 380 + 381 + const verifyLocalProfileAfterEdit = async () => { 382 + const didResult = await pollJson( 383 + 'local handle resolution after profile edit', 384 + () => `${config.pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 385 + ({ ok, json }) => ok && typeof json?.did === 'string' && json.did.length > 0, 386 + 30000, 387 + ); 388 + const did = didResult.json.did; 389 + const result = await pollJson( 390 + 'local profile record after edit', 391 + () => 392 + `${config.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, 393 + ({ ok, json }) => 394 + ok && 395 + json?.value?.description === config.profileNote && 396 + typeof json?.value?.avatar?.ref?.$link === 'string' && 397 + json.value.avatar.ref.$link.length > 0, 398 + 30000, 399 + ); 400 + return { 401 + did, 402 + avatarCid: result.json.value.avatar.ref.$link, 403 + description: result.json.value.description, 404 + }; 405 + }; 406 + 407 + const dismissModalBackdropIfPresent = async () => { 408 + const backdrop = page.locator('[aria-label*="click to close"]').last(); 409 + if (await backdrop.count()) { 410 + await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 411 + await wait(400); 412 + } 413 + }; 414 + 415 + const uploadProfileAvatar = async () => { 416 + const avatarFile = await ensureAvatarFixture(); 417 + let fileInputs = page.locator('input[type="file"]'); 418 + let count = await fileInputs.count(); 419 + 420 + if (count === 0) { 421 + const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 422 + if (await changeAvatar.count()) { 423 + await changeAvatar.click({ noWaitAfter: true }); 424 + await wait(500); 425 + const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 426 + if (await uploadFromFiles.count()) { 427 + const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 428 + await uploadFromFiles.click({ noWaitAfter: true }); 429 + const chooser = await chooserPromise; 430 + await chooser.setFiles(avatarFile); 431 + await wait(750); 432 + const editImageHeading = page.getByText(/^Edit image$/).last(); 433 + if (await editImageHeading.count()) { 434 + await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 435 + const cropSave = page.getByRole('button', { name: 'Save' }).last(); 436 + await cropSave.click({ noWaitAfter: true }); 437 + await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 438 + summary.notes.push('profile avatar crop saved'); 439 + } 440 + summary.notes.push('profile avatar uploaded via file chooser'); 441 + await wait(1500); 442 + return avatarFile; 443 + } 444 + } 445 + } 446 + 447 + if (count === 0) { 448 + throw new Error('profile avatar file input unavailable'); 449 + } 450 + 451 + await fileInputs.first().setInputFiles(avatarFile); 452 + await wait(1500); 453 + summary.notes.push(`edit profile file inputs: ${count}`); 454 + return avatarFile; 455 + }; 456 + 457 + const editProfile = async () => { 458 + const edit = page.getByRole('button', { name: /edit profile/i }); 459 + if (!(await edit.count())) { 460 + throw new Error('edit profile button unavailable'); 461 + } 462 + await edit.click({ noWaitAfter: true }); 463 + await wait(1000); 464 + await dismissModalBackdropIfPresent(); 465 + const avatarFile = await uploadProfileAvatar(); 466 + const bioField = page.locator('textarea[aria-label="Description"]').first(); 467 + if (await bioField.count()) { 468 + await bioField.fill(config.profileNote); 469 + const actual = await bioField.inputValue(); 470 + if (actual !== config.profileNote) { 471 + throw new Error(`profile description fill did not stick: ${actual}`); 472 + } 473 + } 474 + const save = page.getByTestId('editProfileSaveBtn'); 475 + await save.waitFor({ state: 'visible', timeout: 15000 }); 476 + await page.waitForFunction(() => { 477 + const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 478 + return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 479 + }, undefined, { timeout: 15000 }); 480 + await save.click({ noWaitAfter: true }); 481 + await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 482 + timeout: 15000, 483 + }); 484 + await wait(3000); 485 + return { avatarFile, profileNote: config.profileNote }; 486 + }; 487 + 488 + return { 489 + login, 490 + completeAgeAssuranceIfNeeded, 491 + gotoProfile, 492 + maybeFollowTarget, 493 + composePost, 494 + waitForProfileHandle, 495 + findRowByPrimaryText, 496 + findFirstFeedItem, 497 + clickQuote, 498 + clickReply, 499 + ensureBookmarked, 500 + ensureNotBookmarked, 501 + ensureLiked, 502 + ensureNotLiked, 503 + ensureReposted, 504 + ensureNotReposted, 505 + openProfileTab, 506 + maybeUnfollowTarget, 507 + maybeDeleteOwnPostByText, 508 + openNotifications, 509 + openSavedPosts, 510 + verifyPublicHandleResolution, 511 + verifyPublicAuthorFeed, 512 + verifyPublicProfile, 513 + verifyPublicProfileAfterEdit, 514 + verifyLocalProfileAfterEdit, 515 + editProfile, 516 + }; 517 + };
+206
pds-smoke-suite/src/browser/lib/single-scenario.mjs
··· 1 + export const runSingleScenario = async ({ 2 + step, 3 + config, 4 + login, 5 + completeAgeAssuranceIfNeeded, 6 + composePost, 7 + verifyPublicHandleResolution, 8 + verifyPublicProfile, 9 + verifyPublicAuthorFeed, 10 + gotoProfile, 11 + page, 12 + findRowByPrimaryText, 13 + ensureLiked, 14 + ensureReposted, 15 + clickQuote, 16 + clickReply, 17 + ensureNotLiked, 18 + ensureNotReposted, 19 + maybeFollowTarget, 20 + findFirstFeedItem, 21 + ensureBookmarked, 22 + openSavedPosts, 23 + ensureNotBookmarked, 24 + maybeUnfollowTarget, 25 + openNotifications, 26 + editProfile, 27 + verifyLocalProfileAfterEdit, 28 + verifyPublicProfileAfterEdit, 29 + openProfileTab, 30 + maybeDeleteOwnPostByText, 31 + }) => { 32 + await step('login', login); 33 + await step('age-assurance', completeAgeAssuranceIfNeeded, { optional: true }); 34 + await step('compose-own-post', () => composePost(config.postText)); 35 + if (config.publicChecks !== false) { 36 + await step('public-resolve-handle', verifyPublicHandleResolution); 37 + await step('public-profile', verifyPublicProfile); 38 + await step('public-author-feed', verifyPublicAuthorFeed); 39 + } 40 + await step('own-profile', () => gotoProfile(config.handle)); 41 + 42 + const ownPost = await step('find-own-post', async () => { 43 + await gotoProfile(config.handle); 44 + await page.getByTestId('postsFeed').first().waitFor({ state: 'visible', timeout: 60000 }); 45 + const row = await findRowByPrimaryText(config.postText, 60000); 46 + const rowTestId = await row.getAttribute('data-testid'); 47 + return { note: 'found own post', rowFound: true, rowTestId }; 48 + }); 49 + 50 + if (ownPost) { 51 + const row = await findRowByPrimaryText(config.postText); 52 + await step('like-own-post', () => ensureLiked(row), { optional: true }); 53 + await step('repost-own-post', () => ensureReposted(row), { optional: true }); 54 + await step('quote-own-post', () => clickQuote(row, config.quoteText), { optional: true }); 55 + await step('reply-own-post', async () => { 56 + await gotoProfile(config.handle); 57 + const refreshed = await findRowByPrimaryText(config.postText, 60000); 58 + await clickReply(refreshed, config.replyText); 59 + }, { optional: true }); 60 + await step('unlike-own-post', async () => { 61 + await gotoProfile(config.handle); 62 + const refreshed = await findRowByPrimaryText(config.postText, 60000); 63 + return ensureNotLiked(refreshed); 64 + }, { optional: true }); 65 + await step('undo-repost-own-post', async () => { 66 + await gotoProfile(config.handle); 67 + const refreshed = await findRowByPrimaryText(config.postText, 60000); 68 + return ensureNotReposted(refreshed); 69 + }, { optional: true }); 70 + } 71 + 72 + await step('target-profile', async () => { 73 + await gotoProfile(config.targetHandle); 74 + }); 75 + await step('follow-target', maybeFollowTarget, { optional: true }); 76 + 77 + await step('inspect-target-post', async () => { 78 + const row = await findFirstFeedItem(20000); 79 + const preview = ((await row.textContent()) || '').replace(/\s+/g, ' ').slice(0, 160); 80 + return { note: preview }; 81 + }, { optional: true }); 82 + 83 + await step('bookmark-target-post', async () => { 84 + const row = await findFirstFeedItem(20000); 85 + return ensureBookmarked(row); 86 + }, { optional: true }); 87 + 88 + await step('saved-posts-page', async () => { 89 + await openSavedPosts(); 90 + const handleText = page.getByText(`@${config.targetHandle.replace(/^@/, '')}`).first(); 91 + await handleText.waitFor({ state: 'visible', timeout: 20000 }); 92 + return { note: `saved post by ${config.targetHandle}` }; 93 + }, { optional: true }); 94 + 95 + await step('like-target-post', async () => { 96 + await gotoProfile(config.targetHandle); 97 + const row = await findFirstFeedItem(20000); 98 + return ensureLiked(row); 99 + }, { optional: true }); 100 + 101 + await step('repost-target-post', async () => { 102 + await gotoProfile(config.targetHandle); 103 + const row = await findFirstFeedItem(20000); 104 + return ensureReposted(row); 105 + }, { optional: true }); 106 + 107 + await step('quote-target-post', async () => { 108 + await gotoProfile(config.targetHandle); 109 + const row = await findFirstFeedItem(20000); 110 + await clickQuote(row, `${config.quoteText} to @${config.targetHandle.replace(/^@/, '')}`); 111 + return { note: 'quoted target post' }; 112 + }, { optional: true }); 113 + 114 + await step('reply-target-post', async () => { 115 + await gotoProfile(config.targetHandle); 116 + const row = await findFirstFeedItem(20000); 117 + await clickReply(row, `${config.replyText} to @${config.targetHandle.replace(/^@/, '')}`); 118 + return { note: 'replied to target post' }; 119 + }, { optional: true }); 120 + 121 + await step('unlike-target-post', async () => { 122 + await gotoProfile(config.targetHandle); 123 + const row = await findFirstFeedItem(20000); 124 + return ensureNotLiked(row); 125 + }, { optional: true }); 126 + 127 + await step('undo-repost-target-post', async () => { 128 + await gotoProfile(config.targetHandle); 129 + const row = await findFirstFeedItem(20000); 130 + return ensureNotReposted(row); 131 + }, { optional: true }); 132 + 133 + await step('unbookmark-target-post', async () => { 134 + await gotoProfile(config.targetHandle); 135 + const row = await findFirstFeedItem(20000); 136 + return ensureNotBookmarked(row); 137 + }, { optional: true }); 138 + 139 + await step('unfollow-target', async () => { 140 + await gotoProfile(config.targetHandle); 141 + return maybeUnfollowTarget(); 142 + }, { optional: true }); 143 + 144 + await step('refollow-target', async () => { 145 + await gotoProfile(config.targetHandle); 146 + return maybeFollowTarget(); 147 + }, { optional: true }); 148 + 149 + await step('notifications-page', async () => { 150 + await openNotifications(); 151 + const tab = page.getByRole('tab', { name: /all|priority/i }).first(); 152 + if (await tab.count()) { 153 + await tab.waitFor({ state: 'visible', timeout: 15000 }); 154 + } 155 + return { note: 'notifications page loaded' }; 156 + }, { optional: true }); 157 + 158 + if (config.editProfile) { 159 + await step('edit-profile', async () => { 160 + await gotoProfile(config.handle); 161 + await editProfile(); 162 + }); 163 + await step('local-profile-after-edit', verifyLocalProfileAfterEdit); 164 + if (config.publicChecks !== false) { 165 + await step('public-profile-after-edit', verifyPublicProfileAfterEdit); 166 + } 167 + } 168 + 169 + await step('cleanup-own-posts-tab', async () => { 170 + await gotoProfile(config.handle); 171 + await openProfileTab('Posts'); 172 + return { note: 'opened own posts tab for cleanup' }; 173 + }, { optional: true }); 174 + 175 + await step('delete-own-target-quote', async () => { 176 + return maybeDeleteOwnPostByText( 177 + `${config.quoteText} to @${config.targetHandle.replace(/^@/, '')}`, 178 + 'deleted target quote post', 179 + ); 180 + }); 181 + 182 + await step('delete-own-quote-post', async () => { 183 + return maybeDeleteOwnPostByText(config.quoteText, 'deleted own quote post'); 184 + }); 185 + 186 + await step('delete-own-root-post', async () => { 187 + return maybeDeleteOwnPostByText(config.postText, 'deleted root smoke post'); 188 + }); 189 + 190 + await step('cleanup-own-replies-tab', async () => { 191 + await gotoProfile(config.handle); 192 + await openProfileTab('Replies'); 193 + return { note: 'opened own replies tab for cleanup' }; 194 + }, { optional: true }); 195 + 196 + await step('delete-own-target-reply', async () => { 197 + return maybeDeleteOwnPostByText( 198 + `${config.replyText} to @${config.targetHandle.replace(/^@/, '')}`, 199 + 'deleted target reply post', 200 + ); 201 + }); 202 + 203 + await step('delete-own-reply-post', async () => { 204 + return maybeDeleteOwnPostByText(config.replyText, 'deleted own reply post'); 205 + }); 206 + };
+237
pds-smoke-suite/src/browser/run-dual.mjs
··· 1 + import fs from 'node:fs/promises'; 2 + import path from 'node:path'; 3 + import { fileURLToPath } from 'node:url'; 4 + import { setupDualBrowser, createDualStepHelpers } from './lib/dual-browser.mjs'; 5 + import { createDualApiHelpers } from './lib/dual-api.mjs'; 6 + import { createListHelpers } from './lib/lists.mjs'; 7 + import { createSettingsHelpers } from './lib/settings.mjs'; 8 + import { runDualScenario } from './lib/dual-scenario.mjs'; 9 + import { createDualActions } from './lib/dual-actions.mjs'; 10 + 11 + export const runDualFromConfig = async (config) => { 12 + await fs.mkdir(config.artifactsDir, { recursive: true }); 13 + const appBaseUrl = config.appUrl.replace(/\/$/, ''); 14 + 15 + const summary = { 16 + startedAt: new Date().toISOString(), 17 + appUrl: config.appUrl, 18 + pdsUrl: config.pdsUrl, 19 + publicApiUrl: config.publicApiUrl, 20 + targetHandle: config.targetHandle, 21 + primaryHandle: config.primary?.handle, 22 + secondaryHandle: config.secondary?.handle, 23 + steps: [], 24 + console: [], 25 + pageErrors: [], 26 + requestFailures: [], 27 + httpFailures: [], 28 + xrpc: [], 29 + notes: [], 30 + }; 31 + 32 + if (config.accountSource) { 33 + summary.notes.push(`account source: ${config.accountSource}`); 34 + } 35 + 36 + const AVATAR_PNG_BASE64 = 37 + 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAV0lEQVR4nO3PQQ0AIBDAMMC/58MCP7KkVbDX1pk5A6gWUC2gWkC1gGoB1QKqBVQLqBZQLaBaQLWAagHVAqoFVAuoFlAtoFpAtYBqAdUCqgVUC6gWUC2gWkD1B4a2AX/y3CvgAAAAAElFTkSuQmCC'; 38 + const { browser, primaryPage, secondaryPage } = await setupDualBrowser({ config, summary }); 39 + const { 40 + screenshot, 41 + normalizeText, 42 + isIgnoredConsole, 43 + isIgnoredRequestFailure, 44 + isIgnoredHttpFailure, 45 + step, 46 + wait, 47 + buttonText, 48 + } = createDualStepHelpers({ config, summary, primaryPage, secondaryPage }); 49 + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 50 + const { 51 + fetchJson, 52 + fetchStatus, 53 + xrpcJson, 54 + waitForOwnPostRecord, 55 + waitForFollowRecord, 56 + waitForNoOwnRecord, 57 + waitForOwnListRecord, 58 + waitForOwnListItemRecord, 59 + recordRkey, 60 + createSession, 61 + pollNotifications, 62 + prepareAccounts, 63 + cleanupStaleSmokeArtifacts, 64 + } = createDualApiHelpers({ config }); 65 + const { 66 + openListPage, 67 + createList, 68 + editCurrentList, 69 + deleteCurrentList, 70 + addUserToCurrentList, 71 + removeUserFromCurrentList, 72 + } = createListHelpers({ appBaseUrl, wait }); 73 + const { 74 + setCheckboxSetting, 75 + setRadioSetting, 76 + } = createSettingsHelpers({ appBaseUrl, wait }); 77 + 78 + const { primary, secondary } = prepareAccounts({ 79 + primaryConfig: config.primary, 80 + secondaryConfig: config.secondary, 81 + startedAt: summary.startedAt, 82 + }); 83 + 84 + const { 85 + login, 86 + completeAgeAssuranceIfNeeded, 87 + gotoProfile, 88 + waitForProfileHandle, 89 + composePost, 90 + composePostWithImage, 91 + editProfile, 92 + verifyLocalProfileAfterEdit, 93 + verifyPublicProfileAfterEdit, 94 + findRowByPrimaryText, 95 + ensureLiked, 96 + ensureNotLiked, 97 + ensureReposted, 98 + ensureNotReposted, 99 + ensureBookmarked, 100 + ensureNotBookmarked, 101 + clickQuote, 102 + clickReply, 103 + maybeFollow, 104 + maybeUnfollow, 105 + openNotifications, 106 + openSavedPosts, 107 + waitForNotificationsFeed, 108 + ensureProfileMuted, 109 + ensureProfileUnmuted, 110 + blockProfile, 111 + unblockProfile, 112 + openReportPostDraft, 113 + openProfileTab, 114 + maybeDeleteOwnPostByText, 115 + } = createDualActions({ 116 + config, 117 + summary, 118 + appBaseUrl, 119 + wait, 120 + sleep, 121 + normalizeText, 122 + buttonText, 123 + fetchJson, 124 + fetchStatus, 125 + xrpcJson, 126 + avatarPngBase64: AVATAR_PNG_BASE64, 127 + }); 128 + 129 + try { 130 + await runDualScenario({ 131 + step, 132 + primaryPage, 133 + secondaryPage, 134 + primary, 135 + secondary, 136 + login, 137 + completeAgeAssuranceIfNeeded, 138 + createSession, 139 + cleanupStaleSmokeArtifacts, 140 + composePost, 141 + waitForOwnPostRecord, 142 + gotoProfile, 143 + waitForProfileHandle, 144 + findRowByPrimaryText, 145 + composePostWithImage, 146 + editProfile, 147 + verifyLocalProfileAfterEdit, 148 + verifyPublicProfileAfterEdit, 149 + createList, 150 + waitForOwnListRecord, 151 + recordRkey, 152 + openListPage, 153 + editCurrentList, 154 + addUserToCurrentList, 155 + waitForOwnListItemRecord, 156 + removeUserFromCurrentList, 157 + waitForNoOwnRecord, 158 + deleteCurrentList, 159 + maybeUnfollow, 160 + maybeFollow, 161 + waitForFollowRecord, 162 + ensureLiked, 163 + ensureBookmarked, 164 + openSavedPosts, 165 + ensureReposted, 166 + clickQuote, 167 + clickReply, 168 + pollNotifications, 169 + openNotifications, 170 + waitForNotificationsFeed, 171 + ensureProfileMuted, 172 + ensureProfileUnmuted, 173 + openReportPostDraft, 174 + blockProfile, 175 + unblockProfile, 176 + setRadioSetting, 177 + setCheckboxSetting, 178 + ensureNotLiked, 179 + ensureNotBookmarked, 180 + ensureNotReposted, 181 + openProfileTab, 182 + maybeDeleteOwnPostByText, 183 + }); 184 + } catch (error) { 185 + summary.fatal = String(error?.message ?? error); 186 + } 187 + 188 + summary.finishedAt = new Date().toISOString(); 189 + summary.unexpected = { 190 + console: summary.console.filter((entry) => !isIgnoredConsole(entry)), 191 + requestFailures: summary.requestFailures.filter((entry) => !isIgnoredRequestFailure(entry)), 192 + httpFailures: summary.httpFailures.filter((entry) => !isIgnoredHttpFailure(entry)), 193 + pageErrors: summary.pageErrors, 194 + }; 195 + summary.unexpected.total = 196 + summary.unexpected.console.length + 197 + summary.unexpected.requestFailures.length + 198 + summary.unexpected.httpFailures.length + 199 + summary.unexpected.pageErrors.length; 200 + if (!summary.fatal && config.strictErrors !== false && summary.unexpected.total > 0) { 201 + summary.fatal = `Unexpected browser/runtime errors: ${summary.unexpected.total}`; 202 + } 203 + summary.ok = !summary.fatal; 204 + await screenshot('primary', 'final').catch(() => undefined); 205 + await screenshot('secondary', 'final').catch(() => undefined); 206 + await fs.writeFile( 207 + path.join(config.artifactsDir, 'summary.json'), 208 + JSON.stringify(summary, null, 2) + '\n', 209 + 'utf8', 210 + ); 211 + console.log(JSON.stringify(summary, null, 2)); 212 + await browser.close(); 213 + return summary; 214 + }; 215 + 216 + export const runDualFromConfigPath = async (configPath) => { 217 + const config = JSON.parse(await fs.readFile(configPath, 'utf8')); 218 + return runDualFromConfig(config); 219 + }; 220 + 221 + export const runDualFromArgv = async (argv = process.argv) => { 222 + const configPath = argv[2]; 223 + if (!configPath) { 224 + console.error('usage: node run-dual.mjs <config.json>'); 225 + return 2; 226 + } 227 + const summary = await runDualFromConfigPath(configPath); 228 + return summary.ok ? 0 : 1; 229 + }; 230 + 231 + const isDirectExecution = 232 + !!process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); 233 + 234 + if (isDirectExecution) { 235 + const exitCode = await runDualFromArgv(process.argv); 236 + process.exitCode = exitCode; 237 + }
+362
pds-smoke-suite/src/browser/run-single.mjs
··· 1 + import fs from 'node:fs/promises'; 2 + import path from 'node:path'; 3 + import { fileURLToPath } from 'node:url'; 4 + import { chromium } from './lib/playwright-runtime.mjs'; 5 + import { runSingleScenario } from './lib/single-scenario.mjs'; 6 + import { createSingleActions } from './lib/single-actions.mjs'; 7 + 8 + export const runSingleFromConfig = async (config) => { 9 + await fs.mkdir(config.artifactsDir, { recursive: true }); 10 + const appBaseUrl = config.appUrl.replace(/\/$/, ''); 11 + 12 + const summary = { 13 + startedAt: new Date().toISOString(), 14 + appUrl: config.appUrl, 15 + pdsUrl: config.pdsUrl, 16 + publicApiUrl: config.publicApiUrl, 17 + handle: config.handle, 18 + targetHandle: config.targetHandle, 19 + steps: [], 20 + console: [], 21 + pageErrors: [], 22 + requestFailures: [], 23 + httpFailures: [], 24 + xrpc: [], 25 + notes: [], 26 + }; 27 + 28 + const ignoredConsole = [ 29 + /events\.bsky\.app\/.*ERR_BLOCKED_BY_CLIENT/i, 30 + /slider-vertical/i, 31 + /Password field is not contained in a form/i, 32 + ]; 33 + 34 + const ignoredRequestFailure = [ 35 + { url: /events\.bsky\.app\//i, error: /ERR_(BLOCKED_BY_CLIENT|ABORTED)/i }, 36 + { url: /workers\.dev\/api\/config/i, error: /ERR_ABORTED/i }, 37 + { url: /app-config\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 38 + { url: /live-events\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 39 + { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 40 + { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 41 + { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts)/i, error: /ERR_ABORTED/i }, 42 + { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 43 + ]; 44 + 45 + const ignoredHttpFailure = [ 46 + { url: /c\.1password\.com\/richicons/i, status: 404 }, 47 + ]; 48 + 49 + const AVATAR_PNG_BASE64 = 50 + 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAV0lEQVR4nO3PQQ0AIBDAMMC/58MCP7KkVbDX1pk5A6gWUC2gWkC1gGoB1QKqBVQLqBZQLaBaQLWAagHVAqoFVAuoFlAtoFpAtYBqAdUCqgVUC6gWUC2gWkD1B4a2AX/y3CvgAAAAAElFTkSuQmCC'; 51 + 52 + const browserCandidates = async () => { 53 + const base = { 54 + headless: config.headless !== false, 55 + chromiumSandbox: true, 56 + }; 57 + const candidates = []; 58 + if (config.browserExecutablePath) { 59 + candidates.push({ 60 + label: `executable:${config.browserExecutablePath}`, 61 + options: { ...base, executablePath: config.browserExecutablePath }, 62 + }); 63 + } 64 + const systemChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 65 + if (!config.browserExecutablePath) { 66 + try { 67 + await fs.access(systemChrome); 68 + candidates.push({ 69 + label: 'system-google-chrome', 70 + options: { ...base, executablePath: systemChrome }, 71 + }); 72 + } catch { 73 + // Fall back to Playwright-managed Chromium below. 74 + } 75 + } 76 + candidates.push({ 77 + label: 'playwright-chromium', 78 + options: { ...base, channel: 'chromium' }, 79 + }); 80 + return candidates; 81 + }; 82 + 83 + const launchBrowser = async () => { 84 + const errors = []; 85 + for (const candidate of await browserCandidates()) { 86 + try { 87 + const browser = await chromium.launch(candidate.options); 88 + summary.notes.push(`browser launch candidate succeeded: ${candidate.label}`); 89 + return browser; 90 + } catch (error) { 91 + errors.push(`${candidate.label}: ${String(error?.message ?? error)}`); 92 + } 93 + } 94 + throw new Error(`unable to launch browser via any candidate: ${errors.join(' | ')}`); 95 + }; 96 + 97 + const browser = await launchBrowser(); 98 + const context = await browser.newContext({ 99 + viewport: { width: 1440, height: 1000 }, 100 + }); 101 + const page = await context.newPage(); 102 + 103 + if (config.browserExecutablePath) { 104 + summary.notes.push(`requested browser executable: ${config.browserExecutablePath}`); 105 + } 106 + 107 + page.on('console', (msg) => { 108 + summary.console.push({ 109 + type: msg.type(), 110 + text: msg.text(), 111 + }); 112 + }); 113 + 114 + page.on('pageerror', (error) => { 115 + summary.pageErrors.push({ 116 + message: String(error?.message ?? error), 117 + stack: error?.stack, 118 + }); 119 + }); 120 + 121 + page.on('requestfailed', (req) => { 122 + summary.requestFailures.push({ 123 + url: req.url(), 124 + method: req.method(), 125 + errorText: req.failure()?.errorText ?? 'unknown', 126 + }); 127 + }); 128 + 129 + page.on('response', (res) => { 130 + const status = res.status(); 131 + if (res.url().includes('/xrpc/')) { 132 + summary.xrpc.push({ 133 + url: res.url(), 134 + status, 135 + method: res.request().method(), 136 + }); 137 + if (summary.xrpc.length > 200) { 138 + summary.xrpc.shift(); 139 + } 140 + } 141 + if (status >= 400) { 142 + summary.httpFailures.push({ 143 + url: res.url(), 144 + status, 145 + method: res.request().method(), 146 + }); 147 + } 148 + }); 149 + 150 + const screenshot = async (name) => { 151 + const file = path.join(config.artifactsDir, `${name}.png`); 152 + await page.screenshot({ path: file, fullPage: true }); 153 + return file; 154 + }; 155 + 156 + const recordStep = (name, status, extra = {}) => { 157 + summary.steps.push({ 158 + name, 159 + status, 160 + at: new Date().toISOString(), 161 + ...extra, 162 + }); 163 + }; 164 + 165 + const normalizeText = (text) => (text || '').replace(/\s+/g, ' ').trim(); 166 + 167 + const isIgnoredConsole = (entry) => 168 + ignoredConsole.some((pattern) => pattern.test(entry.text || '')); 169 + 170 + const isIgnoredRequestFailure = (entry) => 171 + ignoredRequestFailure.some( 172 + (rule) => rule.url.test(entry.url || '') && rule.error.test(entry.errorText || ''), 173 + ); 174 + 175 + const isIgnoredHttpFailure = (entry) => 176 + ignoredHttpFailure.some( 177 + (rule) => rule.url.test(entry.url || '') && (!rule.status || rule.status === entry.status), 178 + ); 179 + 180 + const step = async (name, fn, { optional = false } = {}) => { 181 + try { 182 + const result = await fn(); 183 + const shot = await screenshot(name); 184 + recordStep(name, 'ok', { screenshot: shot, ...(result ?? {}) }); 185 + return result; 186 + } catch (error) { 187 + const shot = await screenshot(`${name}-error`).catch(() => undefined); 188 + recordStep(name, optional ? 'skipped' : 'failed', { 189 + screenshot: shot, 190 + error: String(error?.message ?? error), 191 + }); 192 + if (!optional) { 193 + throw error; 194 + } 195 + return null; 196 + } 197 + }; 198 + 199 + const wait = (ms) => page.waitForTimeout(ms); 200 + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 201 + 202 + const fetchJson = async (url) => { 203 + const res = await fetch(url, { 204 + headers: { accept: 'application/json' }, 205 + }); 206 + const text = await res.text(); 207 + let json; 208 + try { 209 + json = text ? JSON.parse(text) : null; 210 + } catch { 211 + json = null; 212 + } 213 + return { ok: res.ok, status: res.status, text, json }; 214 + }; 215 + 216 + const fetchStatus = async (url) => { 217 + const res = await fetch(url, { 218 + redirect: 'follow', 219 + }); 220 + return { ok: res.ok, status: res.status, url: res.url }; 221 + }; 222 + 223 + const pollJson = async (name, buildUrl, predicate, timeoutMs) => { 224 + const started = Date.now(); 225 + let last; 226 + while (Date.now() - started < timeoutMs) { 227 + last = await fetchJson(buildUrl()); 228 + if (predicate(last)) { 229 + return last; 230 + } 231 + await sleep(5000); 232 + } 233 + throw new Error(`${name} did not succeed before timeout; last status=${last?.status ?? 'none'}`); 234 + }; 235 + 236 + const { 237 + login, 238 + completeAgeAssuranceIfNeeded, 239 + gotoProfile, 240 + maybeFollowTarget, 241 + composePost, 242 + waitForProfileHandle, 243 + findRowByPrimaryText, 244 + findFirstFeedItem, 245 + clickQuote, 246 + clickReply, 247 + ensureBookmarked, 248 + ensureNotBookmarked, 249 + ensureLiked, 250 + ensureNotLiked, 251 + ensureReposted, 252 + ensureNotReposted, 253 + openProfileTab, 254 + maybeUnfollowTarget, 255 + maybeDeleteOwnPostByText, 256 + openNotifications, 257 + openSavedPosts, 258 + verifyPublicHandleResolution, 259 + verifyPublicAuthorFeed, 260 + verifyPublicProfile, 261 + verifyPublicProfileAfterEdit, 262 + verifyLocalProfileAfterEdit, 263 + editProfile, 264 + } = createSingleActions({ 265 + config, 266 + summary, 267 + page, 268 + appBaseUrl, 269 + wait, 270 + sleep, 271 + normalizeText, 272 + buttonText, 273 + fetchStatus, 274 + pollJson, 275 + avatarPngBase64: AVATAR_PNG_BASE64, 276 + }); 277 + 278 + try { 279 + await runSingleScenario({ 280 + step, 281 + config, 282 + login, 283 + completeAgeAssuranceIfNeeded, 284 + composePost, 285 + verifyPublicHandleResolution, 286 + verifyPublicProfile, 287 + verifyPublicAuthorFeed, 288 + gotoProfile, 289 + page, 290 + findRowByPrimaryText, 291 + ensureLiked, 292 + ensureReposted, 293 + clickQuote, 294 + clickReply, 295 + ensureNotLiked, 296 + ensureNotReposted, 297 + maybeFollowTarget, 298 + findFirstFeedItem, 299 + ensureBookmarked, 300 + openSavedPosts, 301 + ensureNotBookmarked, 302 + maybeUnfollowTarget, 303 + openNotifications, 304 + editProfile, 305 + verifyLocalProfileAfterEdit, 306 + verifyPublicProfileAfterEdit, 307 + openProfileTab, 308 + maybeDeleteOwnPostByText, 309 + }); 310 + } catch (error) { 311 + summary.fatal = String(error?.message ?? error); 312 + } 313 + 314 + summary.finishedAt = new Date().toISOString(); 315 + summary.unexpected = { 316 + console: summary.console.filter((entry) => !isIgnoredConsole(entry)), 317 + requestFailures: summary.requestFailures.filter((entry) => !isIgnoredRequestFailure(entry)), 318 + httpFailures: summary.httpFailures.filter((entry) => !isIgnoredHttpFailure(entry)), 319 + pageErrors: summary.pageErrors, 320 + }; 321 + summary.unexpected.total = 322 + summary.unexpected.console.length + 323 + summary.unexpected.requestFailures.length + 324 + summary.unexpected.httpFailures.length + 325 + summary.unexpected.pageErrors.length; 326 + if (!summary.fatal && config.strictErrors !== false && summary.unexpected.total > 0) { 327 + summary.fatal = `Unexpected browser/runtime errors: ${summary.unexpected.total}`; 328 + } 329 + summary.ok = !summary.fatal; 330 + await screenshot('final').catch(() => undefined); 331 + await fs.writeFile( 332 + path.join(config.artifactsDir, 'summary.json'), 333 + JSON.stringify(summary, null, 2) + '\n', 334 + 'utf8', 335 + ); 336 + console.log(JSON.stringify(summary, null, 2)); 337 + await browser.close(); 338 + return summary; 339 + }; 340 + 341 + export const runSingleFromConfigPath = async (configPath) => { 342 + const config = JSON.parse(await fs.readFile(configPath, 'utf8')); 343 + return runSingleFromConfig(config); 344 + }; 345 + 346 + export const runSingleFromArgv = async (argv = process.argv) => { 347 + const configPath = argv[2]; 348 + if (!configPath) { 349 + console.error('usage: node run-single.mjs <config.json>'); 350 + return 2; 351 + } 352 + const summary = await runSingleFromConfigPath(configPath); 353 + return summary.ok ? 0 : 1; 354 + }; 355 + 356 + const isDirectExecution = 357 + !!process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); 358 + 359 + if (isDirectExecution) { 360 + const exitCode = await runSingleFromArgv(process.argv); 361 + process.exitCode = exitCode; 362 + }
+160
pds-smoke-suite/src/cli.mjs
··· 1 + import fs from 'node:fs/promises'; 2 + import { 3 + createBringYourOwnDualConfig, 4 + createBringYourOwnSingleConfig, 5 + } from './adapters/bring-your-own.mjs'; 6 + import { 7 + createPerlskyDualConfig, 8 + createPerlskySingleConfig, 9 + } from './adapters/perlsky.mjs'; 10 + 11 + const usage = `Usage: 12 + pds-smoke-suite run-single [--adapter bring-your-own|perlsky] --config config.json 13 + pds-smoke-suite run-dual [--adapter bring-your-own|perlsky] --config config.json 14 + pds-smoke-suite validate --mode single|dual [--adapter bring-your-own|perlsky] --config config.json 15 + pds-smoke-suite print-example --mode single|dual [--adapter bring-your-own|perlsky] 16 + 17 + Notes: 18 + - bring-your-own is the default adapter 19 + - v1 is browser-first against bsky.app 20 + - direct API/AppView contract layers are documented as a later v2 expansion 21 + `; 22 + 23 + const parseArgs = (argv) => { 24 + const result = { 25 + command: argv[2], 26 + adapter: 'bring-your-own', 27 + }; 28 + 29 + for (let i = 3; i < argv.length; i += 1) { 30 + const arg = argv[i]; 31 + if (arg === '--config') { 32 + result.configPath = argv[++i]; 33 + continue; 34 + } 35 + if (arg === '--mode') { 36 + result.mode = argv[++i]; 37 + continue; 38 + } 39 + if (arg === '--adapter') { 40 + result.adapter = argv[++i]; 41 + continue; 42 + } 43 + if (arg === '--help' || arg === '-h' || arg === 'help') { 44 + result.help = true; 45 + continue; 46 + } 47 + throw new Error(`unknown argument: ${arg}`); 48 + } 49 + 50 + return result; 51 + }; 52 + 53 + const createExampleConfig = ({ mode, adapter }) => { 54 + const base = { 55 + pdsUrl: 'https://your-pds.example', 56 + artifactsDir: `data/browser-smoke/${adapter}-${mode}`, 57 + targetHandle: 'alice.mosphere.at', 58 + strictErrors: true, 59 + }; 60 + 61 + if (mode === 'single') { 62 + return { 63 + ...base, 64 + editProfile: true, 65 + account: { 66 + handle: 'smoke-primary.your-pds.example', 67 + password: 'replace-me', 68 + }, 69 + }; 70 + } 71 + 72 + return { 73 + ...base, 74 + primary: { 75 + handle: 'smoke-primary.your-pds.example', 76 + password: 'replace-me', 77 + }, 78 + secondary: { 79 + handle: 'smoke-secondary.your-pds.example', 80 + password: 'replace-me-too', 81 + }, 82 + }; 83 + }; 84 + 85 + const normalizeMode = (command, mode) => { 86 + if (command === 'run-single') { 87 + return 'single'; 88 + } 89 + if (command === 'run-dual') { 90 + return 'dual'; 91 + } 92 + return mode; 93 + }; 94 + 95 + const normalizeConfig = ({ mode, adapter, raw }) => { 96 + if (mode === 'single') { 97 + return adapter === 'perlsky' 98 + ? createPerlskySingleConfig(raw) 99 + : createBringYourOwnSingleConfig(raw); 100 + } 101 + if (mode === 'dual') { 102 + return adapter === 'perlsky' 103 + ? createPerlskyDualConfig(raw) 104 + : createBringYourOwnDualConfig(raw); 105 + } 106 + throw new Error(`unsupported mode: ${mode}`); 107 + }; 108 + 109 + const loadJsonConfig = async (configPath) => { 110 + const text = await fs.readFile(configPath, 'utf8'); 111 + return JSON.parse(text); 112 + }; 113 + 114 + export const runCliFromArgv = async (argv = process.argv) => { 115 + const args = parseArgs(argv); 116 + 117 + if (args.help || !args.command) { 118 + console.log(usage); 119 + return 0; 120 + } 121 + 122 + const mode = normalizeMode(args.command, args.mode); 123 + 124 + if (args.command === 'print-example') { 125 + if (!mode) { 126 + throw new Error('print-example requires --mode single|dual'); 127 + } 128 + console.log(JSON.stringify(createExampleConfig({ mode, adapter: args.adapter }), null, 2)); 129 + return 0; 130 + } 131 + 132 + if (!mode) { 133 + throw new Error('validate requires --mode single|dual'); 134 + } 135 + if (!args.configPath) { 136 + throw new Error('--config is required'); 137 + } 138 + 139 + const raw = await loadJsonConfig(args.configPath); 140 + const config = normalizeConfig({ mode, adapter: args.adapter, raw }); 141 + 142 + if (args.command === 'validate') { 143 + console.log(JSON.stringify(config, null, 2)); 144 + return 0; 145 + } 146 + 147 + if (args.command === 'run-single') { 148 + const { runSingleFromConfig } = await import('./browser/run-single.mjs'); 149 + const summary = await runSingleFromConfig(config); 150 + return summary.ok ? 0 : 1; 151 + } 152 + 153 + if (args.command === 'run-dual') { 154 + const { runDualFromConfig } = await import('./browser/run-dual.mjs'); 155 + const summary = await runDualFromConfig(config); 156 + return summary.ok ? 0 : 1; 157 + } 158 + 159 + throw new Error(`unsupported command: ${args.command}`); 160 + };
+15
pds-smoke-suite/src/config.mjs
··· 43 43 .filter(Boolean); 44 44 }; 45 45 46 + const derivePdsHost = (pdsUrl) => { 47 + try { 48 + return new URL(pdsUrl).host; 49 + } catch { 50 + const match = String(pdsUrl).match(/^https?:\/\/([^/]+)/); 51 + return match?.[1]; 52 + } 53 + }; 54 + 46 55 export const createAccountConfig = ({ 47 56 handle, 48 57 password, ··· 90 99 91 100 export const createSuiteConfig = ({ 92 101 pdsUrl, 102 + pdsHost, 93 103 artifactsDir, 94 104 appUrl = DEFAULTS.appUrl, 95 105 publicApiUrl = DEFAULTS.publicApiUrl, ··· 113 123 publicChecks: !!publicChecks, 114 124 ...rest, 115 125 }; 126 + 127 + normalized.pdsHost = optionalString(pdsHost) || derivePdsHost(normalized.pdsUrl); 128 + if (!normalized.pdsHost) { 129 + throw new Error('pdsHost could not be derived from pdsUrl'); 130 + } 116 131 117 132 const maybeTarget = optionalString(targetHandle); 118 133 if (maybeTarget) {
+3
pds-smoke-suite/src/index.mjs
··· 1 1 export * from './config.mjs'; 2 2 export * from './adapters/bring-your-own.mjs'; 3 3 export * from './adapters/perlsky.mjs'; 4 + export * from './browser/run-single.mjs'; 5 + export * from './browser/run-dual.mjs'; 6 + export * from './cli.mjs';
+3 -1210
tools/browser-automation/dual-smoke.mjs
··· 1 - import fs from 'node:fs/promises'; 2 - import path from 'node:path'; 3 - import { setupDualBrowser, createDualStepHelpers } from './lib/dual-browser.mjs'; 4 - import { createDualApiHelpers } from './lib/dual-api.mjs'; 5 - import { createListHelpers } from './lib/lists.mjs'; 6 - import { createSettingsHelpers } from './lib/settings.mjs'; 7 - 8 - const configPath = process.argv[2]; 9 - if (!configPath) { 10 - console.error('usage: node dual-smoke.mjs <config.json>'); 11 - process.exit(2); 12 - } 13 - 14 - const config = JSON.parse(await fs.readFile(configPath, 'utf8')); 15 - await fs.mkdir(config.artifactsDir, { recursive: true }); 16 - const appBaseUrl = config.appUrl.replace(/\/$/, ''); 17 - 18 - const summary = { 19 - startedAt: new Date().toISOString(), 20 - appUrl: config.appUrl, 21 - pdsUrl: config.pdsUrl, 22 - publicApiUrl: config.publicApiUrl, 23 - targetHandle: config.targetHandle, 24 - primaryHandle: config.primary?.handle, 25 - secondaryHandle: config.secondary?.handle, 26 - steps: [], 27 - console: [], 28 - pageErrors: [], 29 - requestFailures: [], 30 - httpFailures: [], 31 - xrpc: [], 32 - notes: [], 33 - }; 34 - 35 - if (config.accountSource) { 36 - summary.notes.push(`account source: ${config.accountSource}`); 37 - } 38 - 39 - const AVATAR_PNG_BASE64 = 40 - 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAV0lEQVR4nO3PQQ0AIBDAMMC/58MCP7KkVbDX1pk5A6gWUC2gWkC1gGoB1QKqBVQLqBZQLaBaQLWAagHVAqoFVAuoFlAtoFpAtYBqAdUCqgVUC6gWUC2gWkD1B4a2AX/y3CvgAAAAAElFTkSuQmCC'; 41 - const { browser, primaryPage, secondaryPage } = await setupDualBrowser({ config, summary }); 42 - const { 43 - screenshot, 44 - normalizeText, 45 - isIgnoredConsole, 46 - isIgnoredRequestFailure, 47 - isIgnoredHttpFailure, 48 - step, 49 - wait, 50 - buttonText, 51 - dismissBlockingOverlays, 52 - } = createDualStepHelpers({ config, summary, primaryPage, secondaryPage }); 53 - const { 54 - fetchJson, 55 - fetchStatus, 56 - xrpcJson, 57 - listOwnRecords, 58 - waitForOwnPostRecord, 59 - waitForFollowRecord, 60 - waitForNoOwnRecord, 61 - waitForOwnListRecord, 62 - waitForOwnListItemRecord, 63 - recordRkey, 64 - createSession, 65 - pollNotifications, 66 - prepareAccounts, 67 - cleanupStaleSmokeArtifacts, 68 - } = createDualApiHelpers({ config }); 69 - const { 70 - openListPage, 71 - createList, 72 - editCurrentList, 73 - deleteCurrentList, 74 - addUserToCurrentList, 75 - removeUserFromCurrentList, 76 - } = createListHelpers({ appBaseUrl, wait }); 77 - const { 78 - setCheckboxSetting, 79 - setRadioSetting, 80 - } = createSettingsHelpers({ appBaseUrl, wait }); 81 - 82 - const { primary, secondary } = prepareAccounts({ 83 - primaryConfig: config.primary, 84 - secondaryConfig: config.secondary, 85 - startedAt: summary.startedAt, 86 - }); 87 - 88 - const login = async (page, account) => { 89 - await page.goto(config.appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); 90 - await page.getByRole('button', { name: 'Sign in' }).nth(0).click({ noWaitAfter: true }); 91 - await wait(page, 1000); 92 - await page.getByRole('button', { name: 'Bluesky Social' }).evaluate((el) => el.click()); 93 - await wait(page, 500); 94 - await page.getByText('Custom').evaluate((el) => el.click()); 95 - await wait(page, 500); 96 - await page.getByPlaceholder('my-server.com').fill(config.pdsHost); 97 - await page.getByRole('button', { name: 'Done' }).evaluate((el) => el.click()); 98 - await wait(page, 500); 99 - const close = page.getByRole('button', { name: 'Close welcome modal' }); 100 - if (await close.count()) { 101 - await close.evaluate((el) => el.click()); 102 - await wait(page, 300); 103 - } 104 - await page.getByPlaceholder('Username or email address').fill(account.handle); 105 - await page.getByPlaceholder('Password').fill(account.password); 106 - await page.getByTestId('loginNextButton').click({ noWaitAfter: true }); 107 - await wait(page, 3000); 108 - }; 109 - 110 - const ensureAvatarFixture = async () => { 111 - const file = path.join(config.artifactsDir, 'avatar-fixture.png'); 112 - await fs.writeFile(file, Buffer.from(AVATAR_PNG_BASE64, 'base64')); 113 - return file; 114 - }; 115 - 116 - const completeAgeAssuranceIfNeeded = async (page, account) => { 117 - const addBirthdate = page.getByRole('button', { name: /update your birthdate/i }); 118 - if (await addBirthdate.count()) { 119 - await addBirthdate.click({ noWaitAfter: true }); 120 - await wait(page, 800); 121 - await page.getByTestId('birthdayInput').fill(account.birthdate); 122 - await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 123 - await wait(page, 3000); 124 - summary.notes.push(`Completed age-assurance birthdate gate for ${account.handle}`); 125 - } 126 - }; 127 - 128 - const gotoProfile = async (page, handle) => { 129 - await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 130 - waitUntil: 'domcontentloaded', 131 - timeout: 60000, 132 - }); 133 - await wait(page, 3000); 134 - }; 135 - 136 - const waitForProfileHandle = async (page, handle, timeout = 20000) => { 137 - const shortHandle = handle.replace(/^@/, ''); 138 - const handleText = shortHandle.startsWith('@') ? shortHandle : `@${shortHandle}`; 139 - await page.getByText(handleText).first().waitFor({ state: 'visible', timeout }); 140 - }; 141 - 142 - const composePost = async (page, text) => { 143 - await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 144 - await wait(page, 800); 145 - const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 146 - await editor.click({ noWaitAfter: true }); 147 - await editor.fill(text); 148 - await wait(page, 300); 149 - await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 150 - await wait(page, 4000); 151 - }; 152 - 153 - const uploadComposerMedia = async (page) => { 154 - const mediaFile = await ensureAvatarFixture(); 155 - const openMedia = page.getByTestId('openMediaBtn').last(); 156 - if (!(await openMedia.count())) { 157 - throw new Error('composer media button unavailable'); 158 - } 159 - const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 160 - await openMedia.click({ noWaitAfter: true }); 161 - const chooser = await chooserPromise; 162 - await chooser.setFiles(mediaFile); 163 - await wait(page, 2000); 164 - return mediaFile; 165 - }; 166 - 167 - const composePostWithImage = async (page, text) => { 168 - await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 169 - await wait(page, 800); 170 - const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 171 - await editor.click({ noWaitAfter: true }); 172 - await editor.fill(text); 173 - const mediaFile = await uploadComposerMedia(page); 174 - await wait(page, 500); 175 - await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 176 - await wait(page, 5000); 177 - return { mediaFile }; 178 - }; 179 - 180 - const dismissModalBackdropIfPresent = async (page) => { 181 - const backdrop = page.locator('[aria-label*="click to close"]').last(); 182 - if (await backdrop.count()) { 183 - await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 184 - await wait(page, 400); 185 - } 186 - }; 187 - 188 - const uploadProfileAvatar = async (page) => { 189 - const avatarFile = await ensureAvatarFixture(); 190 - let fileInputs = page.locator('input[type="file"]'); 191 - let count = await fileInputs.count(); 192 - 193 - if (count === 0) { 194 - const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 195 - if (await changeAvatar.count()) { 196 - await changeAvatar.click({ noWaitAfter: true }); 197 - await wait(page, 500); 198 - const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 199 - if (await uploadFromFiles.count()) { 200 - const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 201 - await uploadFromFiles.click({ noWaitAfter: true }); 202 - const chooser = await chooserPromise; 203 - await chooser.setFiles(avatarFile); 204 - await wait(page, 750); 205 - const editImageHeading = page.getByText(/^Edit image$/).last(); 206 - if (await editImageHeading.count()) { 207 - await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 208 - const cropSave = page.getByRole('button', { name: 'Save' }).last(); 209 - await cropSave.click({ noWaitAfter: true }); 210 - await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 211 - } 212 - await wait(page, 1500); 213 - return avatarFile; 214 - } 215 - } 216 - } 217 - 218 - if (count === 0) { 219 - throw new Error('profile avatar file input unavailable'); 220 - } 221 - 222 - await fileInputs.first().setInputFiles(avatarFile); 223 - await wait(page, 1500); 224 - return avatarFile; 225 - }; 226 - 227 - const editProfile = async (page, account) => { 228 - const edit = page.getByRole('button', { name: /edit profile/i }); 229 - if (!(await edit.count())) { 230 - throw new Error(`edit profile button unavailable for ${account.handle}`); 231 - } 232 - await edit.click({ noWaitAfter: true }); 233 - await wait(page, 1000); 234 - await dismissModalBackdropIfPresent(page); 235 - const avatarFile = await uploadProfileAvatar(page); 236 - const bioField = page.locator('textarea[aria-label="Description"]').first(); 237 - if (await bioField.count()) { 238 - await bioField.fill(account.profileNote); 239 - const actual = await bioField.inputValue(); 240 - if (actual !== account.profileNote) { 241 - throw new Error(`profile description fill did not stick for ${account.handle}: ${actual}`); 242 - } 243 - } 244 - const save = page.getByTestId('editProfileSaveBtn'); 245 - await save.waitFor({ state: 'visible', timeout: 15000 }); 246 - await page.waitForFunction(() => { 247 - const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 248 - return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 249 - }, undefined, { timeout: 15000 }); 250 - await save.click({ noWaitAfter: true }); 251 - await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 252 - timeout: 15000, 253 - }); 254 - await wait(page, 3000); 255 - return { avatarFile, profileNote: account.profileNote }; 256 - }; 257 - 258 - const verifyLocalProfileAfterEdit = async (account) => { 259 - const didResult = await xrpcJson('com.atproto.identity.resolveHandle', { 260 - params: { handle: account.handle }, 261 - }); 262 - if (!didResult.ok || didResult.json?.did !== account.did) { 263 - throw new Error(`handle did mismatch for ${account.handle}`); 264 - } 265 - const result = await xrpcJson('com.atproto.repo.getRecord', { 266 - params: { 267 - repo: account.did, 268 - collection: 'app.bsky.actor.profile', 269 - rkey: 'self', 270 - }, 271 - }); 272 - if (!result.ok) { 273 - throw new Error(`profile record lookup failed for ${account.handle}: ${result.status} ${result.text}`); 274 - } 275 - const avatarCid = result.json?.value?.avatar?.ref?.$link; 276 - const description = result.json?.value?.description; 277 - if (description !== account.profileNote || typeof avatarCid !== 'string' || !avatarCid.length) { 278 - throw new Error(`profile record did not contain expected avatar/description for ${account.handle}`); 279 - } 280 - return { avatarCid, description }; 281 - }; 1 + import { runDualFromArgv } from '../../pds-smoke-suite/src/browser/run-dual.mjs'; 282 2 283 - const verifyPublicProfileAfterEdit = async (account) => { 284 - const started = Date.now(); 285 - let result; 286 - while (Date.now() - started < (config.publicCheckTimeoutMs ?? 180000)) { 287 - result = await fetchJson( 288 - `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(account.handle)}`, 289 - ); 290 - if ( 291 - result.ok && 292 - result.json?.description === account.profileNote && 293 - typeof result.json?.avatar === 'string' && 294 - result.json.avatar.length > 0 295 - ) { 296 - break; 297 - } 298 - await sleep(5000); 299 - } 300 - if (!result?.ok) { 301 - throw new Error(`public profile lookup failed for ${account.handle}: ${result?.status} ${result?.text}`); 302 - } 303 - if (result.json?.description !== account.profileNote || typeof result.json?.avatar !== 'string') { 304 - throw new Error(`public profile missing updated description/avatar for ${account.handle}`); 305 - } 306 - const avatarResult = await fetchStatus(result.json.avatar); 307 - if (!avatarResult.ok) { 308 - throw new Error(`public avatar URL returned ${avatarResult.status} for ${account.handle}`); 309 - } 310 - return { 311 - avatar: result.json.avatar, 312 - avatarStatus: avatarResult.status, 313 - description: result.json.description, 314 - }; 315 - }; 316 - 317 - const findRowByPrimaryText = async (page, needle, timeout = 60000) => { 318 - const started = Date.now(); 319 - while (Date.now() - started < timeout) { 320 - const rows = page.locator('[data-testid^="feedItem-by-"]'); 321 - const count = await rows.count(); 322 - for (let i = 0; i < count; i += 1) { 323 - const row = rows.nth(i); 324 - const primaryText = row.locator('[data-testid="postText"]').first(); 325 - if (!(await primaryText.count())) { 326 - continue; 327 - } 328 - const text = normalizeText(await primaryText.textContent()); 329 - if (text === needle) { 330 - await row.waitFor({ state: 'visible', timeout: 10000 }); 331 - return row; 332 - } 333 - } 334 - await wait(page, 1000); 335 - } 336 - throw new Error(`feed item with primary text not found: ${needle}`); 337 - }; 338 - 339 - const maybeFindRowByPrimaryText = async (page, needle, timeout = 10000) => { 340 - try { 341 - return await findRowByPrimaryText(page, needle, timeout); 342 - } catch { 343 - return null; 344 - } 345 - }; 346 - 347 - const clickLike = async (page, row) => { 348 - const btn = row.getByTestId('likeBtn').first(); 349 - await btn.click({ noWaitAfter: true }); 350 - await wait(page, 1500); 351 - }; 352 - 353 - const ensureLiked = async (page, row) => { 354 - const btn = row.getByTestId('likeBtn').first(); 355 - const before = await buttonText(btn); 356 - if (/unlike/i.test(before)) { 357 - return { note: 'already liked' }; 358 - } 359 - await clickLike(page, row); 360 - return { note: await buttonText(btn) }; 361 - }; 362 - 363 - const ensureNotLiked = async (page, row) => { 364 - const btn = row.getByTestId('likeBtn').first(); 365 - const before = await buttonText(btn); 366 - if (!/unlike/i.test(before)) { 367 - return { note: 'already not liked' }; 368 - } 369 - await clickLike(page, row); 370 - return { note: await buttonText(btn) }; 371 - }; 372 - 373 - const clickRepost = async (page, row) => { 374 - await dismissBlockingOverlays(page); 375 - const btn = row.getByTestId('repostBtn').first(); 376 - await btn.click({ noWaitAfter: true }); 377 - await wait(page, 500); 378 - const repost = page.getByText(/^Repost$/).last(); 379 - if (await repost.count()) { 380 - await repost.click({ noWaitAfter: true }); 381 - await wait(page, 1500); 382 - await dismissBlockingOverlays(page); 383 - } 384 - }; 385 - 386 - const ensureReposted = async (page, row) => { 387 - const btn = row.getByTestId('repostBtn').first(); 388 - const before = await buttonText(btn); 389 - if (/undo repost|remove repost/i.test(before)) { 390 - return { note: 'already reposted' }; 391 - } 392 - await clickRepost(page, row); 393 - return { note: await buttonText(btn) }; 394 - }; 395 - 396 - const ensureNotReposted = async (page, row) => { 397 - const btn = row.getByTestId('repostBtn').first(); 398 - const before = await buttonText(btn); 399 - if (!/undo repost|remove repost/i.test(before)) { 400 - return { note: 'already not reposted' }; 401 - } 402 - await btn.click({ noWaitAfter: true }); 403 - await wait(page, 1500); 404 - return { note: await buttonText(btn) }; 405 - }; 406 - 407 - const ensureBookmarked = async (page, row) => { 408 - const btn = row.getByTestId('postBookmarkBtn').first(); 409 - const before = await buttonText(btn); 410 - if (/remove from saved posts/i.test(before)) { 411 - return { note: 'already bookmarked' }; 412 - } 413 - await btn.click({ noWaitAfter: true }); 414 - await wait(page, 1500); 415 - return { note: await buttonText(btn) }; 416 - }; 417 - 418 - const ensureNotBookmarked = async (page, row) => { 419 - const btn = row.getByTestId('postBookmarkBtn').first(); 420 - const before = await buttonText(btn); 421 - if (!/remove from saved posts/i.test(before)) { 422 - return { note: 'already not bookmarked' }; 423 - } 424 - await btn.click({ noWaitAfter: true }); 425 - await wait(page, 1500); 426 - return { note: await buttonText(btn) }; 427 - }; 428 - 429 - const clickQuote = async (page, row, text) => { 430 - await dismissBlockingOverlays(page); 431 - const btn = row.getByTestId('repostBtn').first(); 432 - await btn.click({ noWaitAfter: true }); 433 - await wait(page, 500); 434 - const quote = page.getByText(/^Quote post$/).last(); 435 - if (!(await quote.count())) { 436 - throw new Error('quote option not available'); 437 - } 438 - await quote.click({ noWaitAfter: true }); 439 - await publishComposer(page, text, { 440 - applyWritesLabel: 'quote publish', 441 - publishLabel: /publish post/i, 442 - }); 443 - await dismissBlockingOverlays(page); 444 - }; 445 - 446 - const clickReply = async (page, row, text) => { 447 - await dismissBlockingOverlays(page); 448 - const btn = row.getByTestId('replyBtn').first(); 449 - await btn.click({ noWaitAfter: true }); 450 - await wait(page, 1000); 451 - 452 - const composeReply = page.getByRole('button', { name: /compose reply/i }).last(); 453 - if (await composeReply.count()) { 454 - await composeReply.click({ noWaitAfter: true }); 455 - await wait(page, 500); 456 - } else { 457 - const writeYourReply = page.getByText(/^Write your reply$/).last(); 458 - if (await writeYourReply.count()) { 459 - await writeYourReply.click({ noWaitAfter: true }); 460 - await wait(page, 500); 461 - } 462 - } 463 - 464 - await publishComposer(page, text, { 465 - applyWritesLabel: 'reply publish', 466 - publishLabel: /publish reply|reply/i, 467 - }); 468 - await dismissBlockingOverlays(page); 469 - }; 470 - 471 - const openProfileMenu = async (page) => { 472 - const btn = page.getByTestId('profileHeaderDropdownBtn').first(); 473 - await btn.waitFor({ state: 'visible', timeout: 15000 }); 474 - await btn.click({ noWaitAfter: true }); 475 - const menu = page.locator('[role="menu"]').last(); 476 - await menu.waitFor({ state: 'visible', timeout: 10000 }); 477 - return menu; 478 - }; 479 - 480 - const menuItems = async (page) => 481 - page.locator('[role="menuitem"]').evaluateAll((els) => 482 - els.map((el) => (el.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean), 483 - ); 484 - 485 - const closeActiveMenu = async (page) => { 486 - const backdrop = page.locator('[aria-label*="backdrop"]').last(); 487 - if (await backdrop.count()) { 488 - await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 489 - await wait(page, 400); 490 - return; 491 - } 492 - await page.keyboard.press('Escape').catch(() => undefined); 493 - await wait(page, 400); 494 - }; 495 - 496 - const ensureProfileMuted = async (page) => { 497 - await openProfileMenu(page); 498 - const items = await menuItems(page); 499 - if (items.some((item) => /unmute account/i.test(item))) { 500 - await closeActiveMenu(page); 501 - return { note: 'already muted' }; 502 - } 503 - await page.getByRole('menuitem', { name: /mute account/i }).click({ noWaitAfter: true }); 504 - await wait(page, 1500); 505 - await openProfileMenu(page); 506 - const after = await menuItems(page); 507 - await closeActiveMenu(page); 508 - if (!after.some((item) => /unmute account/i.test(item))) { 509 - throw new Error('mute account did not switch menu state'); 510 - } 511 - return { note: 'muted account' }; 512 - }; 513 - 514 - const ensureProfileUnmuted = async (page) => { 515 - await openProfileMenu(page); 516 - const items = await menuItems(page); 517 - if (!items.some((item) => /unmute account/i.test(item))) { 518 - await closeActiveMenu(page); 519 - return { note: 'already unmuted' }; 520 - } 521 - await page.getByRole('menuitem', { name: /unmute account/i }).click({ noWaitAfter: true }); 522 - await wait(page, 1500); 523 - await openProfileMenu(page); 524 - const after = await menuItems(page); 525 - await closeActiveMenu(page); 526 - if (!after.some((item) => /mute account/i.test(item))) { 527 - throw new Error('unmute account did not restore menu state'); 528 - } 529 - return { note: 'unmuted account' }; 530 - }; 531 - 532 - const blockProfile = async (page) => { 533 - await openProfileMenu(page); 534 - const items = await menuItems(page); 535 - if (items.some((item) => /unblock account/i.test(item))) { 536 - await closeActiveMenu(page); 537 - return { note: 'already blocked' }; 538 - } 539 - await page.getByRole('menuitem', { name: /block account/i }).click({ noWaitAfter: true }); 540 - const dialog = page.locator('[role="dialog"]').last(); 541 - await dialog.waitFor({ state: 'visible', timeout: 10000 }); 542 - await dialog.getByRole('button', { name: /^Block$/i }).click({ noWaitAfter: true }); 543 - await wait(page, 2500); 544 - const unblock = page.getByRole('button', { name: /unblock/i }).first(); 545 - if (!(await unblock.count())) { 546 - throw new Error('block account did not expose an unblock button'); 547 - } 548 - return { note: 'blocked account' }; 549 - }; 550 - 551 - const unblockProfile = async (page) => { 552 - const unblock = page.getByRole('button', { name: /unblock/i }).first(); 553 - if (!(await unblock.count())) { 554 - return { note: 'already unblocked' }; 555 - } 556 - await unblock.click({ noWaitAfter: true }); 557 - await wait(page, 1000); 558 - const dialog = page.locator('[role="dialog"]').last(); 559 - const confirm = dialog.getByRole('button', { name: /unblock/i }).last(); 560 - if (await confirm.count()) { 561 - await confirm.click({ noWaitAfter: true }); 562 - } 563 - await wait(page, 1500); 564 - const blockedBadge = page.getByText(/user blocked/i).first(); 565 - if (await blockedBadge.count()) { 566 - throw new Error('profile still appears blocked after unblock'); 567 - } 568 - return { note: 'unblocked account' }; 569 - }; 570 - 571 - const openReportPostDraft = async (page, row) => { 572 - await openPostOptions(page, row); 573 - await page.getByRole('menuitem', { name: /report post/i }).click({ noWaitAfter: true }); 574 - const dialog = page.locator('[role="dialog"]').last(); 575 - await dialog.waitFor({ state: 'visible', timeout: 10000 }); 576 - await dialog.getByRole('button', { name: /create report for other/i }).click({ noWaitAfter: true }); 577 - await wait(page, 1000); 578 - const submit = dialog.getByRole('button', { name: /submit report/i }).last(); 579 - await submit.waitFor({ state: 'visible', timeout: 10000 }); 580 - const body = normalizeText(await dialog.textContent()); 581 - const close = dialog.getByRole('button', { name: /close active dialog/i }).last(); 582 - if (await close.count()) { 583 - await close.click({ noWaitAfter: true }); 584 - } else { 585 - await page.keyboard.press('Escape').catch(() => undefined); 586 - } 587 - await wait(page, 1000); 588 - return { 589 - note: 'opened report draft without submitting', 590 - submitVisible: true, 591 - body, 592 - }; 593 - }; 594 - 595 - const waitForVisibleEditor = async (page) => { 596 - const editors = page.locator('[aria-label="Rich-Text Editor"]'); 597 - const started = Date.now(); 598 - while (Date.now() - started < 20000) { 599 - const count = await editors.count(); 600 - for (let i = count - 1; i >= 0; i -= 1) { 601 - const editor = editors.nth(i); 602 - if (await editor.isVisible().catch(() => false)) { 603 - return editor; 604 - } 605 - } 606 - await wait(page, 250); 607 - } 608 - throw new Error('visible rich-text editor not found'); 609 - }; 610 - 611 - const publishComposer = async (page, text, { applyWritesLabel, publishLabel }) => { 612 - const editor = await waitForVisibleEditor(page); 613 - await editor.click({ noWaitAfter: true }); 614 - await editor.fill(text); 615 - 616 - const publish = page.getByTestId('composerPublishBtn').last(); 617 - await publish.waitFor({ state: 'visible', timeout: 15000 }); 618 - const responsePromise = page.waitForResponse( 619 - (res) => 620 - res.url().includes('/xrpc/com.atproto.repo.applyWrites') && 621 - res.request().method() === 'POST', 622 - { timeout: 30000 }, 623 - ); 624 - await publish.click({ noWaitAfter: true }); 625 - const response = await responsePromise; 626 - if (response.status() !== 200) { 627 - throw new Error(`${applyWritesLabel} failed with status ${response.status()}`); 628 - } 629 - await wait(page, 4000); 630 - 631 - const buttonName = publishLabel instanceof RegExp ? publishLabel : /publish/i; 632 - await page.getByTestId('composerPublishBtn').getByRole('button', { name: buttonName }).waitFor({ 633 - state: 'detached', 634 - timeout: 15000, 635 - }).catch(() => undefined); 636 - }; 637 - 638 - const maybeFollow = async (page) => { 639 - const follow = page.getByTestId('followBtn').first(); 640 - if (await follow.count()) { 641 - const label = (await follow.getAttribute('aria-label')) ?? ''; 642 - if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 643 - return { note: 'already following' }; 644 - } 645 - await follow.click({ noWaitAfter: true }); 646 - await wait(page, 2000); 647 - return { note: 'follow attempted' }; 648 - } 649 - const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 650 - if (!(await roleFollow.count())) { 651 - return { note: 'follow button unavailable' }; 652 - } 653 - const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 654 - if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 655 - return { note: 'already following' }; 656 - } 657 - await roleFollow.click({ noWaitAfter: true }); 658 - await wait(page, 2000); 659 - return { note: 'follow attempted via role button' }; 660 - }; 661 - 662 - const maybeUnfollow = async (page) => { 663 - const btn = page.getByTestId('unfollowBtn').first(); 664 - if (!(await btn.count())) { 665 - return { note: 'already not following' }; 666 - } 667 - await btn.click({ noWaitAfter: true }); 668 - await wait(page, 2000); 669 - return { note: 'unfollow attempted' }; 670 - }; 671 - 672 - const openNotifications = async (page) => { 673 - await page.goto(`${appBaseUrl}/notifications`, { 674 - waitUntil: 'domcontentloaded', 675 - timeout: 60000, 676 - }); 677 - await wait(page, 3000); 678 - const heading = page.getByText(/^Notifications$/).first(); 679 - if (await heading.count()) { 680 - await heading.waitFor({ state: 'visible', timeout: 15000 }); 681 - } 682 - }; 683 - 684 - const openSavedPosts = async (page) => { 685 - await page.goto(`${appBaseUrl}/saved`, { 686 - waitUntil: 'domcontentloaded', 687 - timeout: 60000, 688 - }); 689 - await wait(page, 3000); 690 - }; 691 - 692 - const waitForNotificationsFeed = async (page) => { 693 - const feed = page.getByTestId('notifsFeed').first(); 694 - if (await feed.count()) { 695 - await feed.waitFor({ state: 'visible', timeout: 15000 }); 696 - return feed; 697 - } 698 - return null; 699 - }; 700 - 701 - const waitForNotificationFeedItem = async (page, handle, timeout = 20000) => { 702 - const exact = page.getByTestId(`feedItem-by-${handle}`).first(); 703 - try { 704 - await exact.waitFor({ state: 'visible', timeout }); 705 - return exact; 706 - } catch { 707 - const fallback = page.locator(`[data-testid^="feedItem-by-${handle}"]`).first(); 708 - await fallback.waitFor({ state: 'visible', timeout }); 709 - return fallback; 710 - } 711 - }; 712 - 713 - const openProfileTab = async (page, name) => { 714 - const tab = page.getByRole('tab', { name }).first(); 715 - await tab.waitFor({ state: 'visible', timeout: 15000 }); 716 - await tab.click({ noWaitAfter: true }); 717 - await wait(page, 2000); 718 - }; 719 - 720 - const openPostOptions = async (page, row) => { 721 - const btn = row.getByTestId('postDropdownBtn').first(); 722 - await btn.click({ noWaitAfter: true }); 723 - const menu = page.locator('[role="menu"]').last(); 724 - await menu.waitFor({ state: 'visible', timeout: 10000 }); 725 - return menu; 726 - }; 727 - 728 - const deletePostRow = async (page, row) => { 729 - await openPostOptions(page, row); 730 - const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 731 - await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 732 - await deleteItem.click({ noWaitAfter: true }); 733 - const dialog = page.locator('[role="dialog"]').last(); 734 - await dialog.waitFor({ state: 'visible', timeout: 10000 }); 735 - const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 736 - await confirm.click({ noWaitAfter: true }); 737 - await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 738 - await wait(page, 3000); 739 - }; 740 - 741 - const maybeDeleteOwnPostByText = async (page, text, successNote) => { 742 - const row = await maybeFindRowByPrimaryText(page, text, 10000); 743 - if (!row) { 744 - return { note: `not surfaced for cleanup: ${text}` }; 745 - } 746 - await deletePostRow(page, row); 747 - return { note: successNote }; 748 - }; 749 - 750 - const ensureBodyContainsAny = async (page, needles) => { 751 - const started = Date.now(); 752 - while (Date.now() - started < 60000) { 753 - const bodyText = normalizeText(await page.locator('body').textContent()); 754 - if (needles.some((needle) => bodyText.includes(needle))) { 755 - return { note: 'notification text visible in UI' }; 756 - } 757 - await wait(page, 2000); 758 - } 759 - throw new Error(`body did not contain any of: ${needles.join(', ')}`); 760 - }; 761 - 762 - try { 763 - await step('primary-login', () => login(primaryPage, primary), { pageNames: ['primary'] }); 764 - await step('primary-age-assurance', () => completeAgeAssuranceIfNeeded(primaryPage, primary), { 765 - optional: true, 766 - pageNames: ['primary'], 767 - }); 768 - await step('secondary-login', () => login(secondaryPage, secondary), { pageNames: ['secondary'] }); 769 - await step('secondary-age-assurance', () => completeAgeAssuranceIfNeeded(secondaryPage, secondary), { 770 - optional: true, 771 - pageNames: ['secondary'], 772 - }); 773 - 774 - primary.session = await createSession(primary.handle, primary.password); 775 - primary.accessJwt = primary.session.accessJwt; 776 - primary.did = primary.session.did; 777 - secondary.session = await createSession(secondary.handle, secondary.password); 778 - secondary.accessJwt = secondary.session.accessJwt; 779 - secondary.did = secondary.session.did; 780 - 781 - await step('primary-preclean-stale-artifacts', async () => cleanupStaleSmokeArtifacts(primary)); 782 - await step('secondary-preclean-stale-artifacts', async () => cleanupStaleSmokeArtifacts(secondary)); 783 - 784 - await step('primary-compose-root-post', () => composePost(primaryPage, primary.postText), { 785 - pageNames: ['primary'], 786 - }); 787 - 788 - primary.rootPost = await waitForOwnPostRecord(primary, primary.postText); 789 - 790 - await step('primary-own-profile', async () => { 791 - await gotoProfile(primaryPage, primary.handle); 792 - await waitForProfileHandle(primaryPage, primary.handle); 793 - const row = await findRowByPrimaryText(primaryPage, primary.postText, 60000); 794 - const rowTestId = await row.getAttribute('data-testid'); 795 - return { rowTestId }; 796 - }, { pageNames: ['primary'] }); 797 - 798 - await step('primary-compose-image-post', async () => composePostWithImage(primaryPage, primary.mediaPostText), { 799 - pageNames: ['primary'], 800 - }); 801 - 802 - await step('primary-image-post-record', async () => { 803 - primary.imagePost = await waitForOwnPostRecord(primary, primary.mediaPostText); 804 - const embed = primary.imagePost.value?.embed; 805 - if (embed?.$type !== 'app.bsky.embed.images' || !Array.isArray(embed.images) || embed.images.length < 1) { 806 - throw new Error('image post did not persist an app.bsky.embed.images record'); 807 - } 808 - return { 809 - uri: primary.imagePost.uri, 810 - imageCount: embed.images.length, 811 - mimeType: embed.images[0]?.image?.mimeType, 812 - }; 813 - }); 814 - 815 - await step('secondary-compose-root-post', () => composePost(secondaryPage, secondary.postText), { 816 - pageNames: ['secondary'], 817 - }); 818 - 819 - secondary.rootPost = await waitForOwnPostRecord(secondary, secondary.postText); 820 - 821 - await step('secondary-own-profile', async () => { 822 - await gotoProfile(secondaryPage, secondary.handle); 823 - await waitForProfileHandle(secondaryPage, secondary.handle); 824 - const row = await findRowByPrimaryText(secondaryPage, secondary.postText, 60000); 825 - const rowTestId = await row.getAttribute('data-testid'); 826 - return { rowTestId }; 827 - }, { pageNames: ['secondary'] }); 828 - 829 - await step('primary-edit-profile', () => editProfile(primaryPage, primary), { 830 - pageNames: ['primary'], 831 - }); 832 - 833 - await step('primary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(primary)); 834 - 835 - await step('primary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(primary)); 836 - 837 - await step('secondary-edit-profile', () => editProfile(secondaryPage, secondary), { 838 - pageNames: ['secondary'], 839 - }); 840 - 841 - await step('secondary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(secondary)); 842 - 843 - await step('secondary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(secondary)); 844 - 845 - await step('primary-create-list', async () => { 846 - return createList(primaryPage, primary.listName, primary.listDescription); 847 - }, { pageNames: ['primary'] }); 848 - 849 - await step('primary-list-record', async () => { 850 - primary.listRecord = await waitForOwnListRecord(primary, primary.listName); 851 - primary.listRkey = recordRkey(primary.listRecord); 852 - if (primary.listRecord.value?.description !== primary.listDescription) { 853 - throw new Error('list record description did not match after create'); 854 - } 855 - return { 856 - uri: primary.listRecord.uri, 857 - rkey: primary.listRkey, 858 - description: primary.listRecord.value?.description, 859 - }; 860 - }); 861 - 862 - await step('primary-edit-list', async () => { 863 - await openListPage(primaryPage, primary.handle, primary.listRkey); 864 - return editCurrentList(primaryPage, primary.listUpdatedName, primary.listUpdatedDescription); 865 - }, { pageNames: ['primary'] }); 866 - 867 - await step('primary-list-record-after-edit', async () => { 868 - primary.listRecord = await waitForOwnListRecord(primary, primary.listUpdatedName); 869 - primary.listRkey = recordRkey(primary.listRecord); 870 - if (primary.listRecord.value?.description !== primary.listUpdatedDescription) { 871 - throw new Error('list record description did not match after edit'); 872 - } 873 - return { 874 - uri: primary.listRecord.uri, 875 - rkey: primary.listRkey, 876 - description: primary.listRecord.value?.description, 877 - }; 878 - }); 879 - 880 - await step('primary-list-add-secondary-member', async () => { 881 - await openListPage(primaryPage, primary.handle, primary.listRkey); 882 - return addUserToCurrentList(primaryPage, secondary.handle); 883 - }, { pageNames: ['primary'] }); 884 - 885 - await step('primary-list-member-record', async () => { 886 - primary.listItemRecord = await waitForOwnListItemRecord(primary, primary.listRecord.uri, secondary.did); 887 - return { 888 - uri: primary.listItemRecord.uri, 889 - rkey: recordRkey(primary.listItemRecord), 890 - }; 891 - }); 892 - 893 - await step('primary-list-remove-secondary-member', async () => { 894 - await openListPage(primaryPage, primary.handle, primary.listRkey); 895 - return removeUserFromCurrentList(primaryPage, secondary.handle); 896 - }, { pageNames: ['primary'] }); 897 - 898 - await step('primary-list-member-record-removed', async () => { 899 - await waitForNoOwnRecord( 900 - primary, 901 - 'app.bsky.graph.listitem', 902 - (record) => 903 - record?.value?.list === primary.listRecord.uri && record?.value?.subject === secondary.did, 904 - ); 905 - return { listUri: primary.listRecord.uri, subject: secondary.did }; 906 - }); 907 - 908 - await step('primary-delete-list', async () => { 909 - await openListPage(primaryPage, primary.handle, primary.listRkey); 910 - return deleteCurrentList(primaryPage); 911 - }, { pageNames: ['primary'] }); 912 - 913 - await step('primary-list-record-removed', async () => { 914 - await waitForNoOwnRecord( 915 - primary, 916 - 'app.bsky.graph.list', 917 - (record) => recordRkey(record) === primary.listRkey, 918 - ); 919 - return { rkey: primary.listRkey }; 920 - }); 921 - 922 - const primaryWaveStarted = Date.now() - 1000; 923 - await step('primary-open-secondary-profile', async () => { 924 - await gotoProfile(primaryPage, secondary.handle); 925 - await waitForProfileHandle(primaryPage, secondary.handle); 926 - }, { pageNames: ['primary'] }); 927 - 928 - await step('primary-reset-follow-secondary', () => maybeUnfollow(primaryPage), { 929 - optional: true, 930 - pageNames: ['primary'], 931 - }); 932 - 933 - await step('primary-follow-secondary', () => maybeFollow(primaryPage), { 934 - pageNames: ['primary'], 935 - }); 936 - 937 - await step('primary-follow-secondary-record', async () => { 938 - const record = await waitForFollowRecord(primary, secondary.did); 939 - return { uri: record.uri }; 940 - }); 941 - 942 - await step('primary-like-secondary-post', async () => { 943 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 944 - return ensureLiked(primaryPage, row); 945 - }, { pageNames: ['primary'] }); 946 - 947 - await step('primary-bookmark-secondary-post', async () => { 948 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 949 - return ensureBookmarked(primaryPage, row); 950 - }, { pageNames: ['primary'] }); 951 - 952 - await step('primary-saved-posts-secondary', async () => { 953 - await openSavedPosts(primaryPage); 954 - await primaryPage.getByText(`@${secondary.handle.replace(/^@/, '')}`).first().waitFor({ 955 - state: 'visible', 956 - timeout: 20000, 957 - }); 958 - return { note: `saved post by ${secondary.handle}` }; 959 - }, { pageNames: ['primary'] }); 960 - 961 - await step('primary-repost-secondary-post', async () => { 962 - await gotoProfile(primaryPage, secondary.handle); 963 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 964 - return ensureReposted(primaryPage, row); 965 - }, { pageNames: ['primary'] }); 966 - 967 - await step('primary-quote-secondary-post', async () => { 968 - await gotoProfile(primaryPage, secondary.handle); 969 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 970 - await clickQuote(primaryPage, row, primary.quoteText); 971 - primary.quotePost = await waitForOwnPostRecord(primary, primary.quoteText); 972 - return { quoteText: primary.quoteText, uri: primary.quotePost.uri }; 973 - }, { pageNames: ['primary'] }); 974 - 975 - await step('primary-reply-secondary-post', async () => { 976 - await gotoProfile(primaryPage, secondary.handle); 977 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 978 - await clickReply(primaryPage, row, primary.replyText); 979 - primary.replyPost = await waitForOwnPostRecord(primary, primary.replyText); 980 - return { replyText: primary.replyText, uri: primary.replyPost.uri }; 981 - }, { pageNames: ['primary'] }); 982 - 983 - await step('secondary-notification-api-primary-engagement-wave', async () => { 984 - const result = await pollNotifications({ 985 - account: secondary, 986 - authorHandle: primary.handle, 987 - reasons: ['like', 'repost', 'quote', 'reply'], 988 - minIndexedAt: primaryWaveStarted, 989 - }); 990 - return { 991 - reasons: result.notifications.map((item) => item.reason), 992 - sample: result.allNotifications.slice(0, 5), 993 - }; 994 - }); 995 - 996 - await step('secondary-notifications-page', async () => { 997 - await openNotifications(secondaryPage); 998 - const feed = await waitForNotificationsFeed(secondaryPage); 999 - return { note: feed ? 'notifications feed visible' : 'notifications page visible without explicit feed testid' }; 1000 - }, { pageNames: ['secondary'] }); 1001 - 1002 - const secondaryWaveStarted = Date.now() - 1000; 1003 - await step('secondary-open-primary-profile', async () => { 1004 - await gotoProfile(secondaryPage, primary.handle); 1005 - await waitForProfileHandle(secondaryPage, primary.handle); 1006 - }, { pageNames: ['secondary'] }); 1007 - 1008 - await step('secondary-reset-follow-primary', () => maybeUnfollow(secondaryPage), { 1009 - optional: true, 1010 - pageNames: ['secondary'], 1011 - }); 1012 - 1013 - await step('secondary-follow-primary', () => maybeFollow(secondaryPage), { 1014 - pageNames: ['secondary'], 1015 - }); 1016 - 1017 - await step('secondary-follow-primary-record', async () => { 1018 - const record = await waitForFollowRecord(secondary, primary.did); 1019 - return { uri: record.uri }; 1020 - }); 1021 - 1022 - await step('primary-notification-api-secondary-follow', async () => { 1023 - const result = await pollNotifications({ 1024 - account: primary, 1025 - authorHandle: secondary.handle, 1026 - reasons: ['follow'], 1027 - minIndexedAt: secondaryWaveStarted, 1028 - timeoutMs: 30000, 1029 - }); 1030 - return { 1031 - reasons: result.notifications.map((item) => item.reason), 1032 - sample: result.allNotifications.slice(0, 5), 1033 - }; 1034 - }, { optional: true }); 1035 - 1036 - await step('primary-notifications-page', async () => { 1037 - await openNotifications(primaryPage); 1038 - const feed = await waitForNotificationsFeed(primaryPage); 1039 - return { note: feed ? 'notifications feed visible' : 'notifications page visible without explicit feed testid' }; 1040 - }, { pageNames: ['primary'] }); 1041 - 1042 - await step('primary-mute-secondary', async () => { 1043 - await gotoProfile(primaryPage, secondary.handle); 1044 - return ensureProfileMuted(primaryPage); 1045 - }, { pageNames: ['primary'] }); 1046 - 1047 - await step('primary-unmute-secondary', async () => { 1048 - await gotoProfile(primaryPage, secondary.handle); 1049 - return ensureProfileUnmuted(primaryPage); 1050 - }, { pageNames: ['primary'] }); 1051 - 1052 - await step('secondary-report-primary-post-draft', async () => { 1053 - await gotoProfile(secondaryPage, primary.handle); 1054 - const row = await findRowByPrimaryText(secondaryPage, primary.postText, 60000); 1055 - return openReportPostDraft(secondaryPage, row); 1056 - }, { pageNames: ['secondary'] }); 1057 - 1058 - await step('secondary-block-primary', async () => { 1059 - await gotoProfile(secondaryPage, primary.handle); 1060 - return blockProfile(secondaryPage); 1061 - }, { pageNames: ['secondary'] }); 1062 - 1063 - await step('secondary-unblock-primary', async () => { 1064 - return unblockProfile(secondaryPage); 1065 - }, { pageNames: ['secondary'] }); 1066 - 1067 - await step('primary-settings-likes-people-i-follow', async () => { 1068 - return setRadioSetting(primaryPage, '/settings/notifications/likes', 'People I follow'); 1069 - }, { pageNames: ['primary'] }); 1070 - 1071 - await step('primary-settings-likes-everyone', async () => { 1072 - return setRadioSetting(primaryPage, '/settings/notifications/likes', 'Everyone'); 1073 - }, { pageNames: ['primary'] }); 1074 - 1075 - await step('primary-settings-threads-oldest', async () => { 1076 - return setRadioSetting(primaryPage, '/settings/threads', 'Oldest replies first'); 1077 - }, { pageNames: ['primary'] }); 1078 - 1079 - await step('primary-settings-threads-tree-view-on', async () => { 1080 - return setCheckboxSetting(primaryPage, '/settings/threads', 'Tree view', true); 1081 - }, { pageNames: ['primary'] }); 1082 - 1083 - await step('primary-settings-threads-tree-view-off', async () => { 1084 - return setCheckboxSetting(primaryPage, '/settings/threads', 'Tree view', false); 1085 - }, { pageNames: ['primary'] }); 1086 - 1087 - await step('primary-settings-threads-top-replies', async () => { 1088 - return setRadioSetting(primaryPage, '/settings/threads', 'Top replies first'); 1089 - }, { pageNames: ['primary'] }); 1090 - 1091 - await step('primary-settings-following-feed-hide-replies', async () => { 1092 - return setCheckboxSetting(primaryPage, '/settings/following-feed', 'Show replies', false); 1093 - }, { pageNames: ['primary'] }); 1094 - 1095 - await step('primary-settings-following-feed-show-replies', async () => { 1096 - return setCheckboxSetting(primaryPage, '/settings/following-feed', 'Show replies', true); 1097 - }, { pageNames: ['primary'] }); 1098 - 1099 - await step('primary-settings-autoplay-off', async () => { 1100 - return setCheckboxSetting(primaryPage, '/settings/content-and-media', 'Autoplay videos and GIFs', false); 1101 - }, { pageNames: ['primary'] }); 1102 - 1103 - await step('primary-settings-autoplay-on', async () => { 1104 - return setCheckboxSetting(primaryPage, '/settings/content-and-media', 'Autoplay videos and GIFs', true); 1105 - }, { pageNames: ['primary'] }); 1106 - 1107 - await step('primary-settings-accessibility-require-alt-on', async () => { 1108 - return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Require alt text before posting', true); 1109 - }, { pageNames: ['primary'] }); 1110 - 1111 - await step('primary-settings-accessibility-require-alt-off', async () => { 1112 - return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Require alt text before posting', false); 1113 - }, { pageNames: ['primary'] }); 1114 - 1115 - await step('primary-settings-accessibility-large-badges-on', async () => { 1116 - return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Display larger alt text badges', true); 1117 - }, { pageNames: ['primary'] }); 1118 - 1119 - await step('primary-settings-accessibility-large-badges-off', async () => { 1120 - return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Display larger alt text badges', false); 1121 - }, { pageNames: ['primary'] }); 1122 - 1123 - await step('primary-cleanup-unlike-secondary-post', async () => { 1124 - await gotoProfile(primaryPage, secondary.handle); 1125 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1126 - return ensureNotLiked(primaryPage, row); 1127 - }, { optional: true, pageNames: ['primary'] }); 1128 - 1129 - await step('primary-cleanup-unbookmark-secondary-post', async () => { 1130 - await gotoProfile(primaryPage, secondary.handle); 1131 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1132 - return ensureNotBookmarked(primaryPage, row); 1133 - }, { optional: true, pageNames: ['primary'] }); 1134 - 1135 - await step('primary-cleanup-undo-repost-secondary-post', async () => { 1136 - await gotoProfile(primaryPage, secondary.handle); 1137 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1138 - return ensureNotReposted(primaryPage, row); 1139 - }, { optional: true, pageNames: ['primary'] }); 1140 - 1141 - await step('primary-cleanup-unfollow-secondary', async () => { 1142 - await gotoProfile(primaryPage, secondary.handle); 1143 - return maybeUnfollow(primaryPage); 1144 - }, { optional: true, pageNames: ['primary'] }); 1145 - 1146 - await step('secondary-cleanup-unfollow-primary', async () => { 1147 - await gotoProfile(secondaryPage, primary.handle); 1148 - return maybeUnfollow(secondaryPage); 1149 - }, { optional: true, pageNames: ['secondary'] }); 1150 - 1151 - await step('primary-cleanup-delete-quote', async () => { 1152 - await gotoProfile(primaryPage, primary.handle); 1153 - await openProfileTab(primaryPage, 'Posts'); 1154 - return maybeDeleteOwnPostByText(primaryPage, primary.quoteText, 'deleted quote post'); 1155 - }, { pageNames: ['primary'] }); 1156 - 1157 - await step('primary-cleanup-delete-image-post', async () => { 1158 - await gotoProfile(primaryPage, primary.handle); 1159 - await openProfileTab(primaryPage, 'Posts'); 1160 - return maybeDeleteOwnPostByText(primaryPage, primary.mediaPostText, 'deleted image post'); 1161 - }, { pageNames: ['primary'] }); 1162 - 1163 - await step('primary-cleanup-delete-reply', async () => { 1164 - await gotoProfile(primaryPage, primary.handle); 1165 - await openProfileTab(primaryPage, 'Replies'); 1166 - return maybeDeleteOwnPostByText(primaryPage, primary.replyText, 'deleted reply post'); 1167 - }, { optional: true, pageNames: ['primary'] }); 1168 - 1169 - await step('secondary-cleanup-delete-root-post', async () => { 1170 - await gotoProfile(secondaryPage, secondary.handle); 1171 - await openProfileTab(secondaryPage, 'Posts'); 1172 - return maybeDeleteOwnPostByText(secondaryPage, secondary.postText, 'deleted root post'); 1173 - }, { pageNames: ['secondary'] }); 1174 - 1175 - await step('primary-cleanup-delete-root-post', async () => { 1176 - await gotoProfile(primaryPage, primary.handle); 1177 - await openProfileTab(primaryPage, 'Posts'); 1178 - return maybeDeleteOwnPostByText(primaryPage, primary.postText, 'deleted root post'); 1179 - }, { optional: true, pageNames: ['primary'] }); 1180 - } catch (error) { 1181 - summary.fatal = String(error?.message ?? error); 1182 - } 1183 - 1184 - summary.finishedAt = new Date().toISOString(); 1185 - summary.unexpected = { 1186 - console: summary.console.filter((entry) => !isIgnoredConsole(entry)), 1187 - requestFailures: summary.requestFailures.filter((entry) => !isIgnoredRequestFailure(entry)), 1188 - httpFailures: summary.httpFailures.filter((entry) => !isIgnoredHttpFailure(entry)), 1189 - pageErrors: summary.pageErrors, 1190 - }; 1191 - summary.unexpected.total = 1192 - summary.unexpected.console.length + 1193 - summary.unexpected.requestFailures.length + 1194 - summary.unexpected.httpFailures.length + 1195 - summary.unexpected.pageErrors.length; 1196 - if (!summary.fatal && config.strictErrors !== false && summary.unexpected.total > 0) { 1197 - summary.fatal = `Unexpected browser/runtime errors: ${summary.unexpected.total}`; 1198 - } 1199 - summary.ok = !summary.fatal; 1200 - await screenshot('primary', 'final').catch(() => undefined); 1201 - await screenshot('secondary', 'final').catch(() => undefined); 1202 - await fs.writeFile( 1203 - path.join(config.artifactsDir, 'summary.json'), 1204 - JSON.stringify(summary, null, 2) + '\n', 1205 - 'utf8', 1206 - ); 1207 - console.log(JSON.stringify(summary, null, 2)); 1208 - await browser.close(); 1209 - if (!summary.ok) { 1210 - process.exitCode = 1; 1211 - } 3 + const exitCode = await runDualFromArgv(process.argv); 4 + process.exitCode = exitCode;
tools/browser-automation/lib/dual-api.mjs pds-smoke-suite/src/browser/lib/dual-api.mjs
+1 -1
tools/browser-automation/lib/dual-browser.mjs pds-smoke-suite/src/browser/lib/dual-browser.mjs
··· 1 1 import fs from 'node:fs/promises'; 2 2 import path from 'node:path'; 3 - import { chromium } from 'playwright'; 3 + import { chromium } from './playwright-runtime.mjs'; 4 4 5 5 const ignoredConsole = [ 6 6 /events\.bsky\.app\/.*ERR_BLOCKED_BY_CLIENT/i,
tools/browser-automation/lib/lists.mjs pds-smoke-suite/src/browser/lib/lists.mjs
tools/browser-automation/lib/settings.mjs pds-smoke-suite/src/browser/lib/settings.mjs
+3 -934
tools/browser-automation/smoke.mjs
··· 1 - import fs from 'node:fs/promises'; 2 - import path from 'node:path'; 3 - import { chromium } from 'playwright'; 4 - 5 - const configPath = process.argv[2]; 6 - if (!configPath) { 7 - console.error('usage: node smoke.mjs <config.json>'); 8 - process.exit(2); 9 - } 10 - 11 - const config = JSON.parse(await fs.readFile(configPath, 'utf8')); 12 - await fs.mkdir(config.artifactsDir, { recursive: true }); 13 - const appBaseUrl = config.appUrl.replace(/\/$/, ''); 14 - 15 - const summary = { 16 - startedAt: new Date().toISOString(), 17 - appUrl: config.appUrl, 18 - pdsUrl: config.pdsUrl, 19 - publicApiUrl: config.publicApiUrl, 20 - handle: config.handle, 21 - targetHandle: config.targetHandle, 22 - steps: [], 23 - console: [], 24 - pageErrors: [], 25 - requestFailures: [], 26 - httpFailures: [], 27 - xrpc: [], 28 - notes: [], 29 - }; 30 - 31 - const ignoredConsole = [ 32 - /events\.bsky\.app\/.*ERR_BLOCKED_BY_CLIENT/i, 33 - /slider-vertical/i, 34 - /Password field is not contained in a form/i, 35 - ]; 36 - 37 - const ignoredRequestFailure = [ 38 - { url: /events\.bsky\.app\//i, error: /ERR_(BLOCKED_BY_CLIENT|ABORTED)/i }, 39 - { url: /workers\.dev\/api\/config/i, error: /ERR_ABORTED/i }, 40 - { url: /app-config\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 41 - { url: /live-events\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 42 - { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 43 - { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 44 - { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts)/i, error: /ERR_ABORTED/i }, 45 - { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 46 - ]; 47 - 48 - const ignoredHttpFailure = [ 49 - { url: /c\.1password\.com\/richicons/i, status: 404 }, 50 - ]; 51 - 52 - const AVATAR_PNG_BASE64 = 53 - 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAV0lEQVR4nO3PQQ0AIBDAMMC/58MCP7KkVbDX1pk5A6gWUC2gWkC1gGoB1QKqBVQLqBZQLaBaQLWAagHVAqoFVAuoFlAtoFpAtYBqAdUCqgVUC6gWUC2gWkD1B4a2AX/y3CvgAAAAAElFTkSuQmCC'; 54 - 55 - const browserCandidates = async () => { 56 - const base = { 57 - headless: config.headless !== false, 58 - chromiumSandbox: true, 59 - }; 60 - const candidates = []; 61 - if (config.browserExecutablePath) { 62 - candidates.push({ 63 - label: `executable:${config.browserExecutablePath}`, 64 - options: { ...base, executablePath: config.browserExecutablePath }, 65 - }); 66 - } 67 - const systemChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 68 - if (!config.browserExecutablePath) { 69 - try { 70 - await fs.access(systemChrome); 71 - candidates.push({ 72 - label: 'system-google-chrome', 73 - options: { ...base, executablePath: systemChrome }, 74 - }); 75 - } catch { 76 - // Fall back to Playwright-managed Chromium below. 77 - } 78 - } 79 - candidates.push({ 80 - label: 'playwright-chromium', 81 - options: { ...base, channel: 'chromium' }, 82 - }); 83 - return candidates; 84 - }; 1 + import { runSingleFromArgv } from '../../pds-smoke-suite/src/browser/run-single.mjs'; 85 2 86 - const launchBrowser = async () => { 87 - const errors = []; 88 - for (const candidate of await browserCandidates()) { 89 - try { 90 - const browser = await chromium.launch(candidate.options); 91 - summary.notes.push(`browser launch candidate succeeded: ${candidate.label}`); 92 - return browser; 93 - } catch (error) { 94 - errors.push(`${candidate.label}: ${String(error?.message ?? error)}`); 95 - } 96 - } 97 - throw new Error(`unable to launch browser via any candidate: ${errors.join(' | ')}`); 98 - }; 99 - 100 - const browser = await launchBrowser(); 101 - const context = await browser.newContext({ 102 - viewport: { width: 1440, height: 1000 }, 103 - }); 104 - const page = await context.newPage(); 105 - 106 - if (config.browserExecutablePath) { 107 - summary.notes.push(`requested browser executable: ${config.browserExecutablePath}`); 108 - } 109 - 110 - page.on('console', (msg) => { 111 - summary.console.push({ 112 - type: msg.type(), 113 - text: msg.text(), 114 - }); 115 - }); 116 - 117 - page.on('pageerror', (error) => { 118 - summary.pageErrors.push({ 119 - message: String(error?.message ?? error), 120 - stack: error?.stack, 121 - }); 122 - }); 123 - 124 - page.on('requestfailed', (req) => { 125 - summary.requestFailures.push({ 126 - url: req.url(), 127 - method: req.method(), 128 - errorText: req.failure()?.errorText ?? 'unknown', 129 - }); 130 - }); 131 - 132 - page.on('response', (res) => { 133 - const status = res.status(); 134 - if (res.url().includes('/xrpc/')) { 135 - summary.xrpc.push({ 136 - url: res.url(), 137 - status, 138 - method: res.request().method(), 139 - }); 140 - if (summary.xrpc.length > 200) { 141 - summary.xrpc.shift(); 142 - } 143 - } 144 - if (status >= 400) { 145 - summary.httpFailures.push({ 146 - url: res.url(), 147 - status, 148 - method: res.request().method(), 149 - }); 150 - } 151 - }); 152 - 153 - const screenshot = async (name) => { 154 - const file = path.join(config.artifactsDir, `${name}.png`); 155 - await page.screenshot({ path: file, fullPage: true }); 156 - return file; 157 - }; 158 - 159 - const ensureAvatarFixture = async () => { 160 - const file = path.join(config.artifactsDir, 'avatar-fixture.png'); 161 - await fs.writeFile(file, Buffer.from(AVATAR_PNG_BASE64, 'base64')); 162 - return file; 163 - }; 164 - 165 - const recordStep = (name, status, extra = {}) => { 166 - summary.steps.push({ 167 - name, 168 - status, 169 - at: new Date().toISOString(), 170 - ...extra, 171 - }); 172 - }; 173 - 174 - const normalizeText = (text) => (text || '').replace(/\s+/g, ' ').trim(); 175 - 176 - const isIgnoredConsole = (entry) => 177 - ignoredConsole.some((pattern) => pattern.test(entry.text || '')); 178 - 179 - const isIgnoredRequestFailure = (entry) => 180 - ignoredRequestFailure.some( 181 - (rule) => rule.url.test(entry.url || '') && rule.error.test(entry.errorText || ''), 182 - ); 183 - 184 - const isIgnoredHttpFailure = (entry) => 185 - ignoredHttpFailure.some( 186 - (rule) => rule.url.test(entry.url || '') && (!rule.status || rule.status === entry.status), 187 - ); 188 - 189 - const step = async (name, fn, { optional = false } = {}) => { 190 - try { 191 - const result = await fn(); 192 - const shot = await screenshot(name); 193 - recordStep(name, 'ok', { screenshot: shot, ...(result ?? {}) }); 194 - return result; 195 - } catch (error) { 196 - const shot = await screenshot(`${name}-error`).catch(() => undefined); 197 - recordStep(name, optional ? 'skipped' : 'failed', { 198 - screenshot: shot, 199 - error: String(error?.message ?? error), 200 - }); 201 - if (!optional) { 202 - throw error; 203 - } 204 - return null; 205 - } 206 - }; 207 - 208 - const wait = (ms) => page.waitForTimeout(ms); 209 - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 210 - 211 - const fetchJson = async (url) => { 212 - const res = await fetch(url, { 213 - headers: { accept: 'application/json' }, 214 - }); 215 - const text = await res.text(); 216 - let json; 217 - try { 218 - json = text ? JSON.parse(text) : null; 219 - } catch { 220 - json = null; 221 - } 222 - return { ok: res.ok, status: res.status, text, json }; 223 - }; 224 - 225 - const fetchStatus = async (url) => { 226 - const res = await fetch(url, { 227 - redirect: 'follow', 228 - }); 229 - return { ok: res.ok, status: res.status, url: res.url }; 230 - }; 231 - 232 - const pollJson = async (name, buildUrl, predicate, timeoutMs) => { 233 - const started = Date.now(); 234 - let last; 235 - while (Date.now() - started < timeoutMs) { 236 - last = await fetchJson(buildUrl()); 237 - if (predicate(last)) { 238 - return last; 239 - } 240 - await sleep(5000); 241 - } 242 - throw new Error(`${name} did not succeed before timeout; last status=${last?.status ?? 'none'}`); 243 - }; 244 - 245 - const login = async () => { 246 - await page.goto(config.appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); 247 - await page.getByRole('button', { name: 'Sign in' }).nth(0).click({ noWaitAfter: true }); 248 - await wait(1000); 249 - await page.getByRole('button', { name: 'Bluesky Social' }).evaluate((el) => el.click()); 250 - await wait(500); 251 - await page.getByText('Custom').evaluate((el) => el.click()); 252 - await wait(500); 253 - await page.getByPlaceholder('my-server.com').fill(config.pdsHost); 254 - await page.getByRole('button', { name: 'Done' }).evaluate((el) => el.click()); 255 - await wait(500); 256 - const close = page.getByRole('button', { name: 'Close welcome modal' }); 257 - if (await close.count()) { 258 - await close.evaluate((el) => el.click()); 259 - await wait(300); 260 - } 261 - await page.getByPlaceholder('Username or email address').fill(config.handle); 262 - await page.getByPlaceholder('Password').fill(config.password); 263 - await page.getByTestId('loginNextButton').click({ noWaitAfter: true }); 264 - await wait(3000); 265 - }; 266 - 267 - const completeAgeAssuranceIfNeeded = async () => { 268 - const addBirthdate = page.getByRole('button', { name: /update your birthdate/i }); 269 - if (await addBirthdate.count()) { 270 - await addBirthdate.click({ noWaitAfter: true }); 271 - await wait(800); 272 - await page.getByTestId('birthdayInput').fill(config.birthdate); 273 - await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 274 - await wait(3000); 275 - summary.notes.push('Completed age-assurance birthdate gate'); 276 - } 277 - }; 278 - 279 - const gotoProfile = async (handle) => { 280 - await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 281 - waitUntil: 'domcontentloaded', 282 - timeout: 60000, 283 - }); 284 - await wait(3000); 285 - }; 286 - 287 - const maybeFollowTarget = async () => { 288 - const follow = page.getByTestId('followBtn').first(); 289 - if (!(await follow.count())) { 290 - const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 291 - if (!(await roleFollow.count())) { 292 - return { note: 'follow button unavailable' }; 293 - } 294 - const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 295 - if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 296 - return { note: 'already following target' }; 297 - } 298 - await roleFollow.click({ noWaitAfter: true }); 299 - await wait(2000); 300 - return { note: 'follow attempted via role button' }; 301 - } 302 - const label = (await follow.getAttribute('aria-label')) ?? ''; 303 - if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 304 - return { note: 'already following target' }; 305 - } 306 - await follow.click({ noWaitAfter: true }); 307 - await wait(2000); 308 - return { note: 'follow attempted' }; 309 - }; 310 - 311 - const composePost = async (text) => { 312 - await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 313 - await wait(800); 314 - const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 315 - await editor.click({ noWaitAfter: true }); 316 - await editor.fill(text); 317 - await wait(300); 318 - await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 319 - await wait(4000); 320 - }; 321 - 322 - const openOwnProfile = async () => { 323 - await gotoProfile(config.handle); 324 - }; 325 - 326 - const waitForProfileHandle = async (handle, timeout = 20000) => { 327 - const shortHandle = handle.replace(/^@/, ''); 328 - const handleText = shortHandle.startsWith('@') ? shortHandle : `@${shortHandle}`; 329 - await page.getByText(handleText).first().waitFor({ state: 'visible', timeout }); 330 - }; 331 - 332 - const findFeedItemByText = async (needle, timeout = 60000) => { 333 - const row = page.locator('[data-testid^="feedItem-by-"]').filter({ hasText: needle }).first(); 334 - await row.waitFor({ state: 'visible', timeout }); 335 - return row; 336 - }; 337 - 338 - const findRowByPrimaryText = async (needle, timeout = 60000) => { 339 - const started = Date.now(); 340 - while (Date.now() - started < timeout) { 341 - const rows = page.locator('[data-testid^="feedItem-by-"]'); 342 - const count = await rows.count(); 343 - for (let i = 0; i < count; i += 1) { 344 - const row = rows.nth(i); 345 - const primary = row.locator('[data-testid="postText"]').first(); 346 - if (!(await primary.count())) { 347 - continue; 348 - } 349 - const text = normalizeText(await primary.textContent()); 350 - if (text === needle) { 351 - await row.waitFor({ state: 'visible', timeout: 10000 }); 352 - return row; 353 - } 354 - } 355 - await wait(1000); 356 - } 357 - throw new Error(`feed item with primary text not found: ${needle}`); 358 - }; 359 - 360 - const maybeFindRowByPrimaryText = async (needle, timeout = 5000) => { 361 - try { 362 - return await findRowByPrimaryText(needle, timeout); 363 - } catch { 364 - return null; 365 - } 366 - }; 367 - 368 - const findFirstFeedItem = async (timeout = 60000) => { 369 - const row = page.locator('[data-testid^="feedItem-by-"]').first(); 370 - await row.waitFor({ state: 'visible', timeout }); 371 - return row; 372 - }; 373 - 374 - const clickLike = async (row) => { 375 - const btn = row.getByTestId('likeBtn').first(); 376 - await btn.click({ noWaitAfter: true }); 377 - await wait(1500); 378 - }; 379 - 380 - const clickRepost = async (row) => { 381 - const btn = row.getByTestId('repostBtn').first(); 382 - await btn.click({ noWaitAfter: true }); 383 - await wait(500); 384 - const repost = page.getByText(/^Repost$/).last(); 385 - if (await repost.count()) { 386 - await repost.click({ noWaitAfter: true }); 387 - await wait(1500); 388 - } 389 - }; 390 - 391 - const clickQuote = async (row, text) => { 392 - const btn = row.getByTestId('repostBtn').first(); 393 - await btn.click({ noWaitAfter: true }); 394 - await wait(500); 395 - const quote = page.getByText(/^Quote post$/).last(); 396 - if (!(await quote.count())) { 397 - throw new Error('quote option not available'); 398 - } 399 - await quote.click({ noWaitAfter: true }); 400 - await wait(1000); 401 - const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 402 - await editor.click({ noWaitAfter: true }); 403 - await editor.fill(text); 404 - await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 405 - await wait(4000); 406 - }; 407 - 408 - const clickReply = async (row, text) => { 409 - const btn = row.getByTestId('replyBtn').first(); 410 - await btn.click({ noWaitAfter: true }); 411 - await wait(1000); 412 - const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 413 - await editor.click({ noWaitAfter: true }); 414 - await editor.fill(text); 415 - const publishReply = page.getByRole('button', { name: /publish reply|reply/i }).last(); 416 - await publishReply.click({ noWaitAfter: true }); 417 - await wait(4000); 418 - }; 419 - 420 - const ensureBookmarked = async (row) => { 421 - const btn = row.getByTestId('postBookmarkBtn').first(); 422 - const before = await buttonText(btn); 423 - if (/remove from saved posts/i.test(before)) { 424 - return { note: 'already bookmarked' }; 425 - } 426 - await btn.click({ noWaitAfter: true }); 427 - await wait(1500); 428 - return { note: await buttonText(btn) }; 429 - }; 430 - 431 - const ensureNotBookmarked = async (row) => { 432 - const btn = row.getByTestId('postBookmarkBtn').first(); 433 - const before = await buttonText(btn); 434 - if (!/remove from saved posts/i.test(before)) { 435 - return { note: 'already not bookmarked' }; 436 - } 437 - await btn.click({ noWaitAfter: true }); 438 - await wait(1500); 439 - return { note: await buttonText(btn) }; 440 - }; 441 - 442 - const buttonText = async (locator) => { 443 - const label = await locator.getAttribute('aria-label'); 444 - if (label && label.trim()) { 445 - return label.trim(); 446 - } 447 - const text = await locator.innerText().catch(() => ''); 448 - return text.trim(); 449 - }; 450 - 451 - const ensureLiked = async (row) => { 452 - const btn = row.getByTestId('likeBtn').first(); 453 - const before = await buttonText(btn); 454 - if (/unlike/i.test(before)) { 455 - return { note: 'already liked' }; 456 - } 457 - await clickLike(row); 458 - return { note: await buttonText(btn) }; 459 - }; 460 - 461 - const ensureNotLiked = async (row) => { 462 - const btn = row.getByTestId('likeBtn').first(); 463 - const before = await buttonText(btn); 464 - if (!/unlike/i.test(before)) { 465 - return { note: 'already not liked' }; 466 - } 467 - await clickLike(row); 468 - return { note: await buttonText(btn) }; 469 - }; 470 - 471 - const ensureReposted = async (row) => { 472 - const btn = row.getByTestId('repostBtn').first(); 473 - const before = await buttonText(btn); 474 - if (/undo repost|remove repost/i.test(before)) { 475 - return { note: 'already reposted' }; 476 - } 477 - await clickRepost(row); 478 - return { note: await buttonText(btn) }; 479 - }; 480 - 481 - const ensureNotReposted = async (row) => { 482 - const btn = row.getByTestId('repostBtn').first(); 483 - const before = await buttonText(btn); 484 - if (!/undo repost|remove repost/i.test(before)) { 485 - return { note: 'already not reposted' }; 486 - } 487 - await btn.click({ noWaitAfter: true }); 488 - await wait(1500); 489 - return { note: await buttonText(btn) }; 490 - }; 491 - 492 - const openProfileTab = async (name) => { 493 - const tab = page.getByRole('tab', { name }).first(); 494 - await tab.waitFor({ state: 'visible', timeout: 15000 }); 495 - await tab.click({ noWaitAfter: true }); 496 - await wait(2000); 497 - }; 498 - 499 - const maybeUnfollowTarget = async () => { 500 - const btn = page.getByTestId('unfollowBtn').first(); 501 - if (!(await btn.count())) { 502 - return { note: 'already not following target' }; 503 - } 504 - await btn.click({ noWaitAfter: true }); 505 - await wait(2000); 506 - return { note: 'unfollow attempted' }; 507 - }; 508 - 509 - const openPostOptions = async (row) => { 510 - const btn = row.getByTestId('postDropdownBtn').first(); 511 - await btn.click({ noWaitAfter: true }); 512 - const menu = page.locator('[role="menu"]').last(); 513 - await menu.waitFor({ state: 'visible', timeout: 10000 }); 514 - return menu; 515 - }; 516 - 517 - const deletePostRow = async (row) => { 518 - await openPostOptions(row); 519 - const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 520 - await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 521 - await deleteItem.click({ noWaitAfter: true }); 522 - const dialog = page.locator('[role="dialog"]').last(); 523 - await dialog.waitFor({ state: 'visible', timeout: 10000 }); 524 - const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 525 - await confirm.click({ noWaitAfter: true }); 526 - await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 527 - await wait(3000); 528 - }; 529 - 530 - const maybeDeleteOwnPostByText = async (text, successNote) => { 531 - const row = await maybeFindRowByPrimaryText(text, 10000); 532 - if (!row) { 533 - return { note: `not surfaced for cleanup: ${text}` }; 534 - } 535 - await deletePostRow(row); 536 - return { note: successNote }; 537 - }; 538 - 539 - const openNotifications = async () => { 540 - await page.goto(`${appBaseUrl}/notifications`, { 541 - waitUntil: 'domcontentloaded', 542 - timeout: 60000, 543 - }); 544 - await wait(3000); 545 - const heading = page.getByText(/^Notifications$/).first(); 546 - if (await heading.count()) { 547 - await heading.waitFor({ state: 'visible', timeout: 15000 }); 548 - } 549 - }; 550 - 551 - const openSavedPosts = async () => { 552 - await page.goto(`${appBaseUrl}/saved`, { 553 - waitUntil: 'domcontentloaded', 554 - timeout: 60000, 555 - }); 556 - await wait(3000); 557 - }; 558 - 559 - const verifyPublicHandleResolution = async () => { 560 - const result = await pollJson( 561 - 'public handle resolution', 562 - () => `${config.publicApiUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 563 - ({ ok, json }) => ok && typeof json?.did === 'string' && json.did.length > 0, 564 - config.publicCheckTimeoutMs ?? 180000, 565 - ); 566 - return { did: result.json.did }; 567 - }; 568 - 569 - const verifyPublicAuthorFeed = async () => { 570 - const result = await pollJson( 571 - 'public author feed indexing', 572 - () => `${config.publicApiUrl}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(config.handle)}&limit=20`, 573 - ({ ok, json }) => 574 - ok && Array.isArray(json?.feed) && json.feed.some((item) => item?.post?.record?.text === config.postText), 575 - config.publicCheckTimeoutMs ?? 180000, 576 - ); 577 - const matching = result.json.feed.find((item) => item?.post?.record?.text === config.postText); 578 - return { 579 - uri: matching?.post?.uri, 580 - cid: matching?.post?.cid, 581 - }; 582 - }; 583 - 584 - const verifyPublicProfile = async () => { 585 - const result = await pollJson( 586 - 'public profile indexing', 587 - () => `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 588 - ({ ok, json }) => ok && typeof json?.postsCount === 'number' && json.postsCount > 0, 589 - config.publicCheckTimeoutMs ?? 180000, 590 - ); 591 - return { 592 - postsCount: result.json.postsCount, 593 - followersCount: result.json.followersCount, 594 - followsCount: result.json.followsCount, 595 - avatar: result.json.avatar, 596 - description: result.json.description, 597 - }; 598 - }; 599 - 600 - const verifyPublicProfileAfterEdit = async () => { 601 - const result = await pollJson( 602 - 'public profile edit indexing', 603 - () => `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 604 - ({ ok, json }) => 605 - ok && 606 - json?.description === config.profileNote && 607 - typeof json?.avatar === 'string' && 608 - json.avatar.length > 0, 609 - config.publicCheckTimeoutMs ?? 180000, 610 - ); 611 - const avatarResult = await fetchStatus(result.json.avatar); 612 - if (!avatarResult.ok) { 613 - throw new Error(`public avatar URL returned ${avatarResult.status}`); 614 - } 615 - return { 616 - avatar: result.json.avatar, 617 - avatarStatus: avatarResult.status, 618 - description: result.json.description, 619 - }; 620 - }; 621 - 622 - const verifyLocalProfileAfterEdit = async () => { 623 - const didResult = await pollJson( 624 - 'local handle resolution after profile edit', 625 - () => `${config.pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 626 - ({ ok, json }) => ok && typeof json?.did === 'string' && json.did.length > 0, 627 - 30000, 628 - ); 629 - const did = didResult.json.did; 630 - const result = await pollJson( 631 - 'local profile record after edit', 632 - () => 633 - `${config.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, 634 - ({ ok, json }) => 635 - ok && 636 - json?.value?.description === config.profileNote && 637 - typeof json?.value?.avatar?.ref?.$link === 'string' && 638 - json.value.avatar.ref.$link.length > 0, 639 - 30000, 640 - ); 641 - return { 642 - did, 643 - avatarCid: result.json.value.avatar.ref.$link, 644 - description: result.json.value.description, 645 - }; 646 - }; 647 - 648 - const dismissModalBackdropIfPresent = async () => { 649 - const backdrop = page.locator('[aria-label*="click to close"]').last(); 650 - if (await backdrop.count()) { 651 - await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 652 - await wait(400); 653 - } 654 - }; 655 - 656 - const uploadProfileAvatar = async () => { 657 - const avatarFile = await ensureAvatarFixture(); 658 - let fileInputs = page.locator('input[type="file"]'); 659 - let count = await fileInputs.count(); 660 - 661 - if (count === 0) { 662 - const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 663 - if (await changeAvatar.count()) { 664 - await changeAvatar.click({ noWaitAfter: true }); 665 - await wait(500); 666 - const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 667 - if (await uploadFromFiles.count()) { 668 - const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 669 - await uploadFromFiles.click({ noWaitAfter: true }); 670 - const chooser = await chooserPromise; 671 - await chooser.setFiles(avatarFile); 672 - await wait(750); 673 - const editImageHeading = page.getByText(/^Edit image$/).last(); 674 - if (await editImageHeading.count()) { 675 - await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 676 - const cropSave = page.getByRole('button', { name: 'Save' }).last(); 677 - await cropSave.click({ noWaitAfter: true }); 678 - await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 679 - summary.notes.push('profile avatar crop saved'); 680 - } 681 - summary.notes.push('profile avatar uploaded via file chooser'); 682 - await wait(1500); 683 - return avatarFile; 684 - } 685 - } 686 - } 687 - 688 - if (count === 0) { 689 - throw new Error('profile avatar file input unavailable'); 690 - } 691 - 692 - await fileInputs.first().setInputFiles(avatarFile); 693 - await wait(1500); 694 - summary.notes.push(`edit profile file inputs: ${count}`); 695 - return avatarFile; 696 - }; 697 - 698 - const editProfile = async () => { 699 - const edit = page.getByRole('button', { name: /edit profile/i }); 700 - if (!(await edit.count())) { 701 - throw new Error('edit profile button unavailable'); 702 - } 703 - await edit.click({ noWaitAfter: true }); 704 - await wait(1000); 705 - await dismissModalBackdropIfPresent(); 706 - const avatarFile = await uploadProfileAvatar(); 707 - const bioField = page.locator('textarea[aria-label="Description"]').first(); 708 - if (await bioField.count()) { 709 - await bioField.fill(config.profileNote); 710 - const actual = await bioField.inputValue(); 711 - if (actual !== config.profileNote) { 712 - throw new Error(`profile description fill did not stick: ${actual}`); 713 - } 714 - } 715 - const save = page.getByTestId('editProfileSaveBtn'); 716 - await save.waitFor({ state: 'visible', timeout: 15000 }); 717 - await page.waitForFunction(() => { 718 - const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 719 - return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 720 - }, undefined, { timeout: 15000 }); 721 - await save.click({ noWaitAfter: true }); 722 - await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 723 - timeout: 15000, 724 - }); 725 - await wait(3000); 726 - return { avatarFile, profileNote: config.profileNote }; 727 - }; 728 - 729 - try { 730 - await step('login', login); 731 - await step('age-assurance', completeAgeAssuranceIfNeeded, { optional: true }); 732 - await step('compose-own-post', () => composePost(config.postText)); 733 - if (config.publicChecks !== false) { 734 - await step('public-resolve-handle', verifyPublicHandleResolution); 735 - await step('public-profile', verifyPublicProfile); 736 - await step('public-author-feed', verifyPublicAuthorFeed); 737 - } 738 - await step('own-profile', () => gotoProfile(config.handle)); 739 - 740 - const ownPost = await step('find-own-post', async () => { 741 - await gotoProfile(config.handle); 742 - await page.getByTestId('postsFeed').first().waitFor({ state: 'visible', timeout: 60000 }); 743 - const row = await findRowByPrimaryText(config.postText, 60000); 744 - const rowTestId = await row.getAttribute('data-testid'); 745 - return { note: 'found own post', rowFound: true, rowTestId }; 746 - }); 747 - 748 - if (ownPost) { 749 - const row = await findRowByPrimaryText(config.postText); 750 - await step('like-own-post', () => ensureLiked(row), { optional: true }); 751 - await step('repost-own-post', () => ensureReposted(row), { optional: true }); 752 - await step('quote-own-post', () => clickQuote(row, config.quoteText), { optional: true }); 753 - await step('reply-own-post', async () => { 754 - await gotoProfile(config.handle); 755 - const refreshed = await findFeedItemByText(config.postText, 60000); 756 - await clickReply(refreshed, config.replyText); 757 - }, { optional: true }); 758 - await step('unlike-own-post', async () => { 759 - await gotoProfile(config.handle); 760 - const refreshed = await findRowByPrimaryText(config.postText, 60000); 761 - return ensureNotLiked(refreshed); 762 - }, { optional: true }); 763 - await step('undo-repost-own-post', async () => { 764 - await gotoProfile(config.handle); 765 - const refreshed = await findRowByPrimaryText(config.postText, 60000); 766 - return ensureNotReposted(refreshed); 767 - }, { optional: true }); 768 - } 769 - 770 - await step('target-profile', async () => { 771 - await gotoProfile(config.targetHandle); 772 - await waitForProfileHandle(config.targetHandle, 20000); 773 - }); 774 - await step('follow-target', maybeFollowTarget, { optional: true }); 775 - 776 - await step('inspect-target-post', async () => { 777 - const row = await findFirstFeedItem(20000); 778 - const preview = ((await row.textContent()) || '').replace(/\s+/g, ' ').slice(0, 160); 779 - return { note: preview }; 780 - }, { optional: true }); 781 - 782 - await step('bookmark-target-post', async () => { 783 - const row = await findFirstFeedItem(20000); 784 - return ensureBookmarked(row); 785 - }, { optional: true }); 786 - 787 - await step('saved-posts-page', async () => { 788 - await openSavedPosts(); 789 - const handleText = page.getByText(`@${config.targetHandle.replace(/^@/, '')}`).first(); 790 - await handleText.waitFor({ state: 'visible', timeout: 20000 }); 791 - return { note: `saved post by ${config.targetHandle}` }; 792 - }, { optional: true }); 793 - 794 - await step('like-target-post', async () => { 795 - await gotoProfile(config.targetHandle); 796 - const row = await findFirstFeedItem(20000); 797 - return ensureLiked(row); 798 - }, { optional: true }); 799 - 800 - await step('repost-target-post', async () => { 801 - await gotoProfile(config.targetHandle); 802 - const row = await findFirstFeedItem(20000); 803 - return ensureReposted(row); 804 - }, { optional: true }); 805 - 806 - await step('quote-target-post', async () => { 807 - await gotoProfile(config.targetHandle); 808 - const row = await findFirstFeedItem(20000); 809 - await clickQuote(row, `${config.quoteText} to @${config.targetHandle.replace(/^@/, '')}`); 810 - return { note: 'quoted target post' }; 811 - }, { optional: true }); 812 - 813 - await step('reply-target-post', async () => { 814 - await gotoProfile(config.targetHandle); 815 - const row = await findFirstFeedItem(20000); 816 - await clickReply(row, `${config.replyText} to @${config.targetHandle.replace(/^@/, '')}`); 817 - return { note: 'replied to target post' }; 818 - }, { optional: true }); 819 - 820 - await step('unlike-target-post', async () => { 821 - await gotoProfile(config.targetHandle); 822 - const row = await findFirstFeedItem(20000); 823 - return ensureNotLiked(row); 824 - }, { optional: true }); 825 - 826 - await step('undo-repost-target-post', async () => { 827 - await gotoProfile(config.targetHandle); 828 - const row = await findFirstFeedItem(20000); 829 - return ensureNotReposted(row); 830 - }, { optional: true }); 831 - 832 - await step('unbookmark-target-post', async () => { 833 - await gotoProfile(config.targetHandle); 834 - const row = await findFirstFeedItem(20000); 835 - return ensureNotBookmarked(row); 836 - }, { optional: true }); 837 - 838 - await step('unfollow-target', async () => { 839 - await gotoProfile(config.targetHandle); 840 - return maybeUnfollowTarget(); 841 - }, { optional: true }); 842 - 843 - await step('refollow-target', async () => { 844 - await gotoProfile(config.targetHandle); 845 - return maybeFollowTarget(); 846 - }, { optional: true }); 847 - 848 - await step('notifications-page', async () => { 849 - await openNotifications(); 850 - const tab = page.getByRole('tab', { name: /all|priority/i }).first(); 851 - if (await tab.count()) { 852 - await tab.waitFor({ state: 'visible', timeout: 15000 }); 853 - } 854 - return { note: 'notifications page loaded' }; 855 - }, { optional: true }); 856 - 857 - if (config.editProfile) { 858 - await step('edit-profile', async () => { 859 - await gotoProfile(config.handle); 860 - await editProfile(); 861 - }); 862 - await step('local-profile-after-edit', verifyLocalProfileAfterEdit); 863 - if (config.publicChecks !== false) { 864 - await step('public-profile-after-edit', verifyPublicProfileAfterEdit); 865 - } 866 - } 867 - 868 - await step('cleanup-own-posts-tab', async () => { 869 - await gotoProfile(config.handle); 870 - await openProfileTab('Posts'); 871 - return { note: 'opened own posts tab for cleanup' }; 872 - }, { optional: true }); 873 - 874 - await step('delete-own-target-quote', async () => { 875 - return maybeDeleteOwnPostByText( 876 - `${config.quoteText} to @${config.targetHandle.replace(/^@/, '')}`, 877 - 'deleted target quote post', 878 - ); 879 - }); 880 - 881 - await step('delete-own-quote-post', async () => { 882 - return maybeDeleteOwnPostByText(config.quoteText, 'deleted own quote post'); 883 - }); 884 - 885 - await step('delete-own-root-post', async () => { 886 - return maybeDeleteOwnPostByText(config.postText, 'deleted root smoke post'); 887 - }); 888 - 889 - await step('cleanup-own-replies-tab', async () => { 890 - await gotoProfile(config.handle); 891 - await openProfileTab('Replies'); 892 - return { note: 'opened own replies tab for cleanup' }; 893 - }, { optional: true }); 894 - 895 - await step('delete-own-target-reply', async () => { 896 - return maybeDeleteOwnPostByText( 897 - `${config.replyText} to @${config.targetHandle.replace(/^@/, '')}`, 898 - 'deleted target reply post', 899 - ); 900 - }); 901 - 902 - await step('delete-own-reply-post', async () => { 903 - return maybeDeleteOwnPostByText(config.replyText, 'deleted own reply post'); 904 - }); 905 - } catch (error) { 906 - summary.fatal = String(error?.message ?? error); 907 - } 908 - 909 - summary.finishedAt = new Date().toISOString(); 910 - summary.unexpected = { 911 - console: summary.console.filter((entry) => !isIgnoredConsole(entry)), 912 - requestFailures: summary.requestFailures.filter((entry) => !isIgnoredRequestFailure(entry)), 913 - httpFailures: summary.httpFailures.filter((entry) => !isIgnoredHttpFailure(entry)), 914 - pageErrors: summary.pageErrors, 915 - }; 916 - summary.unexpected.total = 917 - summary.unexpected.console.length + 918 - summary.unexpected.requestFailures.length + 919 - summary.unexpected.httpFailures.length + 920 - summary.unexpected.pageErrors.length; 921 - if (!summary.fatal && config.strictErrors !== false && summary.unexpected.total > 0) { 922 - summary.fatal = `Unexpected browser/runtime errors: ${summary.unexpected.total}`; 923 - } 924 - summary.ok = !summary.fatal; 925 - await screenshot('final').catch(() => undefined); 926 - await fs.writeFile( 927 - path.join(config.artifactsDir, 'summary.json'), 928 - JSON.stringify(summary, null, 2) + '\n', 929 - 'utf8', 930 - ); 931 - console.log(JSON.stringify(summary, null, 2)); 932 - await browser.close(); 933 - if (!summary.ok) { 934 - process.exitCode = 1; 935 - } 3 + const exitCode = await runSingleFromArgv(process.argv); 4 + process.exitCode = exitCode;