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.

Rename smoke package staging to atproto-smoke

alice e2745c84 20aa751b

+3939 -4
+1 -1
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 lives in `pds-smoke-suite/`, which owns the browser runtime, package CLI, example configs, and bring-your-own-account plus `perlsky` adapter helpers. 61 + - Extraction work toward a cross-PDS standalone package now lives in `atproto-smoke/`, which owns the browser runtime, package CLI, example configs, and bring-your-own-account plus `perlsky` adapter helpers. 62 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:
+111
atproto-smoke/README.md
··· 1 + # atproto-smoke 2 + 3 + This directory is the extraction staging area for a standalone `bsky.app` 4 + compatibility smoke suite that can be used by multiple PDS implementations, not 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. 8 + 9 + ## Current Scope 10 + 11 + The existing browser automation is already strong enough to be useful outside 12 + this repo: 13 + 14 + - reusable-account `bsky.app` smoke flows 15 + - post, image post, like, repost, quote, reply, bookmark, follow 16 + - list lifecycle 17 + - profile edit and avatar upload 18 + - notifications checks 19 + - settings-depth flows 20 + - strict artifacts with screenshots, console output, failed requests, failed 21 + HTTP responses, and recent XRPC traffic 22 + 23 + DMs are intentionally deferred for now. The current suite is focused on stable 24 + social, list, and settings interactions first. 25 + 26 + ## Extraction Shape 27 + 28 + The target standalone project shape is: 29 + 30 + 1. Generic core browser flows and artifact handling 31 + 2. A bring-your-own-accounts mode with minimal configuration 32 + 3. Thin per-PDS adapters for provisioning and implementation-specific defaults 33 + 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 atproto-smoke/bin/atproto-smoke.mjs print-example --mode dual 44 + node atproto-smoke/bin/atproto-smoke.mjs validate --mode dual --config atproto-smoke/examples/bring-your-own-dual.json 45 + node atproto-smoke/bin/atproto-smoke.mjs run-dual --config atproto-smoke/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` 53 + 54 + ## Minimal Configuration Goal 55 + 56 + The default experience for other PDS developers should be: 57 + 58 + - provide a `pdsUrl` 59 + - provide one or two existing account credentials 60 + - optionally provide a `targetHandle` 61 + - run the suite against `bsky.app` 62 + 63 + Provisioning is intentionally adapter-specific. That means `perlsky` can keep a 64 + helpful invite/bootstrap path, while other PDSes like `rsky` or `pegasus` can 65 + add their own adapters without changing the core browser flows. 66 + 67 + ## Current Adapter Contract 68 + 69 + The staging helpers in `src/` model two layers: 70 + 71 + - `adapters/bring-your-own.mjs` 72 + For the lowest-friction mode where callers supply existing credentials 73 + - `adapters/perlsky.mjs` 74 + For `perlsky`-specific defaults like cleanup prefixes and adapter tagging 75 + 76 + The current config contract is intentionally small: 77 + 78 + - suite-level settings: 79 + `pdsUrl`, `artifactsDir`, `appUrl`, `publicApiUrl`, `targetHandle`, 80 + `publicCheckTimeoutMs`, `headless`, `strictErrors`, `publicChecks`, 81 + `browserExecutablePath`, `adapter` 82 + - account-level settings: 83 + `handle`, `password`, `birthdate`, `postText`, `mediaPostText`, `quoteText`, 84 + `replyText`, `profileNote`, `cleanupPostPrefixes` 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 + 103 + ## Planned Next Steps 104 + 105 + - keep `script/perlsky-browser-smoke` as a thin `perlsky` adapter over this 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 110 + - revisit a JS-to-TS migration later, after the standalone package boundary is 111 + stable
+10
atproto-smoke/bin/atproto-smoke.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
atproto-smoke/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
atproto-smoke/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
atproto-smoke/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 + }
+17
atproto-smoke/package.json
··· 1 + { 2 + "name": "atproto-smoke", 3 + "private": true, 4 + "type": "module", 5 + "bin": { 6 + "atproto-smoke": "./bin/atproto-smoke.mjs" 7 + }, 8 + "exports": { 9 + ".": "./src/index.mjs", 10 + "./config": "./src/config.mjs", 11 + "./adapters/bring-your-own": "./src/adapters/bring-your-own.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" 16 + } 17 + }
+31
atproto-smoke/src/adapters/bring-your-own.mjs
··· 1 + import { 2 + createAccountConfig, 3 + createDualRunConfig, 4 + createSingleRunConfig, 5 + } from '../config.mjs'; 6 + 7 + export const createBringYourOwnAccount = (account = {}) => { 8 + return createAccountConfig(account); 9 + }; 10 + 11 + export const createBringYourOwnSingleConfig = ({ 12 + account, 13 + ...rest 14 + } = {}) => { 15 + return createSingleRunConfig({ 16 + ...rest, 17 + account: createBringYourOwnAccount(account), 18 + }); 19 + }; 20 + 21 + export const createBringYourOwnDualConfig = ({ 22 + primary, 23 + secondary, 24 + ...rest 25 + } = {}) => { 26 + return createDualRunConfig({ 27 + ...rest, 28 + primary: createBringYourOwnAccount(primary), 29 + secondary: createBringYourOwnAccount(secondary), 30 + }); 31 + };
+60
atproto-smoke/src/adapters/perlsky.mjs
··· 1 + import { 2 + createAccountConfig, 3 + createDualRunConfig, 4 + createSingleRunConfig, 5 + } from '../config.mjs'; 6 + 7 + export const PERLSKY_PRIMARY_CLEANUP_PREFIXES = Object.freeze([ 8 + 'perlsky browser smoke ', 9 + ]); 10 + 11 + export const PERLSKY_SECONDARY_CLEANUP_PREFIXES = Object.freeze([ 12 + 'perlsky browser secondary ', 13 + ]); 14 + 15 + export const createPerlskyAccountConfig = ({ 16 + role = 'primary', 17 + ...account 18 + } = {}) => { 19 + const cleanupPostPrefixes = role === 'secondary' 20 + ? PERLSKY_SECONDARY_CLEANUP_PREFIXES 21 + : PERLSKY_PRIMARY_CLEANUP_PREFIXES; 22 + 23 + return createAccountConfig({ 24 + cleanupPostPrefixes, 25 + ...account, 26 + }); 27 + }; 28 + 29 + export const createPerlskySingleConfig = ({ 30 + account, 31 + ...rest 32 + } = {}) => { 33 + return createSingleRunConfig({ 34 + ...rest, 35 + adapter: 'perlsky', 36 + account: createPerlskyAccountConfig({ 37 + role: 'primary', 38 + ...account, 39 + }), 40 + }); 41 + }; 42 + 43 + export const createPerlskyDualConfig = ({ 44 + primary, 45 + secondary, 46 + ...rest 47 + } = {}) => { 48 + return createDualRunConfig({ 49 + ...rest, 50 + adapter: 'perlsky', 51 + primary: createPerlskyAccountConfig({ 52 + role: 'primary', 53 + ...primary, 54 + }), 55 + secondary: createPerlskyAccountConfig({ 56 + role: 'secondary', 57 + ...secondary, 58 + }), 59 + }); 60 + };
+719
atproto-smoke/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 + };
+309
atproto-smoke/src/browser/lib/dual-api.mjs
··· 1 + export const createDualApiHelpers = ({ config }) => { 2 + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 3 + 4 + const fetchJson = async (url, options = {}) => { 5 + const timeoutMs = options.timeoutMs ?? 30000; 6 + const controller = new AbortController(); 7 + const timer = setTimeout(() => controller.abort(), timeoutMs); 8 + const fetchOptions = { 9 + ...options, 10 + signal: controller.signal, 11 + }; 12 + delete fetchOptions.timeoutMs; 13 + let res; 14 + try { 15 + res = await fetch(url, fetchOptions); 16 + } finally { 17 + clearTimeout(timer); 18 + } 19 + const text = await res.text(); 20 + let json; 21 + try { 22 + json = text ? JSON.parse(text) : null; 23 + } catch { 24 + json = null; 25 + } 26 + return { ok: res.ok, status: res.status, text, json }; 27 + }; 28 + 29 + const fetchStatus = async (url) => { 30 + const res = await fetch(url, { 31 + redirect: 'follow', 32 + }); 33 + return { ok: res.ok, status: res.status, url: res.url }; 34 + }; 35 + 36 + const xrpcJson = async (nsid, { method = 'GET', token, params, body, timeoutMs } = {}) => { 37 + const url = new URL(`${config.pdsUrl}/xrpc/${nsid}`); 38 + if (params) { 39 + for (const [key, value] of Object.entries(params)) { 40 + url.searchParams.set(key, value); 41 + } 42 + } 43 + const headers = { accept: 'application/json' }; 44 + if (token) { 45 + headers.authorization = `Bearer ${token}`; 46 + } 47 + if (body !== undefined) { 48 + headers['content-type'] = 'application/json'; 49 + } 50 + return fetchJson(url.toString(), { 51 + method, 52 + headers, 53 + timeoutMs, 54 + body: body === undefined ? undefined : JSON.stringify(body), 55 + }); 56 + }; 57 + 58 + const listOwnRecords = async (account, collection, limit = 100) => { 59 + const result = await xrpcJson('com.atproto.repo.listRecords', { 60 + token: account.accessJwt, 61 + params: { 62 + repo: account.did, 63 + collection, 64 + limit: String(limit), 65 + }, 66 + }); 67 + if (!result.ok) { 68 + throw new Error( 69 + `listRecords failed for ${account.handle} collection ${collection}: ${result.status} ${result.text}`, 70 + ); 71 + } 72 + return result.json?.records || []; 73 + }; 74 + 75 + const listOwnPosts = async (account, limit = 100) => 76 + listOwnRecords(account, 'app.bsky.feed.post', limit); 77 + 78 + const recordRkey = (recordOrUri) => { 79 + const uri = typeof recordOrUri === 'string' ? recordOrUri : recordOrUri?.uri; 80 + return uri?.split('/').pop(); 81 + }; 82 + 83 + const deleteOwnRecord = async (account, collection, record) => { 84 + const rkey = recordRkey(record); 85 + if (!rkey) { 86 + throw new Error(`unable to determine rkey for ${collection} on ${account.handle}`); 87 + } 88 + const result = await xrpcJson('com.atproto.repo.deleteRecord', { 89 + method: 'POST', 90 + token: account.accessJwt, 91 + body: { 92 + repo: account.did, 93 + collection, 94 + rkey, 95 + }, 96 + }); 97 + if (!result.ok) { 98 + throw new Error( 99 + `deleteRecord failed for ${account.handle} ${collection} ${rkey}: ${result.status} ${result.text}`, 100 + ); 101 + } 102 + return { rkey }; 103 + }; 104 + 105 + const purgeOwnRecords = async (account, collection, predicate, limit = 100) => { 106 + const records = await listOwnRecords(account, collection, limit); 107 + const doomed = records.filter(predicate); 108 + for (const record of doomed) { 109 + await deleteOwnRecord(account, collection, record); 110 + await sleep(250); 111 + } 112 + return doomed.length; 113 + }; 114 + 115 + const waitForOwnRecord = async (account, collection, predicate, timeoutMs = 60000) => { 116 + const started = Date.now(); 117 + while (Date.now() - started < timeoutMs) { 118 + const records = await listOwnRecords(account, collection); 119 + const match = records.find(predicate); 120 + if (match) { 121 + return match; 122 + } 123 + await sleep(2000); 124 + } 125 + throw new Error(`record not observed for ${account.handle} in ${collection}`); 126 + }; 127 + 128 + const waitForOwnPostRecord = async (account, text, timeoutMs = 60000) => { 129 + return waitForOwnRecord( 130 + account, 131 + 'app.bsky.feed.post', 132 + (record) => record?.value?.text === text, 133 + timeoutMs, 134 + ); 135 + }; 136 + 137 + const waitForFollowRecord = async (account, subjectDid, timeoutMs = 60000) => 138 + waitForOwnRecord( 139 + account, 140 + 'app.bsky.graph.follow', 141 + (record) => record?.value?.subject === subjectDid, 142 + timeoutMs, 143 + ); 144 + 145 + const waitForNoOwnRecord = async (account, collection, predicate, timeoutMs = 60000) => { 146 + const started = Date.now(); 147 + while (Date.now() - started < timeoutMs) { 148 + const records = await listOwnRecords(account, collection); 149 + if (!records.find(predicate)) { 150 + return true; 151 + } 152 + await sleep(2000); 153 + } 154 + throw new Error(`record still present for ${account.handle} in ${collection}`); 155 + }; 156 + 157 + const waitForOwnListRecord = async (account, name, timeoutMs = 60000) => 158 + waitForOwnRecord( 159 + account, 160 + 'app.bsky.graph.list', 161 + (record) => record?.value?.name === name, 162 + timeoutMs, 163 + ); 164 + 165 + const waitForOwnListItemRecord = async (account, listUri, subjectDid, timeoutMs = 60000) => 166 + waitForOwnRecord( 167 + account, 168 + 'app.bsky.graph.listitem', 169 + (record) => record?.value?.list === listUri && record?.value?.subject === subjectDid, 170 + timeoutMs, 171 + ); 172 + 173 + const createSession = async (handle, password) => { 174 + const result = await xrpcJson('com.atproto.server.createSession', { 175 + method: 'POST', 176 + body: { 177 + identifier: handle, 178 + password, 179 + }, 180 + }); 181 + if (!result.ok) { 182 + throw new Error(`createSession failed for ${handle}: ${result.status} ${result.text}`); 183 + } 184 + return result.json; 185 + }; 186 + 187 + const pollNotifications = async ({ 188 + account, 189 + authorHandle, 190 + reasons, 191 + minIndexedAt, 192 + timeoutMs = 180000, 193 + }) => { 194 + const started = Date.now(); 195 + let last; 196 + while (Date.now() - started < timeoutMs) { 197 + last = await xrpcJson('app.bsky.notification.listNotifications', { 198 + token: account.accessJwt, 199 + params: { limit: '100' }, 200 + timeoutMs: 15000, 201 + }); 202 + if (last.ok && Array.isArray(last.json?.notifications)) { 203 + const matching = last.json.notifications.filter((item) => { 204 + if (item?.author?.handle !== authorHandle) { 205 + return false; 206 + } 207 + const indexedAt = Date.parse(item?.indexedAt || item?.record?.createdAt || 0); 208 + if (Number.isFinite(minIndexedAt) && indexedAt < minIndexedAt) { 209 + return false; 210 + } 211 + return reasons.includes(item?.reason); 212 + }); 213 + const seenReasons = new Set(matching.map((item) => item.reason)); 214 + if (reasons.every((reason) => seenReasons.has(reason))) { 215 + return { 216 + notifications: matching, 217 + allNotifications: last.json.notifications.slice(0, 12), 218 + }; 219 + } 220 + } 221 + await sleep(5000); 222 + } 223 + throw new Error( 224 + `notifications not observed for ${account.handle} within ${timeoutMs}ms; last status=${last?.status ?? 'none'} body=${last?.text ?? ''}`, 225 + ); 226 + }; 227 + 228 + const accountFromConfig = (entry) => ({ 229 + ...entry, 230 + mediaPostText: entry.mediaPostText || `${entry.postText} image`, 231 + shortHandle: entry.handle.replace(/^@/, ''), 232 + }); 233 + 234 + const prepareAccounts = ({ primaryConfig, secondaryConfig, startedAt }) => { 235 + const runToken = startedAt.replace(/\D/g, '').slice(0, 14); 236 + const primary = accountFromConfig({ 237 + ...primaryConfig, 238 + listName: primaryConfig.listName || `Smoke List ${runToken}`, 239 + listDescription: primaryConfig.listDescription || `smoke list description ${runToken}`, 240 + listUpdatedName: primaryConfig.listUpdatedName || `Updated Smoke List ${runToken}`, 241 + listUpdatedDescription: 242 + primaryConfig.listUpdatedDescription || `updated smoke list description ${runToken}`, 243 + }); 244 + const secondary = accountFromConfig(secondaryConfig); 245 + return { primary, secondary }; 246 + }; 247 + 248 + const stalePostPrefixesFor = (account) => { 249 + if (Array.isArray(account.cleanupPostPrefixes) && account.cleanupPostPrefixes.length) { 250 + return account.cleanupPostPrefixes; 251 + } 252 + if (/secondary/i.test(account.postText || '')) { 253 + return ['perlsky browser secondary ']; 254 + } 255 + return ['perlsky browser smoke ']; 256 + }; 257 + 258 + const staleListPrefixes = ['Smoke List ', 'Updated Smoke List ']; 259 + 260 + const cleanupStaleSmokeArtifacts = async (account) => { 261 + const postPrefixes = stalePostPrefixesFor(account); 262 + const deletedPosts = await purgeOwnRecords( 263 + account, 264 + 'app.bsky.feed.post', 265 + (record) => postPrefixes.some((prefix) => (record?.value?.text || '').startsWith(prefix)), 266 + ); 267 + const lists = await listOwnRecords(account, 'app.bsky.graph.list', 100); 268 + const doomedLists = lists.filter((record) => 269 + staleListPrefixes.some((prefix) => (record?.value?.name || '').startsWith(prefix)), 270 + ); 271 + const doomedListUris = new Set(doomedLists.map((record) => record.uri)); 272 + const deletedListItems = doomedListUris.size 273 + ? await purgeOwnRecords( 274 + account, 275 + 'app.bsky.graph.listitem', 276 + (record) => doomedListUris.has(record?.value?.list), 277 + ) 278 + : 0; 279 + let deletedLists = 0; 280 + for (const record of doomedLists) { 281 + await deleteOwnRecord(account, 'app.bsky.graph.list', record); 282 + deletedLists += 1; 283 + await sleep(250); 284 + } 285 + return { deletedPosts, deletedListItems, deletedLists }; 286 + }; 287 + 288 + return { 289 + fetchJson, 290 + fetchStatus, 291 + xrpcJson, 292 + listOwnRecords, 293 + listOwnPosts, 294 + deleteOwnRecord, 295 + purgeOwnRecords, 296 + waitForOwnRecord, 297 + waitForOwnPostRecord, 298 + waitForFollowRecord, 299 + waitForNoOwnRecord, 300 + waitForOwnListRecord, 301 + waitForOwnListItemRecord, 302 + recordRkey, 303 + createSession, 304 + pollNotifications, 305 + accountFromConfig, 306 + prepareAccounts, 307 + cleanupStaleSmokeArtifacts, 308 + }; 309 + };
+254
atproto-smoke/src/browser/lib/dual-browser.mjs
··· 1 + import fs from 'node:fs/promises'; 2 + import path from 'node:path'; 3 + import { chromium } from './playwright-runtime.mjs'; 4 + 5 + const ignoredConsole = [ 6 + /events\.bsky\.app\/.*ERR_BLOCKED_BY_CLIENT/i, 7 + /slider-vertical/i, 8 + /Password field is not contained in a form/i, 9 + /Failed to load resource: the server responded with a status of 400 \(\)/i, 10 + ]; 11 + 12 + const ignoredRequestFailure = [ 13 + { url: /events\.bsky\.app\//i, error: /ERR_(BLOCKED_BY_CLIENT|ABORTED)/i }, 14 + { url: /workers\.dev\/api\/config/i, error: /ERR_ABORTED/i }, 15 + { url: /app-config\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 16 + { url: /live-events\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 17 + { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 18 + { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 19 + { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts|.*\.vtt)/i, error: /ERR_ABORTED/i }, 20 + { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 21 + { url: /\/xrpc\/app\.bsky\.graph\.(?:muteActor|unmuteActor)/i, error: /ERR_ABORTED/i }, 22 + ]; 23 + 24 + const ignoredHttpFailure = [ 25 + { url: /c\.1password\.com\/richicons/i, status: 404 }, 26 + { url: /\/xrpc\/app\.bsky\.graph\.getList\?/, status: 400 }, 27 + ]; 28 + 29 + const browserCandidates = async (config) => { 30 + const base = { 31 + headless: config.headless !== false, 32 + chromiumSandbox: true, 33 + }; 34 + const candidates = []; 35 + if (config.browserExecutablePath) { 36 + candidates.push({ 37 + label: `executable:${config.browserExecutablePath}`, 38 + options: { ...base, executablePath: config.browserExecutablePath }, 39 + }); 40 + } 41 + const systemChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 42 + if (!config.browserExecutablePath) { 43 + try { 44 + await fs.access(systemChrome); 45 + candidates.push({ 46 + label: 'system-google-chrome', 47 + options: { ...base, executablePath: systemChrome }, 48 + }); 49 + } catch { 50 + // Fall back to Playwright-managed Chromium below. 51 + } 52 + } 53 + candidates.push({ 54 + label: 'playwright-chromium', 55 + options: { ...base, channel: 'chromium' }, 56 + }); 57 + return candidates; 58 + }; 59 + 60 + const launchBrowser = async (config, summary) => { 61 + const errors = []; 62 + for (const candidate of await browserCandidates(config)) { 63 + try { 64 + const browser = await chromium.launch(candidate.options); 65 + summary.notes.push(`browser launch candidate succeeded: ${candidate.label}`); 66 + return browser; 67 + } catch (error) { 68 + errors.push(`${candidate.label}: ${String(error?.message ?? error)}`); 69 + } 70 + } 71 + throw new Error(`unable to launch browser via any candidate: ${errors.join(' | ')}`); 72 + }; 73 + 74 + const attachPageLogging = (summary, name, page) => { 75 + page.on('console', (msg) => { 76 + summary.console.push({ 77 + page: name, 78 + type: msg.type(), 79 + text: msg.text(), 80 + }); 81 + }); 82 + 83 + page.on('pageerror', (error) => { 84 + summary.pageErrors.push({ 85 + page: name, 86 + message: String(error?.message ?? error), 87 + stack: error?.stack, 88 + }); 89 + }); 90 + 91 + page.on('requestfailed', (req) => { 92 + summary.requestFailures.push({ 93 + page: name, 94 + url: req.url(), 95 + method: req.method(), 96 + errorText: req.failure()?.errorText ?? 'unknown', 97 + }); 98 + }); 99 + 100 + page.on('response', (res) => { 101 + const status = res.status(); 102 + if (res.url().includes('/xrpc/')) { 103 + summary.xrpc.push({ 104 + page: name, 105 + url: res.url(), 106 + status, 107 + method: res.request().method(), 108 + }); 109 + if (summary.xrpc.length > 300) { 110 + summary.xrpc.shift(); 111 + } 112 + } 113 + if (status >= 400) { 114 + summary.httpFailures.push({ 115 + page: name, 116 + url: res.url(), 117 + status, 118 + method: res.request().method(), 119 + }); 120 + } 121 + }); 122 + }; 123 + 124 + export const setupDualBrowser = async ({ config, summary }) => { 125 + const browser = await launchBrowser(config, summary); 126 + const primaryContext = await browser.newContext({ 127 + viewport: { width: 1440, height: 1000 }, 128 + }); 129 + const secondaryContext = await browser.newContext({ 130 + viewport: { width: 1440, height: 1000 }, 131 + }); 132 + const primaryPage = await primaryContext.newPage(); 133 + const secondaryPage = await secondaryContext.newPage(); 134 + 135 + attachPageLogging(summary, 'primary', primaryPage); 136 + attachPageLogging(summary, 'secondary', secondaryPage); 137 + 138 + return { 139 + browser, 140 + primaryContext, 141 + secondaryContext, 142 + primaryPage, 143 + secondaryPage, 144 + }; 145 + }; 146 + 147 + export const createDualStepHelpers = ({ config, summary, primaryPage, secondaryPage }) => { 148 + const pageFor = (name) => (name === 'primary' ? primaryPage : secondaryPage); 149 + 150 + const screenshot = async (pageName, name) => { 151 + const page = pageFor(pageName); 152 + const file = path.join(config.artifactsDir, `${name}-${pageName}.png`); 153 + await page.screenshot({ path: file, fullPage: true }); 154 + return file; 155 + }; 156 + 157 + const recordStep = (name, status, extra = {}) => { 158 + summary.steps.push({ 159 + name, 160 + status, 161 + at: new Date().toISOString(), 162 + ...extra, 163 + }); 164 + }; 165 + 166 + const normalizeText = (text) => (text || '').replace(/\s+/g, ' ').trim(); 167 + 168 + const isIgnoredConsole = (entry) => 169 + ignoredConsole.some((pattern) => pattern.test(entry.text || '')); 170 + 171 + const isIgnoredRequestFailure = (entry) => 172 + ignoredRequestFailure.some( 173 + (rule) => rule.url.test(entry.url || '') && rule.error.test(entry.errorText || ''), 174 + ); 175 + 176 + const isIgnoredHttpFailure = (entry) => 177 + ignoredHttpFailure.some( 178 + (rule) => rule.url.test(entry.url || '') && (!rule.status || rule.status === entry.status), 179 + ); 180 + 181 + const step = async (name, fn, { optional = false, pageNames = [] } = {}) => { 182 + try { 183 + const result = await fn(); 184 + const screenshots = {}; 185 + for (const pageName of pageNames) { 186 + screenshots[pageName] = await screenshot(pageName, name); 187 + } 188 + recordStep(name, 'ok', { screenshots, ...(result ?? {}) }); 189 + return result; 190 + } catch (error) { 191 + const screenshots = {}; 192 + for (const pageName of pageNames) { 193 + screenshots[pageName] = await screenshot(pageName, `${name}-error`).catch(() => undefined); 194 + } 195 + recordStep(name, optional ? 'skipped' : 'failed', { 196 + screenshots, 197 + error: String(error?.message ?? error), 198 + }); 199 + if (!optional) { 200 + throw error; 201 + } 202 + return null; 203 + } 204 + }; 205 + 206 + const wait = async (page, ms) => { 207 + await page.waitForTimeout(ms); 208 + }; 209 + 210 + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 211 + 212 + const buttonText = async (locator) => { 213 + const label = await locator.getAttribute('aria-label'); 214 + if (label && label.trim()) { 215 + return label.trim(); 216 + } 217 + const text = await locator.innerText().catch(() => ''); 218 + return text.trim(); 219 + }; 220 + 221 + const dismissBlockingOverlays = async (page) => { 222 + const backdrop = page.locator('[aria-label*="click to close"]').last(); 223 + if (await backdrop.count()) { 224 + await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 225 + await wait(page, 400); 226 + } 227 + 228 + const dialog = page.locator('[role="dialog"][aria-modal="true"]').last(); 229 + if (await dialog.count()) { 230 + const close = dialog.getByRole('button', { name: /close/i }).last(); 231 + if (await close.count()) { 232 + await close.click({ noWaitAfter: true }).catch(() => undefined); 233 + await wait(page, 400); 234 + } 235 + await page.keyboard.press('Escape').catch(() => undefined); 236 + await wait(page, 400); 237 + } 238 + }; 239 + 240 + return { 241 + pageFor, 242 + screenshot, 243 + recordStep, 244 + normalizeText, 245 + isIgnoredConsole, 246 + isIgnoredRequestFailure, 247 + isIgnoredHttpFailure, 248 + step, 249 + wait, 250 + sleep, 251 + buttonText, 252 + dismissBlockingOverlays, 253 + }; 254 + };
+470
atproto-smoke/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 + };
+179
atproto-smoke/src/browser/lib/lists.mjs
··· 1 + export const createListHelpers = ({ appBaseUrl, wait }) => { 2 + const openLists = async (page) => { 3 + await page.goto(`${appBaseUrl}/lists`, { 4 + waitUntil: 'domcontentloaded', 5 + timeout: 60000, 6 + }); 7 + await wait(page, 3000); 8 + const newList = page.getByTestId('newUserListBtn').first(); 9 + if (await newList.count()) { 10 + await newList.waitFor({ state: 'visible', timeout: 15000 }); 11 + } 12 + }; 13 + 14 + const openListPage = async (page, handle, listRkey) => { 15 + await page.goto( 16 + `${appBaseUrl}/profile/${encodeURIComponent(handle)}/lists/${encodeURIComponent(listRkey)}`, 17 + { 18 + waitUntil: 'domcontentloaded', 19 + timeout: 60000, 20 + }, 21 + ); 22 + await wait(page, 3000); 23 + }; 24 + 25 + const waitForListTitle = async (page, title, timeout = 20000) => { 26 + await page.getByText(title, { exact: true }).first().waitFor({ state: 'visible', timeout }); 27 + }; 28 + 29 + const fillListEditor = async (page, name, description) => { 30 + const dialog = page.locator('[role="dialog"]').last(); 31 + await dialog.waitFor({ state: 'visible', timeout: 15000 }); 32 + await dialog.getByTestId('editListNameInput').fill(name); 33 + await dialog.getByTestId('editListDescriptionInput').fill(description); 34 + return dialog; 35 + }; 36 + 37 + const saveListEditor = async (page) => { 38 + const dialog = page.locator('[role="dialog"]').last(); 39 + const save = dialog.getByTestId('editProfileSaveBtn').last(); 40 + await save.waitFor({ state: 'visible', timeout: 15000 }); 41 + await page.waitForFunction(() => { 42 + const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 43 + return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 44 + }, undefined, { timeout: 15000 }); 45 + await save.click({ noWaitAfter: true }); 46 + await dialog.waitFor({ state: 'hidden', timeout: 20000 }); 47 + await wait(page, 3000); 48 + }; 49 + 50 + const createList = async (page, name, description) => { 51 + await openLists(page); 52 + await page.getByTestId('newUserListBtn').first().click({ noWaitAfter: true }); 53 + await wait(page, 1000); 54 + await fillListEditor(page, name, description); 55 + await saveListEditor(page); 56 + await wait(page, 3000); 57 + return { url: page.url() }; 58 + }; 59 + 60 + const openCurrentListOptions = async (page) => { 61 + const btn = page.getByTestId('moreOptionsBtn').first(); 62 + await btn.waitFor({ state: 'visible', timeout: 15000 }); 63 + await btn.click({ noWaitAfter: true }); 64 + const menu = page.locator('[role="menu"]').last(); 65 + await menu.waitFor({ state: 'visible', timeout: 10000 }); 66 + return menu; 67 + }; 68 + 69 + const editCurrentList = async (page, name, description) => { 70 + await openCurrentListOptions(page); 71 + await page.getByRole('menuitem', { name: /edit list details/i }).click({ noWaitAfter: true }); 72 + await wait(page, 800); 73 + await fillListEditor(page, name, description); 74 + await saveListEditor(page); 75 + await wait(page, 2000); 76 + return { listName: name, listDescription: description }; 77 + }; 78 + 79 + const deleteCurrentList = async (page) => { 80 + const beforeUrl = page.url(); 81 + await openCurrentListOptions(page); 82 + await page.getByRole('menuitem', { name: /delete list/i }).click({ noWaitAfter: true }); 83 + const dialog = page.locator('[role="dialog"]').last(); 84 + await dialog.waitFor({ state: 'visible', timeout: 15000 }); 85 + const confirm = dialog.getByRole('button', { name: /^delete$/i }).last(); 86 + await confirm.click({ noWaitAfter: true }); 87 + await dialog.waitFor({ state: 'hidden', timeout: 20000 }); 88 + await page.waitForFunction( 89 + (url) => window.location.href !== url && !/\/lists\/[^/?#]+/.test(window.location.pathname), 90 + beforeUrl, 91 + { timeout: 20000 }, 92 + ); 93 + await wait(page, 3000); 94 + return { url: page.url() }; 95 + }; 96 + 97 + const openListPeopleTab = async (page) => { 98 + await page.getByRole('tab', { name: /^People$/i }).click({ noWaitAfter: true }); 99 + await wait(page, 1500); 100 + }; 101 + 102 + const openAddPeopleToList = async (page) => { 103 + await openListPeopleTab(page); 104 + const add = page.getByRole('button', { name: /start adding people|add people/i }).last(); 105 + await add.waitFor({ state: 'visible', timeout: 15000 }); 106 + await add.click({ noWaitAfter: true }); 107 + await page.getByText(/^Add people to list$/i).last().waitFor({ state: 'visible', timeout: 15000 }); 108 + await wait(page, 1000); 109 + }; 110 + 111 + const closeAddPeopleToList = async (page) => { 112 + const close = page.getByRole('button', { name: /^close$/i }).last(); 113 + if (await close.count()) { 114 + await close.click({ noWaitAfter: true }).catch(() => undefined); 115 + } else { 116 + await page.keyboard.press('Escape').catch(() => undefined); 117 + } 118 + await wait(page, 1000); 119 + }; 120 + 121 + const searchAddPeopleList = async (page, handle) => { 122 + const search = page.getByPlaceholder('Search').last(); 123 + await search.fill(handle.replace(/^@/, '')); 124 + await wait(page, 2500); 125 + await page.getByText(`@${handle.replace(/^@/, '')}`).last().waitFor({ state: 'visible', timeout: 15000 }); 126 + }; 127 + 128 + const addUserToCurrentList = async (page, handle) => { 129 + await openAddPeopleToList(page); 130 + await searchAddPeopleList(page, handle); 131 + const add = page.getByRole('button', { name: /add user to list/i }).last(); 132 + await add.click({ noWaitAfter: true }); 133 + await wait(page, 2000); 134 + const remove = page.getByRole('button', { name: /remove user from list/i }).last(); 135 + await remove.waitFor({ state: 'visible', timeout: 15000 }); 136 + await closeAddPeopleToList(page); 137 + await page.getByText(`@${handle.replace(/^@/, '')}`).first().waitFor({ state: 'visible', timeout: 15000 }); 138 + return { handle }; 139 + }; 140 + 141 + const removeUserFromCurrentList = async (page, handle) => { 142 + await openListPeopleTab(page); 143 + await page.getByText(`@${handle.replace(/^@/, '')}`).first().waitFor({ state: 'visible', timeout: 15000 }); 144 + const edit = page.getByTestId(`user-${handle}-editBtn`).first(); 145 + await edit.waitFor({ state: 'visible', timeout: 15000 }); 146 + await edit.click({ noWaitAfter: true }); 147 + await wait(page, 1000); 148 + let remove = page.getByTestId(`user-${handle}-addBtn`).first(); 149 + if (!(await remove.count())) { 150 + remove = page.getByRole('button', { name: /^remove$/i }).last(); 151 + } 152 + await remove.click({ noWaitAfter: true }); 153 + await wait(page, 2000); 154 + const done = page.getByRole('button', { name: /^done$/i }).last(); 155 + if (await done.count()) { 156 + await done.click({ noWaitAfter: true }); 157 + await wait(page, 1000); 158 + } 159 + return { handle }; 160 + }; 161 + 162 + return { 163 + openLists, 164 + openListPage, 165 + waitForListTitle, 166 + fillListEditor, 167 + saveListEditor, 168 + createList, 169 + openCurrentListOptions, 170 + editCurrentList, 171 + deleteCurrentList, 172 + openListPeopleTab, 173 + openAddPeopleToList, 174 + closeAddPeopleToList, 175 + searchAddPeopleList, 176 + addUserToCurrentList, 177 + removeUserFromCurrentList, 178 + }; 179 + };
+9
atproto-smoke/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;
+57
atproto-smoke/src/browser/lib/settings.mjs
··· 1 + export const createSettingsHelpers = ({ appBaseUrl, wait }) => { 2 + const openSettingRoute = async (page, route) => { 3 + await page.goto(`${appBaseUrl}${route}`, { 4 + waitUntil: 'domcontentloaded', 5 + timeout: 60000, 6 + }); 7 + await wait(page, 3000); 8 + }; 9 + 10 + const roleSetting = (page, role, name) => page.getByRole(role, { name }).first(); 11 + 12 + const settingState = async (page, role, name) => { 13 + const locator = roleSetting(page, role, name); 14 + await locator.waitFor({ state: 'visible', timeout: 15000 }); 15 + return (await locator.getAttribute('aria-checked')) === 'true'; 16 + }; 17 + 18 + const setCheckboxSetting = async (page, route, name, desired) => { 19 + await openSettingRoute(page, route); 20 + const locator = roleSetting(page, 'checkbox', name); 21 + const current = await settingState(page, 'checkbox', name); 22 + if (current !== desired) { 23 + await locator.click({ noWaitAfter: true }); 24 + await wait(page, 2000); 25 + } 26 + await openSettingRoute(page, route); 27 + const verified = await settingState(page, 'checkbox', name); 28 + if (verified !== desired) { 29 + throw new Error(`checkbox setting ${name} on ${route} expected ${desired} but saw ${verified}`); 30 + } 31 + return { desired, verified }; 32 + }; 33 + 34 + const setRadioSetting = async (page, route, name) => { 35 + await openSettingRoute(page, route); 36 + const locator = roleSetting(page, 'radio', name); 37 + const current = await settingState(page, 'radio', name); 38 + if (!current) { 39 + await locator.click({ noWaitAfter: true }); 40 + await wait(page, 2000); 41 + } 42 + await openSettingRoute(page, route); 43 + const verified = await settingState(page, 'radio', name); 44 + if (!verified) { 45 + throw new Error(`radio setting ${name} on ${route} did not persist`); 46 + } 47 + return { selected: name }; 48 + }; 49 + 50 + return { 51 + openSettingRoute, 52 + roleSetting, 53 + settingState, 54 + setCheckboxSetting, 55 + setRadioSetting, 56 + }; 57 + };
+517
atproto-smoke/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
atproto-smoke/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
atproto-smoke/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
atproto-smoke/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
atproto-smoke/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 + atproto-smoke run-single [--adapter bring-your-own|perlsky] --config config.json 13 + atproto-smoke run-dual [--adapter bring-your-own|perlsky] --config config.json 14 + atproto-smoke validate --mode single|dual [--adapter bring-your-own|perlsky] --config config.json 15 + atproto-smoke 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 + };
+182
atproto-smoke/src/config.mjs
··· 1 + const DEFAULTS = { 2 + appUrl: 'https://bsky.app', 3 + publicApiUrl: 'https://public.api.bsky.app', 4 + publicCheckTimeoutMs: 180000, 5 + birthdate: '1990-01-01', 6 + headless: true, 7 + strictErrors: false, 8 + publicChecks: true, 9 + }; 10 + 11 + const requireString = (value, label) => { 12 + if (typeof value !== 'string' || value.trim() === '') { 13 + throw new Error(`${label} is required`); 14 + } 15 + return value; 16 + }; 17 + 18 + const optionalString = (value) => { 19 + if (value === undefined || value === null) { 20 + return undefined; 21 + } 22 + if (typeof value !== 'string') { 23 + throw new Error('optional string values must be strings when provided'); 24 + } 25 + const trimmed = value.trim(); 26 + return trimmed === '' ? undefined : trimmed; 27 + }; 28 + 29 + const normalizeCleanupPrefixes = (prefixes) => { 30 + if (prefixes === undefined) { 31 + return []; 32 + } 33 + if (!Array.isArray(prefixes)) { 34 + throw new Error('cleanupPostPrefixes must be an array when provided'); 35 + } 36 + return prefixes 37 + .map((value) => { 38 + if (typeof value !== 'string') { 39 + throw new Error('cleanup post prefixes must be strings'); 40 + } 41 + return value.length ? value : undefined; 42 + }) 43 + .filter(Boolean); 44 + }; 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 + 55 + export const createAccountConfig = ({ 56 + handle, 57 + password, 58 + birthdate = DEFAULTS.birthdate, 59 + postText, 60 + mediaPostText, 61 + quoteText, 62 + replyText, 63 + profileNote, 64 + cleanupPostPrefixes, 65 + ...rest 66 + } = {}) => { 67 + const normalized = { 68 + handle: requireString(handle, 'account.handle'), 69 + password: requireString(password, 'account.password'), 70 + birthdate: optionalString(birthdate) || DEFAULTS.birthdate, 71 + cleanupPostPrefixes: normalizeCleanupPrefixes(cleanupPostPrefixes), 72 + ...rest, 73 + }; 74 + 75 + const post = optionalString(postText); 76 + const mediaPost = optionalString(mediaPostText); 77 + const quote = optionalString(quoteText); 78 + const reply = optionalString(replyText); 79 + const note = optionalString(profileNote); 80 + 81 + if (post) { 82 + normalized.postText = post; 83 + } 84 + if (mediaPost) { 85 + normalized.mediaPostText = mediaPost; 86 + } 87 + if (quote) { 88 + normalized.quoteText = quote; 89 + } 90 + if (reply) { 91 + normalized.replyText = reply; 92 + } 93 + if (note) { 94 + normalized.profileNote = note; 95 + } 96 + 97 + return normalized; 98 + }; 99 + 100 + export const createSuiteConfig = ({ 101 + pdsUrl, 102 + pdsHost, 103 + artifactsDir, 104 + appUrl = DEFAULTS.appUrl, 105 + publicApiUrl = DEFAULTS.publicApiUrl, 106 + publicCheckTimeoutMs = DEFAULTS.publicCheckTimeoutMs, 107 + targetHandle, 108 + headless = DEFAULTS.headless, 109 + strictErrors = DEFAULTS.strictErrors, 110 + publicChecks = DEFAULTS.publicChecks, 111 + browserExecutablePath, 112 + adapter, 113 + ...rest 114 + } = {}) => { 115 + const normalized = { 116 + pdsUrl: requireString(pdsUrl, 'pdsUrl'), 117 + artifactsDir: requireString(artifactsDir, 'artifactsDir'), 118 + appUrl: optionalString(appUrl) || DEFAULTS.appUrl, 119 + publicApiUrl: optionalString(publicApiUrl) || DEFAULTS.publicApiUrl, 120 + publicCheckTimeoutMs: Number(publicCheckTimeoutMs || DEFAULTS.publicCheckTimeoutMs), 121 + headless: !!headless, 122 + strictErrors: !!strictErrors, 123 + publicChecks: !!publicChecks, 124 + ...rest, 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 + } 131 + 132 + const maybeTarget = optionalString(targetHandle); 133 + if (maybeTarget) { 134 + normalized.targetHandle = maybeTarget; 135 + } 136 + 137 + const maybeBrowserExecutablePath = optionalString(browserExecutablePath); 138 + if (maybeBrowserExecutablePath) { 139 + normalized.browserExecutablePath = maybeBrowserExecutablePath; 140 + } 141 + 142 + const maybeAdapter = optionalString(adapter); 143 + if (maybeAdapter) { 144 + normalized.adapter = maybeAdapter; 145 + } 146 + 147 + return normalized; 148 + }; 149 + 150 + export const createSingleRunConfig = ({ 151 + account, 152 + editProfile = false, 153 + ...rest 154 + } = {}) => { 155 + return { 156 + ...createSuiteConfig(rest), 157 + ...createAccountConfig(account), 158 + editProfile: !!editProfile, 159 + }; 160 + }; 161 + 162 + export const createDualRunConfig = ({ 163 + primary, 164 + secondary, 165 + accountSource, 166 + ...rest 167 + } = {}) => { 168 + const normalized = { 169 + ...createSuiteConfig(rest), 170 + primary: createAccountConfig(primary), 171 + secondary: createAccountConfig(secondary), 172 + }; 173 + 174 + const maybeAccountSource = optionalString(accountSource); 175 + if (maybeAccountSource) { 176 + normalized.accountSource = maybeAccountSource; 177 + } 178 + 179 + return normalized; 180 + }; 181 + 182 + export const suiteDefaults = Object.freeze({ ...DEFAULTS });
+6
atproto-smoke/src/index.mjs
··· 1 + export * from './config.mjs'; 2 + export * from './adapters/bring-your-own.mjs'; 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';
+1 -1
docs/BROWSER_SMOKE.md
··· 86 86 87 87 ## Extraction 88 88 89 - The generic runtime now lives under [pds-smoke-suite](../pds-smoke-suite/README.md). 89 + The generic runtime now lives under [atproto-smoke](../atproto-smoke/README.md). 90 90 `perlsky` still keeps [script/perlsky-browser-smoke](/Users/sarah/src/tries/2026-03-10-perlds/script/perlsky-browser-smoke) 91 91 as the ergonomic adapter for this repo, but the standalone package now owns: 92 92
+1 -1
tools/browser-automation/dual-smoke.mjs
··· 1 - import { runDualFromArgv } from '../../pds-smoke-suite/src/browser/run-dual.mjs'; 1 + import { runDualFromArgv } from '../../atproto-smoke/src/browser/run-dual.mjs'; 2 2 3 3 const exitCode = await runDualFromArgv(process.argv); 4 4 process.exitCode = exitCode;
+1 -1
tools/browser-automation/smoke.mjs
··· 1 - import { runSingleFromArgv } from '../../pds-smoke-suite/src/browser/run-single.mjs'; 1 + import { runSingleFromArgv } from '../../atproto-smoke/src/browser/run-single.mjs'; 2 2 3 3 const exitCode = await runSingleFromArgv(process.argv); 4 4 process.exitCode = exitCode;