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.

Consume standalone atproto-smoke from perlsky

alice 0f96f06d 22b32ad3

+55 -3990
+3 -2
README.md
··· 58 58 - DMs are intentionally deferred from the current browser-smoke tranche; see `docs/BROWSER_SMOKE.md` for current scope and rationale. 59 59 - Fresh-account creation is still available through the explicit `bootstrap-*` commands, but it is no longer the normal path for repeated browser smoke runs. 60 60 - Detailed browser-smoke workflow, current interaction coverage, and the env-gated `prove` wrapper live in `docs/BROWSER_SMOKE.md`. 61 - - Extraction work toward a cross-PDS standalone package now lives in `atproto-smoke/`, which owns the browser runtime, package CLI, example configs, and bring-your-own-account plus `perlsky` adapter helpers. 62 - - `script/perlsky-browser-smoke` now prefers an external sibling checkout at `../atproto-smoke` when present, and falls back to the in-repo `atproto-smoke/` copy. Set `PERLSKY_BROWSER_SUITE_ROOT` to point it at any other checkout explicitly. 61 + - Extraction work toward a cross-PDS standalone package now lives in the `atproto-smoke` repo, which owns the browser runtime, package CLI, example configs, and bring-your-own-account plus `perlsky` adapter helpers. 62 + - `script/perlsky-browser-smoke` expects a standalone checkout at `../atproto-smoke` by default. Set `PERLSKY_BROWSER_SUITE_ROOT` to point it at any other checkout explicitly. 63 + - The shared smoke runtime now applies a bounded per-step timeout (`stepTimeoutMs`, default `120000`) so late browser stalls fail with artifacts instead of hanging forever. 63 64 64 65 Moderation and labels: 65 66
-5
atproto-smoke/.gitignore
··· 1 - node_modules/ 2 - playwright-report/ 3 - test-results/ 4 - data/ 5 - .DS_Store
-129
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 - ## Quickstart 10 - 11 - ```sh 12 - npm install 13 - npx playwright install chromium 14 - node bin/atproto-smoke.mjs print-example --mode dual > config.json 15 - $EDITOR config.json 16 - node bin/atproto-smoke.mjs run-dual --config config.json 17 - ``` 18 - 19 - For the lowest-friction path, point the suite at an existing PDS and two 20 - existing accounts. The package is intentionally adapter-friendly, but 21 - bring-your-own accounts are the default path for non-Perl PDS implementations. 22 - 23 - ## Current Scope 24 - 25 - The existing browser automation is already strong enough to be useful outside 26 - this repo: 27 - 28 - - reusable-account `bsky.app` smoke flows 29 - - post, image post, like, repost, quote, reply, bookmark, follow 30 - - list lifecycle 31 - - profile edit and avatar upload 32 - - notifications checks 33 - - settings-depth flows 34 - - strict artifacts with screenshots, console output, failed requests, failed 35 - HTTP responses, and recent XRPC traffic 36 - 37 - DMs are intentionally deferred for now. The current suite is focused on stable 38 - social, list, and settings interactions first. 39 - 40 - ## Extraction Shape 41 - 42 - The target standalone project shape is: 43 - 44 - 1. Generic core browser flows and artifact handling 45 - 2. A bring-your-own-accounts mode with minimal configuration 46 - 3. Thin per-PDS adapters for provisioning and implementation-specific defaults 47 - 48 - The generic runtime, config builders, and adapter helpers now live here. The 49 - older `tools/browser-automation/` entrypoints simply forward into this package 50 - so the existing repo scripts keep working during the extraction. 51 - 52 - ## Current CLI 53 - 54 - The package now has its own CLI entrypoint: 55 - 56 - ```sh 57 - node atproto-smoke/bin/atproto-smoke.mjs print-example --mode dual 58 - node atproto-smoke/bin/atproto-smoke.mjs validate --mode dual --config atproto-smoke/examples/bring-your-own-dual.json 59 - node atproto-smoke/bin/atproto-smoke.mjs run-dual --config atproto-smoke/examples/bring-your-own-dual.json 60 - ``` 61 - 62 - Examples live in [examples/](./examples): 63 - 64 - - `bring-your-own-single.json` 65 - - `bring-your-own-dual.json` 66 - - `perlsky-dual.json` 67 - 68 - ## Minimal Configuration Goal 69 - 70 - The default experience for other PDS developers should be: 71 - 72 - - provide a `pdsUrl` 73 - - provide one or two existing account credentials 74 - - optionally provide a `targetHandle` 75 - - run the suite against `bsky.app` 76 - 77 - Provisioning is intentionally adapter-specific. That means `perlsky` can keep a 78 - helpful invite/bootstrap path, while other PDSes like `rsky` or `pegasus` can 79 - add their own adapters without changing the core browser flows. 80 - 81 - ## Current Adapter Contract 82 - 83 - The staging helpers in `src/` model two layers: 84 - 85 - - `adapters/bring-your-own.mjs` 86 - For the lowest-friction mode where callers supply existing credentials 87 - - `adapters/perlsky.mjs` 88 - For `perlsky`-specific defaults like cleanup prefixes and adapter tagging 89 - 90 - The current config contract is intentionally small: 91 - 92 - - suite-level settings: 93 - `pdsUrl`, `artifactsDir`, `appUrl`, `publicApiUrl`, `targetHandle`, 94 - `publicCheckTimeoutMs`, `headless`, `strictErrors`, `publicChecks`, 95 - `browserExecutablePath`, `adapter` 96 - - account-level settings: 97 - `handle`, `password`, `birthdate`, `postText`, `mediaPostText`, `quoteText`, 98 - `replyText`, `profileNote`, `cleanupPostPrefixes` 99 - 100 - `pdsHost` is derived automatically from `pdsUrl`, so callers do not need any 101 - perlsky-specific host-setting knowledge just to point the browser at a custom 102 - PDS. 103 - 104 - ## V2 Ideas 105 - 106 - The long-term direction is a test pyramid, not a browser-only harness and not a 107 - pure endpoint-only harness: 108 - 109 - 1. direct PDS/AppView contract tests 110 - 2. cross-service integration checks 111 - 3. a thinner `bsky.app` smoke on top 112 - 113 - The browser layer stays because it catches real `social-app` assumptions and 114 - AppView proxying issues. The direct API/AppView layers belong underneath it so 115 - regressions become easier to debug and less brittle when the UI changes. 116 - 117 - In other words: this project should eventually answer both "does my PDS return 118 - the right protocol shapes?" and "does it still behave correctly through 119 - `bsky.app` and AppView-backed reads?". 120 - 121 - ## Planned Next Steps 122 - 123 - - keep `script/perlsky-browser-smoke` as a thin `perlsky` adapter over this 124 - generic package 125 - - add a repo-independent install story once the extracted package boundary 126 - settles 127 - - add direct API/AppView contract tests as the first major v2 expansion 128 - - revisit a JS-to-TS migration later, after the standalone package boundary is 129 - 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 - }
-29
atproto-smoke/package.json
··· 1 - { 2 - "name": "atproto-smoke", 3 - "private": true, 4 - "description": "ATProto PDS compatibility smoke and end-to-end browser checks", 5 - "type": "module", 6 - "engines": { 7 - "node": ">=20" 8 - }, 9 - "bin": { 10 - "atproto-smoke": "./bin/atproto-smoke.mjs" 11 - }, 12 - "dependencies": { 13 - "playwright": "^1.54.2" 14 - }, 15 - "repository": { 16 - "type": "git", 17 - "url": "https://github.com/aliceisjustplaying/atproto-smoke.git" 18 - }, 19 - "homepage": "https://github.com/aliceisjustplaying/atproto-smoke", 20 - "exports": { 21 - ".": "./src/index.mjs", 22 - "./config": "./src/config.mjs", 23 - "./adapters/bring-your-own": "./src/adapters/bring-your-own.mjs", 24 - "./adapters/perlsky": "./src/adapters/perlsky.mjs", 25 - "./browser/run-single": "./src/browser/run-single.mjs", 26 - "./browser/run-dual": "./src/browser/run-dual.mjs", 27 - "./cli": "./src/cli.mjs" 28 - } 29 - }
-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 - };
-19
atproto-smoke/src/browser/lib/playwright-runtime.mjs
··· 1 - let playwright; 2 - 3 - try { 4 - playwright = await import('playwright'); 5 - } catch (primaryError) { 6 - try { 7 - playwright = await import('../../../../tools/browser-automation/node_modules/playwright/index.mjs'); 8 - } catch { 9 - throw new Error( 10 - [ 11 - 'Unable to load Playwright.', 12 - 'Install dependencies with `npm install` and then install a browser with `npx playwright install chromium`.', 13 - `Original error: ${String(primaryError?.message ?? primaryError)}`, 14 - ].join(' '), 15 - ); 16 - } 17 - } 18 - 19 - 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';
+11 -4
docs/BROWSER_SMOKE.md
··· 73 73 - HTTP failures 74 74 - recent XRPC traffic 75 75 76 + The generic runtime also applies a per-step timeout (`stepTimeoutMs`, default 77 + `120000`) so late browser stalls fail as bounded smoke errors instead of 78 + hanging indefinitely. 79 + 76 80 ## Test Suite Wrapper 77 81 78 82 The browser smoke is available from `prove`, but it is intentionally opt-in: ··· 86 90 87 91 ## Extraction 88 92 89 - The generic runtime now lives under [atproto-smoke](../atproto-smoke/README.md). 93 + The generic runtime now lives in the standalone `atproto-smoke` repo: 94 + 95 + - `https://github.com/aliceisjustplaying/atproto-smoke` 96 + - `https://tangled.org/alice.mosphere.at/atproto-smoke` 97 + 90 98 `perlsky` still keeps [script/perlsky-browser-smoke](/Users/sarah/src/tries/2026-03-10-perlds/script/perlsky-browser-smoke) 91 99 as the ergonomic adapter for this repo, but the standalone package now owns: 92 100 ··· 98 106 This keeps the current `perlsky` workflow stable while making extraction to a 99 107 repo-independent package much more straightforward. 100 108 101 - The wrapper now prefers an external sibling checkout at `../atproto-smoke` 102 - when present, and otherwise falls back to the in-repo `atproto-smoke/` copy. 103 - Set `PERLSKY_BROWSER_SUITE_ROOT` to force a specific checkout. 109 + The wrapper now expects a standalone `atproto-smoke` checkout, either at the 110 + default sibling path `../atproto-smoke` or via `PERLSKY_BROWSER_SUITE_ROOT`. 104 111 105 112 ## Notes 106 113
-2
script/perlsky-browser-smoke
··· 12 12 use POSIX qw(strftime); 13 13 14 14 my $root = abs_path(File::Spec->catdir($Bin, '..')); 15 - my $embedded_suite_dir = File::Spec->catdir($root, 'atproto-smoke'); 16 15 my $adjacent_suite_dir = File::Spec->catdir($root, '..', 'atproto-smoke'); 17 16 my $suite_dir = _resolve_suite_dir( 18 17 $ENV{PERLSKY_BROWSER_SUITE_ROOT}, 19 18 $adjacent_suite_dir, 20 - $embedded_suite_dir, 21 19 ); 22 20 my $cache_dir = File::Spec->catdir($root, '.cache', 'ms-playwright'); 23 21 my $default_pair_file = File::Spec->catfile($root, '.cache', 'browser-smoke', 'reusable-pair.json');
+2 -1
tools/browser-automation/dual-smoke.mjs
··· 1 - import { runDualFromArgv } from '../../atproto-smoke/src/browser/run-dual.mjs'; 1 + import { importSuiteModule } from './suite-root.mjs'; 2 2 3 + const { runDualFromArgv } = await importSuiteModule('src/browser/run-dual.mjs'); 3 4 const exitCode = await runDualFromArgv(process.argv); 4 5 process.exitCode = exitCode;
+2 -1
tools/browser-automation/smoke.mjs
··· 1 - import { runSingleFromArgv } from '../../atproto-smoke/src/browser/run-single.mjs'; 1 + import { importSuiteModule } from './suite-root.mjs'; 2 2 3 + const { runSingleFromArgv } = await importSuiteModule('src/browser/run-single.mjs'); 3 4 const exitCode = await runSingleFromArgv(process.argv); 4 5 process.exitCode = exitCode;
+37
tools/browser-automation/suite-root.mjs
··· 1 + import fs from 'node:fs'; 2 + import path from 'node:path'; 3 + import { fileURLToPath, pathToFileURL } from 'node:url'; 4 + 5 + const browserDir = path.dirname(fileURLToPath(import.meta.url)); 6 + const repoRoot = path.resolve(browserDir, '..', '..'); 7 + 8 + const isSuiteRoot = (candidate) => { 9 + if (!candidate) { 10 + return false; 11 + } 12 + return ( 13 + fs.existsSync(path.join(candidate, 'package.json')) && 14 + fs.existsSync(path.join(candidate, 'src', 'browser')) 15 + ); 16 + }; 17 + 18 + export const resolveSuiteRoot = () => { 19 + const candidates = [ 20 + process.env.PERLSKY_BROWSER_SUITE_ROOT, 21 + path.resolve(repoRoot, '..', 'atproto-smoke'), 22 + ]; 23 + for (const candidate of candidates) { 24 + if (isSuiteRoot(candidate)) { 25 + return candidate; 26 + } 27 + } 28 + throw new Error( 29 + 'unable to locate standalone atproto-smoke checkout; set PERLSKY_BROWSER_SUITE_ROOT or clone ../atproto-smoke', 30 + ); 31 + }; 32 + 33 + export const importSuiteModule = async (relativePath) => { 34 + const suiteRoot = resolveSuiteRoot(); 35 + const modulePath = pathToFileURL(path.join(suiteRoot, relativePath)).href; 36 + return import(modulePath); 37 + };