this repo has no description
32
fork

Configure Feed

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

Add strict ESLint and Prettier tooling

alice 2b017a3a d061d58f

+3701 -1475
+3
.prettierignore
··· 1 + .tmp/ 2 + data/ 3 + node_modules/
+1 -1
bin/atproto-smoke.mjs
··· 1 1 #!/usr/bin/env node 2 - import { runCliFromArgv } from '../src/cli.mjs'; 2 + import { runCliFromArgv } from "../src/cli.mjs"; 3 3 4 4 try { 5 5 const exitCode = await runCliFromArgv(process.argv);
+44
eslint.config.js
··· 1 + import eslint from "@eslint/js"; 2 + import globals from "globals"; 3 + 4 + export default [ 5 + { 6 + ignores: [".tmp/**", "data/**", "node_modules/**"], 7 + }, 8 + eslint.configs.recommended, 9 + { 10 + files: ["**/*.{js,mjs,cjs}"], 11 + languageOptions: { 12 + ecmaVersion: "latest", 13 + sourceType: "module", 14 + globals: { 15 + ...globals.browser, 16 + ...globals.node, 17 + }, 18 + }, 19 + rules: { 20 + "consistent-return": "error", 21 + eqeqeq: ["error", "always"], 22 + "no-console": "warn", 23 + "no-implicit-coercion": "error", 24 + "no-shadow": "error", 25 + "no-unused-vars": [ 26 + "error", 27 + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 28 + ], 29 + "no-var": "error", 30 + "prefer-const": "error", 31 + "require-await": "error", 32 + }, 33 + }, 34 + { 35 + files: [ 36 + "**/__tests__/**/*.{js,mjs,cjs}", 37 + "**/*.test.{js,mjs,cjs}", 38 + "**/*.spec.{js,mjs,cjs}", 39 + ], 40 + rules: { 41 + "require-await": "off", 42 + }, 43 + }, 44 + ];
+1129
package-lock.json
··· 1 1 { 2 2 "name": "atproto-smoke", 3 + "version": "0.2.0", 3 4 "lockfileVersion": 3, 4 5 "requires": true, 5 6 "packages": { 6 7 "": { 7 8 "name": "atproto-smoke", 9 + "version": "0.2.0", 8 10 "dependencies": { 9 11 "playwright": "^1.54.2" 10 12 }, 11 13 "bin": { 12 14 "atproto-smoke": "bin/atproto-smoke.mjs" 13 15 }, 16 + "devDependencies": { 17 + "@eslint/js": "^9.39.2", 18 + "@types/node": "^24.7.2", 19 + "eslint": "^9.39.2", 20 + "globals": "^17.0.0", 21 + "prettier": "^3.8.0", 22 + "typescript": "^5.9.3" 23 + }, 14 24 "engines": { 15 25 "node": ">=20" 16 26 } 17 27 }, 28 + "node_modules/@eslint-community/eslint-utils": { 29 + "version": "4.9.1", 30 + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", 31 + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", 32 + "dev": true, 33 + "license": "MIT", 34 + "dependencies": { 35 + "eslint-visitor-keys": "^3.4.3" 36 + }, 37 + "engines": { 38 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 39 + }, 40 + "funding": { 41 + "url": "https://opencollective.com/eslint" 42 + }, 43 + "peerDependencies": { 44 + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" 45 + } 46 + }, 47 + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { 48 + "version": "3.4.3", 49 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", 50 + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", 51 + "dev": true, 52 + "license": "Apache-2.0", 53 + "engines": { 54 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 55 + }, 56 + "funding": { 57 + "url": "https://opencollective.com/eslint" 58 + } 59 + }, 60 + "node_modules/@eslint-community/regexpp": { 61 + "version": "4.12.2", 62 + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", 63 + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", 64 + "dev": true, 65 + "license": "MIT", 66 + "engines": { 67 + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" 68 + } 69 + }, 70 + "node_modules/@eslint/config-array": { 71 + "version": "0.21.2", 72 + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", 73 + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", 74 + "dev": true, 75 + "license": "Apache-2.0", 76 + "dependencies": { 77 + "@eslint/object-schema": "^2.1.7", 78 + "debug": "^4.3.1", 79 + "minimatch": "^3.1.5" 80 + }, 81 + "engines": { 82 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 83 + } 84 + }, 85 + "node_modules/@eslint/config-helpers": { 86 + "version": "0.4.2", 87 + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", 88 + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", 89 + "dev": true, 90 + "license": "Apache-2.0", 91 + "dependencies": { 92 + "@eslint/core": "^0.17.0" 93 + }, 94 + "engines": { 95 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 96 + } 97 + }, 98 + "node_modules/@eslint/core": { 99 + "version": "0.17.0", 100 + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", 101 + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", 102 + "dev": true, 103 + "license": "Apache-2.0", 104 + "dependencies": { 105 + "@types/json-schema": "^7.0.15" 106 + }, 107 + "engines": { 108 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 109 + } 110 + }, 111 + "node_modules/@eslint/eslintrc": { 112 + "version": "3.3.5", 113 + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", 114 + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", 115 + "dev": true, 116 + "license": "MIT", 117 + "dependencies": { 118 + "ajv": "^6.14.0", 119 + "debug": "^4.3.2", 120 + "espree": "^10.0.1", 121 + "globals": "^14.0.0", 122 + "ignore": "^5.2.0", 123 + "import-fresh": "^3.2.1", 124 + "js-yaml": "^4.1.1", 125 + "minimatch": "^3.1.5", 126 + "strip-json-comments": "^3.1.1" 127 + }, 128 + "engines": { 129 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 130 + }, 131 + "funding": { 132 + "url": "https://opencollective.com/eslint" 133 + } 134 + }, 135 + "node_modules/@eslint/eslintrc/node_modules/globals": { 136 + "version": "14.0.0", 137 + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", 138 + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", 139 + "dev": true, 140 + "license": "MIT", 141 + "engines": { 142 + "node": ">=18" 143 + }, 144 + "funding": { 145 + "url": "https://github.com/sponsors/sindresorhus" 146 + } 147 + }, 148 + "node_modules/@eslint/js": { 149 + "version": "9.39.4", 150 + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", 151 + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", 152 + "dev": true, 153 + "license": "MIT", 154 + "engines": { 155 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 156 + }, 157 + "funding": { 158 + "url": "https://eslint.org/donate" 159 + } 160 + }, 161 + "node_modules/@eslint/object-schema": { 162 + "version": "2.1.7", 163 + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", 164 + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", 165 + "dev": true, 166 + "license": "Apache-2.0", 167 + "engines": { 168 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 169 + } 170 + }, 171 + "node_modules/@eslint/plugin-kit": { 172 + "version": "0.4.1", 173 + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", 174 + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", 175 + "dev": true, 176 + "license": "Apache-2.0", 177 + "dependencies": { 178 + "@eslint/core": "^0.17.0", 179 + "levn": "^0.4.1" 180 + }, 181 + "engines": { 182 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 183 + } 184 + }, 185 + "node_modules/@humanfs/core": { 186 + "version": "0.19.1", 187 + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", 188 + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", 189 + "dev": true, 190 + "license": "Apache-2.0", 191 + "engines": { 192 + "node": ">=18.18.0" 193 + } 194 + }, 195 + "node_modules/@humanfs/node": { 196 + "version": "0.16.7", 197 + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", 198 + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", 199 + "dev": true, 200 + "license": "Apache-2.0", 201 + "dependencies": { 202 + "@humanfs/core": "^0.19.1", 203 + "@humanwhocodes/retry": "^0.4.0" 204 + }, 205 + "engines": { 206 + "node": ">=18.18.0" 207 + } 208 + }, 209 + "node_modules/@humanwhocodes/module-importer": { 210 + "version": "1.0.1", 211 + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", 212 + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", 213 + "dev": true, 214 + "license": "Apache-2.0", 215 + "engines": { 216 + "node": ">=12.22" 217 + }, 218 + "funding": { 219 + "type": "github", 220 + "url": "https://github.com/sponsors/nzakas" 221 + } 222 + }, 223 + "node_modules/@humanwhocodes/retry": { 224 + "version": "0.4.3", 225 + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", 226 + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", 227 + "dev": true, 228 + "license": "Apache-2.0", 229 + "engines": { 230 + "node": ">=18.18" 231 + }, 232 + "funding": { 233 + "type": "github", 234 + "url": "https://github.com/sponsors/nzakas" 235 + } 236 + }, 237 + "node_modules/@types/estree": { 238 + "version": "1.0.8", 239 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 240 + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 241 + "dev": true, 242 + "license": "MIT" 243 + }, 244 + "node_modules/@types/json-schema": { 245 + "version": "7.0.15", 246 + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", 247 + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", 248 + "dev": true, 249 + "license": "MIT" 250 + }, 251 + "node_modules/@types/node": { 252 + "version": "24.12.0", 253 + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", 254 + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", 255 + "dev": true, 256 + "license": "MIT", 257 + "dependencies": { 258 + "undici-types": "~7.16.0" 259 + } 260 + }, 261 + "node_modules/acorn": { 262 + "version": "8.16.0", 263 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", 264 + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", 265 + "dev": true, 266 + "license": "MIT", 267 + "bin": { 268 + "acorn": "bin/acorn" 269 + }, 270 + "engines": { 271 + "node": ">=0.4.0" 272 + } 273 + }, 274 + "node_modules/acorn-jsx": { 275 + "version": "5.3.2", 276 + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", 277 + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", 278 + "dev": true, 279 + "license": "MIT", 280 + "peerDependencies": { 281 + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" 282 + } 283 + }, 284 + "node_modules/ajv": { 285 + "version": "6.14.0", 286 + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", 287 + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", 288 + "dev": true, 289 + "license": "MIT", 290 + "dependencies": { 291 + "fast-deep-equal": "^3.1.1", 292 + "fast-json-stable-stringify": "^2.0.0", 293 + "json-schema-traverse": "^0.4.1", 294 + "uri-js": "^4.2.2" 295 + }, 296 + "funding": { 297 + "type": "github", 298 + "url": "https://github.com/sponsors/epoberezkin" 299 + } 300 + }, 301 + "node_modules/ansi-styles": { 302 + "version": "4.3.0", 303 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 304 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 305 + "dev": true, 306 + "license": "MIT", 307 + "dependencies": { 308 + "color-convert": "^2.0.1" 309 + }, 310 + "engines": { 311 + "node": ">=8" 312 + }, 313 + "funding": { 314 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 315 + } 316 + }, 317 + "node_modules/argparse": { 318 + "version": "2.0.1", 319 + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", 320 + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", 321 + "dev": true, 322 + "license": "Python-2.0" 323 + }, 324 + "node_modules/balanced-match": { 325 + "version": "1.0.2", 326 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 327 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 328 + "dev": true, 329 + "license": "MIT" 330 + }, 331 + "node_modules/brace-expansion": { 332 + "version": "1.1.13", 333 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", 334 + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", 335 + "dev": true, 336 + "license": "MIT", 337 + "dependencies": { 338 + "balanced-match": "^1.0.0", 339 + "concat-map": "0.0.1" 340 + } 341 + }, 342 + "node_modules/callsites": { 343 + "version": "3.1.0", 344 + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", 345 + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", 346 + "dev": true, 347 + "license": "MIT", 348 + "engines": { 349 + "node": ">=6" 350 + } 351 + }, 352 + "node_modules/chalk": { 353 + "version": "4.1.2", 354 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 355 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 356 + "dev": true, 357 + "license": "MIT", 358 + "dependencies": { 359 + "ansi-styles": "^4.1.0", 360 + "supports-color": "^7.1.0" 361 + }, 362 + "engines": { 363 + "node": ">=10" 364 + }, 365 + "funding": { 366 + "url": "https://github.com/chalk/chalk?sponsor=1" 367 + } 368 + }, 369 + "node_modules/color-convert": { 370 + "version": "2.0.1", 371 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 372 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 373 + "dev": true, 374 + "license": "MIT", 375 + "dependencies": { 376 + "color-name": "~1.1.4" 377 + }, 378 + "engines": { 379 + "node": ">=7.0.0" 380 + } 381 + }, 382 + "node_modules/color-name": { 383 + "version": "1.1.4", 384 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 385 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 386 + "dev": true, 387 + "license": "MIT" 388 + }, 389 + "node_modules/concat-map": { 390 + "version": "0.0.1", 391 + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 392 + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", 393 + "dev": true, 394 + "license": "MIT" 395 + }, 396 + "node_modules/cross-spawn": { 397 + "version": "7.0.6", 398 + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 399 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 400 + "dev": true, 401 + "license": "MIT", 402 + "dependencies": { 403 + "path-key": "^3.1.0", 404 + "shebang-command": "^2.0.0", 405 + "which": "^2.0.1" 406 + }, 407 + "engines": { 408 + "node": ">= 8" 409 + } 410 + }, 411 + "node_modules/debug": { 412 + "version": "4.4.3", 413 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 414 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 415 + "dev": true, 416 + "license": "MIT", 417 + "dependencies": { 418 + "ms": "^2.1.3" 419 + }, 420 + "engines": { 421 + "node": ">=6.0" 422 + }, 423 + "peerDependenciesMeta": { 424 + "supports-color": { 425 + "optional": true 426 + } 427 + } 428 + }, 429 + "node_modules/deep-is": { 430 + "version": "0.1.4", 431 + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", 432 + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", 433 + "dev": true, 434 + "license": "MIT" 435 + }, 436 + "node_modules/escape-string-regexp": { 437 + "version": "4.0.0", 438 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", 439 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", 440 + "dev": true, 441 + "license": "MIT", 442 + "engines": { 443 + "node": ">=10" 444 + }, 445 + "funding": { 446 + "url": "https://github.com/sponsors/sindresorhus" 447 + } 448 + }, 449 + "node_modules/eslint": { 450 + "version": "9.39.4", 451 + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", 452 + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", 453 + "dev": true, 454 + "license": "MIT", 455 + "dependencies": { 456 + "@eslint-community/eslint-utils": "^4.8.0", 457 + "@eslint-community/regexpp": "^4.12.1", 458 + "@eslint/config-array": "^0.21.2", 459 + "@eslint/config-helpers": "^0.4.2", 460 + "@eslint/core": "^0.17.0", 461 + "@eslint/eslintrc": "^3.3.5", 462 + "@eslint/js": "9.39.4", 463 + "@eslint/plugin-kit": "^0.4.1", 464 + "@humanfs/node": "^0.16.6", 465 + "@humanwhocodes/module-importer": "^1.0.1", 466 + "@humanwhocodes/retry": "^0.4.2", 467 + "@types/estree": "^1.0.6", 468 + "ajv": "^6.14.0", 469 + "chalk": "^4.0.0", 470 + "cross-spawn": "^7.0.6", 471 + "debug": "^4.3.2", 472 + "escape-string-regexp": "^4.0.0", 473 + "eslint-scope": "^8.4.0", 474 + "eslint-visitor-keys": "^4.2.1", 475 + "espree": "^10.4.0", 476 + "esquery": "^1.5.0", 477 + "esutils": "^2.0.2", 478 + "fast-deep-equal": "^3.1.3", 479 + "file-entry-cache": "^8.0.0", 480 + "find-up": "^5.0.0", 481 + "glob-parent": "^6.0.2", 482 + "ignore": "^5.2.0", 483 + "imurmurhash": "^0.1.4", 484 + "is-glob": "^4.0.0", 485 + "json-stable-stringify-without-jsonify": "^1.0.1", 486 + "lodash.merge": "^4.6.2", 487 + "minimatch": "^3.1.5", 488 + "natural-compare": "^1.4.0", 489 + "optionator": "^0.9.3" 490 + }, 491 + "bin": { 492 + "eslint": "bin/eslint.js" 493 + }, 494 + "engines": { 495 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 496 + }, 497 + "funding": { 498 + "url": "https://eslint.org/donate" 499 + }, 500 + "peerDependencies": { 501 + "jiti": "*" 502 + }, 503 + "peerDependenciesMeta": { 504 + "jiti": { 505 + "optional": true 506 + } 507 + } 508 + }, 509 + "node_modules/eslint-scope": { 510 + "version": "8.4.0", 511 + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", 512 + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", 513 + "dev": true, 514 + "license": "BSD-2-Clause", 515 + "dependencies": { 516 + "esrecurse": "^4.3.0", 517 + "estraverse": "^5.2.0" 518 + }, 519 + "engines": { 520 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 521 + }, 522 + "funding": { 523 + "url": "https://opencollective.com/eslint" 524 + } 525 + }, 526 + "node_modules/eslint-visitor-keys": { 527 + "version": "4.2.1", 528 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", 529 + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", 530 + "dev": true, 531 + "license": "Apache-2.0", 532 + "engines": { 533 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 534 + }, 535 + "funding": { 536 + "url": "https://opencollective.com/eslint" 537 + } 538 + }, 539 + "node_modules/espree": { 540 + "version": "10.4.0", 541 + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", 542 + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", 543 + "dev": true, 544 + "license": "BSD-2-Clause", 545 + "dependencies": { 546 + "acorn": "^8.15.0", 547 + "acorn-jsx": "^5.3.2", 548 + "eslint-visitor-keys": "^4.2.1" 549 + }, 550 + "engines": { 551 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 552 + }, 553 + "funding": { 554 + "url": "https://opencollective.com/eslint" 555 + } 556 + }, 557 + "node_modules/esquery": { 558 + "version": "1.7.0", 559 + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", 560 + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", 561 + "dev": true, 562 + "license": "BSD-3-Clause", 563 + "dependencies": { 564 + "estraverse": "^5.1.0" 565 + }, 566 + "engines": { 567 + "node": ">=0.10" 568 + } 569 + }, 570 + "node_modules/esrecurse": { 571 + "version": "4.3.0", 572 + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", 573 + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", 574 + "dev": true, 575 + "license": "BSD-2-Clause", 576 + "dependencies": { 577 + "estraverse": "^5.2.0" 578 + }, 579 + "engines": { 580 + "node": ">=4.0" 581 + } 582 + }, 583 + "node_modules/estraverse": { 584 + "version": "5.3.0", 585 + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", 586 + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", 587 + "dev": true, 588 + "license": "BSD-2-Clause", 589 + "engines": { 590 + "node": ">=4.0" 591 + } 592 + }, 593 + "node_modules/esutils": { 594 + "version": "2.0.3", 595 + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", 596 + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", 597 + "dev": true, 598 + "license": "BSD-2-Clause", 599 + "engines": { 600 + "node": ">=0.10.0" 601 + } 602 + }, 603 + "node_modules/fast-deep-equal": { 604 + "version": "3.1.3", 605 + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 606 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", 607 + "dev": true, 608 + "license": "MIT" 609 + }, 610 + "node_modules/fast-json-stable-stringify": { 611 + "version": "2.1.0", 612 + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", 613 + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", 614 + "dev": true, 615 + "license": "MIT" 616 + }, 617 + "node_modules/fast-levenshtein": { 618 + "version": "2.0.6", 619 + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", 620 + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", 621 + "dev": true, 622 + "license": "MIT" 623 + }, 624 + "node_modules/file-entry-cache": { 625 + "version": "8.0.0", 626 + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", 627 + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", 628 + "dev": true, 629 + "license": "MIT", 630 + "dependencies": { 631 + "flat-cache": "^4.0.0" 632 + }, 633 + "engines": { 634 + "node": ">=16.0.0" 635 + } 636 + }, 637 + "node_modules/find-up": { 638 + "version": "5.0.0", 639 + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", 640 + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", 641 + "dev": true, 642 + "license": "MIT", 643 + "dependencies": { 644 + "locate-path": "^6.0.0", 645 + "path-exists": "^4.0.0" 646 + }, 647 + "engines": { 648 + "node": ">=10" 649 + }, 650 + "funding": { 651 + "url": "https://github.com/sponsors/sindresorhus" 652 + } 653 + }, 654 + "node_modules/flat-cache": { 655 + "version": "4.0.1", 656 + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", 657 + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", 658 + "dev": true, 659 + "license": "MIT", 660 + "dependencies": { 661 + "flatted": "^3.2.9", 662 + "keyv": "^4.5.4" 663 + }, 664 + "engines": { 665 + "node": ">=16" 666 + } 667 + }, 668 + "node_modules/flatted": { 669 + "version": "3.4.2", 670 + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", 671 + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", 672 + "dev": true, 673 + "license": "ISC" 674 + }, 18 675 "node_modules/fsevents": { 19 676 "version": "2.3.2", 20 677 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", ··· 29 686 "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 30 687 } 31 688 }, 689 + "node_modules/glob-parent": { 690 + "version": "6.0.2", 691 + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", 692 + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", 693 + "dev": true, 694 + "license": "ISC", 695 + "dependencies": { 696 + "is-glob": "^4.0.3" 697 + }, 698 + "engines": { 699 + "node": ">=10.13.0" 700 + } 701 + }, 702 + "node_modules/globals": { 703 + "version": "17.4.0", 704 + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", 705 + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", 706 + "dev": true, 707 + "license": "MIT", 708 + "engines": { 709 + "node": ">=18" 710 + }, 711 + "funding": { 712 + "url": "https://github.com/sponsors/sindresorhus" 713 + } 714 + }, 715 + "node_modules/has-flag": { 716 + "version": "4.0.0", 717 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 718 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 719 + "dev": true, 720 + "license": "MIT", 721 + "engines": { 722 + "node": ">=8" 723 + } 724 + }, 725 + "node_modules/ignore": { 726 + "version": "5.3.2", 727 + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", 728 + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", 729 + "dev": true, 730 + "license": "MIT", 731 + "engines": { 732 + "node": ">= 4" 733 + } 734 + }, 735 + "node_modules/import-fresh": { 736 + "version": "3.3.1", 737 + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", 738 + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", 739 + "dev": true, 740 + "license": "MIT", 741 + "dependencies": { 742 + "parent-module": "^1.0.0", 743 + "resolve-from": "^4.0.0" 744 + }, 745 + "engines": { 746 + "node": ">=6" 747 + }, 748 + "funding": { 749 + "url": "https://github.com/sponsors/sindresorhus" 750 + } 751 + }, 752 + "node_modules/imurmurhash": { 753 + "version": "0.1.4", 754 + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", 755 + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", 756 + "dev": true, 757 + "license": "MIT", 758 + "engines": { 759 + "node": ">=0.8.19" 760 + } 761 + }, 762 + "node_modules/is-extglob": { 763 + "version": "2.1.1", 764 + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 765 + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", 766 + "dev": true, 767 + "license": "MIT", 768 + "engines": { 769 + "node": ">=0.10.0" 770 + } 771 + }, 772 + "node_modules/is-glob": { 773 + "version": "4.0.3", 774 + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", 775 + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 776 + "dev": true, 777 + "license": "MIT", 778 + "dependencies": { 779 + "is-extglob": "^2.1.1" 780 + }, 781 + "engines": { 782 + "node": ">=0.10.0" 783 + } 784 + }, 785 + "node_modules/isexe": { 786 + "version": "2.0.0", 787 + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 788 + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 789 + "dev": true, 790 + "license": "ISC" 791 + }, 792 + "node_modules/js-yaml": { 793 + "version": "4.1.1", 794 + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", 795 + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", 796 + "dev": true, 797 + "license": "MIT", 798 + "dependencies": { 799 + "argparse": "^2.0.1" 800 + }, 801 + "bin": { 802 + "js-yaml": "bin/js-yaml.js" 803 + } 804 + }, 805 + "node_modules/json-buffer": { 806 + "version": "3.0.1", 807 + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", 808 + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", 809 + "dev": true, 810 + "license": "MIT" 811 + }, 812 + "node_modules/json-schema-traverse": { 813 + "version": "0.4.1", 814 + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", 815 + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", 816 + "dev": true, 817 + "license": "MIT" 818 + }, 819 + "node_modules/json-stable-stringify-without-jsonify": { 820 + "version": "1.0.1", 821 + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", 822 + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", 823 + "dev": true, 824 + "license": "MIT" 825 + }, 826 + "node_modules/keyv": { 827 + "version": "4.5.4", 828 + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", 829 + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", 830 + "dev": true, 831 + "license": "MIT", 832 + "dependencies": { 833 + "json-buffer": "3.0.1" 834 + } 835 + }, 836 + "node_modules/levn": { 837 + "version": "0.4.1", 838 + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", 839 + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", 840 + "dev": true, 841 + "license": "MIT", 842 + "dependencies": { 843 + "prelude-ls": "^1.2.1", 844 + "type-check": "~0.4.0" 845 + }, 846 + "engines": { 847 + "node": ">= 0.8.0" 848 + } 849 + }, 850 + "node_modules/locate-path": { 851 + "version": "6.0.0", 852 + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", 853 + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", 854 + "dev": true, 855 + "license": "MIT", 856 + "dependencies": { 857 + "p-locate": "^5.0.0" 858 + }, 859 + "engines": { 860 + "node": ">=10" 861 + }, 862 + "funding": { 863 + "url": "https://github.com/sponsors/sindresorhus" 864 + } 865 + }, 866 + "node_modules/lodash.merge": { 867 + "version": "4.6.2", 868 + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", 869 + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", 870 + "dev": true, 871 + "license": "MIT" 872 + }, 873 + "node_modules/minimatch": { 874 + "version": "3.1.5", 875 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", 876 + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", 877 + "dev": true, 878 + "license": "ISC", 879 + "dependencies": { 880 + "brace-expansion": "^1.1.7" 881 + }, 882 + "engines": { 883 + "node": "*" 884 + } 885 + }, 886 + "node_modules/ms": { 887 + "version": "2.1.3", 888 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 889 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 890 + "dev": true, 891 + "license": "MIT" 892 + }, 893 + "node_modules/natural-compare": { 894 + "version": "1.4.0", 895 + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", 896 + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", 897 + "dev": true, 898 + "license": "MIT" 899 + }, 900 + "node_modules/optionator": { 901 + "version": "0.9.4", 902 + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", 903 + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", 904 + "dev": true, 905 + "license": "MIT", 906 + "dependencies": { 907 + "deep-is": "^0.1.3", 908 + "fast-levenshtein": "^2.0.6", 909 + "levn": "^0.4.1", 910 + "prelude-ls": "^1.2.1", 911 + "type-check": "^0.4.0", 912 + "word-wrap": "^1.2.5" 913 + }, 914 + "engines": { 915 + "node": ">= 0.8.0" 916 + } 917 + }, 918 + "node_modules/p-limit": { 919 + "version": "3.1.0", 920 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", 921 + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 922 + "dev": true, 923 + "license": "MIT", 924 + "dependencies": { 925 + "yocto-queue": "^0.1.0" 926 + }, 927 + "engines": { 928 + "node": ">=10" 929 + }, 930 + "funding": { 931 + "url": "https://github.com/sponsors/sindresorhus" 932 + } 933 + }, 934 + "node_modules/p-locate": { 935 + "version": "5.0.0", 936 + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", 937 + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", 938 + "dev": true, 939 + "license": "MIT", 940 + "dependencies": { 941 + "p-limit": "^3.0.2" 942 + }, 943 + "engines": { 944 + "node": ">=10" 945 + }, 946 + "funding": { 947 + "url": "https://github.com/sponsors/sindresorhus" 948 + } 949 + }, 950 + "node_modules/parent-module": { 951 + "version": "1.0.1", 952 + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", 953 + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", 954 + "dev": true, 955 + "license": "MIT", 956 + "dependencies": { 957 + "callsites": "^3.0.0" 958 + }, 959 + "engines": { 960 + "node": ">=6" 961 + } 962 + }, 963 + "node_modules/path-exists": { 964 + "version": "4.0.0", 965 + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 966 + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 967 + "dev": true, 968 + "license": "MIT", 969 + "engines": { 970 + "node": ">=8" 971 + } 972 + }, 973 + "node_modules/path-key": { 974 + "version": "3.1.1", 975 + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 976 + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 977 + "dev": true, 978 + "license": "MIT", 979 + "engines": { 980 + "node": ">=8" 981 + } 982 + }, 32 983 "node_modules/playwright": { 33 984 "version": "1.58.2", 34 985 "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", ··· 57 1008 }, 58 1009 "engines": { 59 1010 "node": ">=18" 1011 + } 1012 + }, 1013 + "node_modules/prelude-ls": { 1014 + "version": "1.2.1", 1015 + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", 1016 + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", 1017 + "dev": true, 1018 + "license": "MIT", 1019 + "engines": { 1020 + "node": ">= 0.8.0" 1021 + } 1022 + }, 1023 + "node_modules/prettier": { 1024 + "version": "3.8.1", 1025 + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", 1026 + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", 1027 + "dev": true, 1028 + "license": "MIT", 1029 + "bin": { 1030 + "prettier": "bin/prettier.cjs" 1031 + }, 1032 + "engines": { 1033 + "node": ">=14" 1034 + }, 1035 + "funding": { 1036 + "url": "https://github.com/prettier/prettier?sponsor=1" 1037 + } 1038 + }, 1039 + "node_modules/punycode": { 1040 + "version": "2.3.1", 1041 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 1042 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 1043 + "dev": true, 1044 + "license": "MIT", 1045 + "engines": { 1046 + "node": ">=6" 1047 + } 1048 + }, 1049 + "node_modules/resolve-from": { 1050 + "version": "4.0.0", 1051 + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", 1052 + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", 1053 + "dev": true, 1054 + "license": "MIT", 1055 + "engines": { 1056 + "node": ">=4" 1057 + } 1058 + }, 1059 + "node_modules/shebang-command": { 1060 + "version": "2.0.0", 1061 + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 1062 + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 1063 + "dev": true, 1064 + "license": "MIT", 1065 + "dependencies": { 1066 + "shebang-regex": "^3.0.0" 1067 + }, 1068 + "engines": { 1069 + "node": ">=8" 1070 + } 1071 + }, 1072 + "node_modules/shebang-regex": { 1073 + "version": "3.0.0", 1074 + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 1075 + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 1076 + "dev": true, 1077 + "license": "MIT", 1078 + "engines": { 1079 + "node": ">=8" 1080 + } 1081 + }, 1082 + "node_modules/strip-json-comments": { 1083 + "version": "3.1.1", 1084 + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", 1085 + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", 1086 + "dev": true, 1087 + "license": "MIT", 1088 + "engines": { 1089 + "node": ">=8" 1090 + }, 1091 + "funding": { 1092 + "url": "https://github.com/sponsors/sindresorhus" 1093 + } 1094 + }, 1095 + "node_modules/supports-color": { 1096 + "version": "7.2.0", 1097 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 1098 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 1099 + "dev": true, 1100 + "license": "MIT", 1101 + "dependencies": { 1102 + "has-flag": "^4.0.0" 1103 + }, 1104 + "engines": { 1105 + "node": ">=8" 1106 + } 1107 + }, 1108 + "node_modules/type-check": { 1109 + "version": "0.4.0", 1110 + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", 1111 + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", 1112 + "dev": true, 1113 + "license": "MIT", 1114 + "dependencies": { 1115 + "prelude-ls": "^1.2.1" 1116 + }, 1117 + "engines": { 1118 + "node": ">= 0.8.0" 1119 + } 1120 + }, 1121 + "node_modules/typescript": { 1122 + "version": "5.9.3", 1123 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 1124 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1125 + "dev": true, 1126 + "license": "Apache-2.0", 1127 + "bin": { 1128 + "tsc": "bin/tsc", 1129 + "tsserver": "bin/tsserver" 1130 + }, 1131 + "engines": { 1132 + "node": ">=14.17" 1133 + } 1134 + }, 1135 + "node_modules/undici-types": { 1136 + "version": "7.16.0", 1137 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", 1138 + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", 1139 + "dev": true, 1140 + "license": "MIT" 1141 + }, 1142 + "node_modules/uri-js": { 1143 + "version": "4.4.1", 1144 + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", 1145 + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", 1146 + "dev": true, 1147 + "license": "BSD-2-Clause", 1148 + "dependencies": { 1149 + "punycode": "^2.1.0" 1150 + } 1151 + }, 1152 + "node_modules/which": { 1153 + "version": "2.0.2", 1154 + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 1155 + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 1156 + "dev": true, 1157 + "license": "ISC", 1158 + "dependencies": { 1159 + "isexe": "^2.0.0" 1160 + }, 1161 + "bin": { 1162 + "node-which": "bin/node-which" 1163 + }, 1164 + "engines": { 1165 + "node": ">= 8" 1166 + } 1167 + }, 1168 + "node_modules/word-wrap": { 1169 + "version": "1.2.5", 1170 + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", 1171 + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", 1172 + "dev": true, 1173 + "license": "MIT", 1174 + "engines": { 1175 + "node": ">=0.10.0" 1176 + } 1177 + }, 1178 + "node_modules/yocto-queue": { 1179 + "version": "0.1.0", 1180 + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", 1181 + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", 1182 + "dev": true, 1183 + "license": "MIT", 1184 + "engines": { 1185 + "node": ">=10" 1186 + }, 1187 + "funding": { 1188 + "url": "https://github.com/sponsors/sindresorhus" 60 1189 } 61 1190 } 62 1191 }
+12
package.json
··· 7 7 "scripts": { 8 8 "help": "node bin/atproto-smoke.mjs --help", 9 9 "list-adapters": "node bin/atproto-smoke.mjs list-adapters", 10 + "lint": "eslint .", 11 + "typecheck": "tsc --project tsconfig.json", 12 + "format": "prettier --write .", 13 + "format:check": "prettier --check .", 10 14 "print-example:dual": "node bin/atproto-smoke.mjs print-example --mode dual", 11 15 "write:pdslab-configs": "node scripts/write-pdslab-configs.mjs", 12 16 "validate:example:single": "node bin/atproto-smoke.mjs validate --mode single --config examples/bring-your-own-single.json", 13 17 "validate:example:dual": "node bin/atproto-smoke.mjs validate --mode dual --config examples/bring-your-own-dual.json", 14 18 "validate:example:perlsky": "node bin/atproto-smoke.mjs validate --mode dual --adapter perlsky --config examples/perlsky-dual.json", 15 19 "validate:example:tranquil-pds": "node bin/atproto-smoke.mjs validate --mode dual --adapter tranquil-pds --config examples/tranquil-pds-dual.json" 20 + }, 21 + "devDependencies": { 22 + "@eslint/js": "^9.39.2", 23 + "@types/node": "^24.7.2", 24 + "eslint": "^9.39.2", 25 + "globals": "^17.0.0", 26 + "prettier": "^3.8.0", 27 + "typescript": "^5.9.3" 16 28 }, 17 29 "engines": { 18 30 "node": ">=20"
+1
prettier.config.mjs
··· 1 + export default {};
+84 -52
scripts/write-pdslab-configs.mjs
··· 1 1 #!/usr/bin/env node 2 2 3 - import fs from 'node:fs/promises'; 4 - import path from 'node:path'; 5 - import { fileURLToPath } from 'node:url'; 6 - import { PDSLAB_TARGETS } from '../src/lab/pdslab-targets.mjs'; 3 + import fs from "node:fs/promises"; 4 + import path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { PDSLAB_TARGETS } from "../src/lab/pdslab-targets.mjs"; 7 7 8 - const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); 8 + const repoRoot = path.resolve( 9 + path.dirname(fileURLToPath(import.meta.url)), 10 + "..", 11 + ); 9 12 10 13 const parseArgs = (argv) => { 11 14 const result = { 12 - ledgerPath: path.join(repoRoot, '.tmp', 'smoke-accounts.local.json'), 13 - outputDir: path.join(repoRoot, '.tmp', 'generated', 'pdslab-configs'), 15 + ledgerPath: path.join(repoRoot, ".tmp", "smoke-accounts.local.json"), 16 + outputDir: path.join(repoRoot, ".tmp", "generated", "pdslab-configs"), 14 17 }; 15 18 16 19 for (let i = 2; i < argv.length; i += 1) { 17 20 const arg = argv[i]; 18 - if (arg === '--ledger') { 21 + if (arg === "--ledger") { 19 22 result.ledgerPath = path.resolve(argv[++i]); 20 23 continue; 21 24 } 22 - if (arg === '--output-dir') { 25 + if (arg === "--output-dir") { 23 26 result.outputDir = path.resolve(argv[++i]); 24 27 continue; 25 28 } 26 - if (arg === '--help' || arg === '-h') { 29 + if (arg === "--help" || arg === "-h") { 27 30 result.help = true; 28 31 continue; 29 32 } ··· 38 41 `; 39 42 40 43 const readJson = async (filePath) => { 41 - return JSON.parse(await fs.readFile(filePath, 'utf8')); 44 + return JSON.parse(await fs.readFile(filePath, "utf8")); 42 45 }; 43 46 44 47 const ensureTarget = (ledger, targetId) => { ··· 63 66 64 67 const createAccount = ({ account, loginIdentifierKey, spec, role }) => { 65 68 if (!account) { 66 - throw new Error('account details are required'); 69 + throw new Error("account details are required"); 67 70 } 68 71 69 72 const normalized = { ··· 75 78 if (loginIdentifierKey) { 76 79 const loginIdentifier = account[loginIdentifierKey]; 77 80 if (!loginIdentifier) { 78 - throw new Error(`missing loginIdentifierKey "${loginIdentifierKey}" on account`); 81 + throw new Error( 82 + `missing loginIdentifierKey "${loginIdentifierKey}" on account`, 83 + ); 79 84 } 80 85 normalized.loginIdentifier = loginIdentifier; 81 86 } ··· 102 107 : ledgerTarget.accounts?.[spec.ledgerAccount]; 103 108 104 109 if (!accountSource) { 105 - throw new Error(`single target ${spec.id} is missing its account in the ledger`); 110 + throw new Error( 111 + `single target ${spec.id} is missing its account in the ledger`, 112 + ); 106 113 } 107 114 108 115 return { 109 116 adapter: spec.adapter, 110 117 pdsUrl: ledgerTarget.pdsUrl, 111 118 artifactsDir: `data/browser-smoke/pdslab/${spec.id}`, 112 - targetHandle: 'smoke-a.perlsky.pdslab.net', 119 + targetHandle: "smoke-a.perlsky.pdslab.net", 113 120 accountSource: spec.accountSource, 114 121 pdslabTargetId: spec.id, 115 122 pdslabRunnerStatus: spec.runnerStatus, ··· 119 126 account: accountSource, 120 127 loginIdentifierKey: spec.loginIdentifierKey, 121 128 spec, 122 - role: spec.ledgerAccount || 'single', 129 + role: spec.ledgerAccount || "single", 123 130 }), 124 131 }; 125 132 }; 126 133 127 134 const createDualConfig = ({ spec, ledgerTarget }) => { 128 - const primary = ledgerTarget.accounts?.['smoke-a']; 129 - const secondary = ledgerTarget.accounts?.['smoke-b']; 135 + const primary = ledgerTarget.accounts?.["smoke-a"]; 136 + const secondary = ledgerTarget.accounts?.["smoke-b"]; 130 137 if (!primary || !secondary) { 131 - throw new Error(`dual target ${spec.id} is missing smoke-a or smoke-b in the ledger`); 138 + throw new Error( 139 + `dual target ${spec.id} is missing smoke-a or smoke-b in the ledger`, 140 + ); 132 141 } 133 142 134 143 return { ··· 139 148 pdslabTargetId: spec.id, 140 149 pdslabRunnerStatus: spec.runnerStatus, 141 150 pdslabNotes: spec.notes, 142 - primary: createAccount({ account: primary, spec, role: 'primary' }), 143 - secondary: createAccount({ account: secondary, spec, role: 'secondary' }), 151 + primary: createAccount({ account: primary, spec, role: "primary" }), 152 + secondary: createAccount({ account: secondary, spec, role: "secondary" }), 144 153 }; 145 154 }; 146 155 147 - const createCrossPdsDualConfig = ({ spec, primaryLedgerTarget, secondaryLedgerTarget }) => { 156 + const createCrossPdsDualConfig = ({ 157 + spec, 158 + primaryLedgerTarget, 159 + secondaryLedgerTarget, 160 + }) => { 148 161 const primarySource = spec.primaryCurrentDeploymentKey 149 162 ? primaryLedgerTarget[spec.primaryCurrentDeploymentKey] 150 163 : primaryLedgerTarget.accounts?.[spec.primaryLedgerAccount]; ··· 153 166 : secondaryLedgerTarget.accounts?.[spec.secondaryLedgerAccount]; 154 167 155 168 if (!primarySource || !secondarySource) { 156 - throw new Error(`cross-PDS target ${spec.id} is missing one of its accounts in the ledger`); 169 + throw new Error( 170 + `cross-PDS target ${spec.id} is missing one of its accounts in the ledger`, 171 + ); 157 172 } 158 173 159 174 return { ··· 171 186 }, 172 187 loginIdentifierKey: spec.primaryLoginIdentifierKey, 173 188 spec, 174 - role: 'primary', 189 + role: "primary", 175 190 }), 176 191 secondary: createAccount({ 177 192 account: { ··· 180 195 }, 181 196 loginIdentifierKey: spec.secondaryLoginIdentifierKey, 182 197 spec, 183 - role: 'secondary', 198 + role: "secondary", 184 199 }), 185 200 }; 186 201 }; ··· 190 205 const skipped = []; 191 206 192 207 for (const spec of PDSLAB_TARGETS) { 193 - const needsLedgerTarget = !!spec.ledgerTarget; 194 - const ledgerTarget = needsLedgerTarget ? ensureTarget(ledger, spec.ledgerTarget) : null; 208 + const needsLedgerTarget = Boolean(spec.ledgerTarget); 209 + const ledgerTarget = needsLedgerTarget 210 + ? ensureTarget(ledger, spec.ledgerTarget) 211 + : null; 195 212 const primaryLedgerTarget = spec.primaryLedgerTarget 196 213 ? ensureTarget(ledger, spec.primaryLedgerTarget) 197 214 : null; ··· 199 216 ? ensureTarget(ledger, spec.secondaryLedgerTarget) 200 217 : null; 201 218 202 - if (spec.runnerStatus !== 'ready' && spec.runnerStatus !== 'needs-login-identifier-support') { 219 + if ( 220 + spec.runnerStatus !== "ready" && 221 + spec.runnerStatus !== "needs-login-identifier-support" 222 + ) { 203 223 skipped.push({ 204 224 id: spec.id, 205 225 mode: spec.mode, ··· 210 230 } 211 231 212 232 let config; 213 - if (spec.mode === 'dual' && spec.primaryLedgerTarget && spec.secondaryLedgerTarget) { 214 - config = createCrossPdsDualConfig({ spec, primaryLedgerTarget, secondaryLedgerTarget }); 215 - } else if (spec.mode === 'dual') { 233 + if ( 234 + spec.mode === "dual" && 235 + spec.primaryLedgerTarget && 236 + spec.secondaryLedgerTarget 237 + ) { 238 + config = createCrossPdsDualConfig({ 239 + spec, 240 + primaryLedgerTarget, 241 + secondaryLedgerTarget, 242 + }); 243 + } else if (spec.mode === "dual") { 216 244 config = createDualConfig({ spec, ledgerTarget }); 217 245 } else { 218 246 config = createSingleConfig({ spec, ledgerTarget }); ··· 240 268 const inventory = { 241 269 generatedAt: plan.generatedAt, 242 270 domain: plan.domain, 243 - runnableTargets: plan.runnableTargets.map(({ id, mode, runnerStatus, config }) => ({ 244 - id, 245 - mode, 246 - runnerStatus, 247 - pdsUrl: config.pdsUrl, 248 - primaryPdsUrl: config.primary?.pdsUrl, 249 - secondaryPdsUrl: config.secondary?.pdsUrl, 250 - artifactsDir: config.artifactsDir, 251 - accountSource: config.accountSource, 252 - pairGroup: config.pdslabPairGroup, 253 - notes: config.pdslabNotes, 254 - loginIdentifier: config.account?.loginIdentifier, 255 - })), 271 + runnableTargets: plan.runnableTargets.map( 272 + ({ id, mode, runnerStatus, config }) => ({ 273 + id, 274 + mode, 275 + runnerStatus, 276 + pdsUrl: config.pdsUrl, 277 + primaryPdsUrl: config.primary?.pdsUrl, 278 + secondaryPdsUrl: config.secondary?.pdsUrl, 279 + artifactsDir: config.artifactsDir, 280 + accountSource: config.accountSource, 281 + pairGroup: config.pdslabPairGroup, 282 + notes: config.pdslabNotes, 283 + loginIdentifier: config.account?.loginIdentifier, 284 + }), 285 + ), 256 286 skippedTargets: plan.skippedTargets, 257 287 }; 258 288 259 289 await fs.writeFile( 260 - path.join(outputDir, 'inventory.json'), 290 + path.join(outputDir, "inventory.json"), 261 291 `${JSON.stringify(inventory, null, 2)}\n`, 262 - 'utf8', 292 + "utf8", 263 293 ); 264 294 265 295 for (const target of plan.runnableTargets) { ··· 267 297 await fs.writeFile( 268 298 path.join(outputDir, fileName), 269 299 `${JSON.stringify(target.config, null, 2)}\n`, 270 - 'utf8', 300 + "utf8", 271 301 ); 272 302 } 273 303 }; ··· 287 317 JSON.stringify( 288 318 { 289 319 wrote: args.outputDir, 290 - runnableTargets: plan.runnableTargets.map(({ id, mode, runnerStatus }) => ({ 291 - id, 292 - mode, 293 - runnerStatus, 294 - })), 320 + runnableTargets: plan.runnableTargets.map( 321 + ({ id, mode, runnerStatus }) => ({ 322 + id, 323 + mode, 324 + runnerStatus, 325 + }), 326 + ), 295 327 skippedTargets: plan.skippedTargets, 296 328 }, 297 329 null,
+13 -24
src/adapters/adapter-builder.mjs
··· 2 2 createAccountConfig, 3 3 createDualRunConfig, 4 4 createSingleRunConfig, 5 - } from '../config.mjs'; 5 + } from "../config.mjs"; 6 6 7 7 export const createRoleBasedAdapter = ({ 8 8 name, ··· 15 15 secondaryCleanupPrefixes, 16 16 dualSuiteDefaults = {}, 17 17 }) => { 18 - const createAccount = ({ 19 - role = 'primary', 20 - ...account 21 - } = {}) => { 22 - const cleanupPostPrefixes = role === 'secondary' 23 - ? secondaryCleanupPrefixes 24 - : primaryCleanupPrefixes; 18 + const createAccount = ({ role = "primary", ...account } = {}) => { 19 + const cleanupPostPrefixes = 20 + role === "secondary" ? secondaryCleanupPrefixes : primaryCleanupPrefixes; 25 21 26 22 return createAccountConfig({ 27 23 cleanupPostPrefixes, ··· 37 33 artifactsDir: `data/browser-smoke/${name}-${mode}`, 38 34 }; 39 35 40 - if (mode === 'single') { 36 + if (mode === "single") { 41 37 return { 42 38 ...base, 43 39 editProfile: true, 44 40 account: { 45 41 handle: primaryHandle, 46 - password: 'replace-me', 42 + password: "replace-me", 47 43 }, 48 44 }; 49 45 } ··· 52 48 ...base, 53 49 primary: { 54 50 handle: primaryHandle, 55 - password: 'replace-me', 51 + password: "replace-me", 56 52 }, 57 53 secondary: { 58 54 handle: secondaryHandle, 59 - password: 'replace-me-too', 55 + password: "replace-me-too", 60 56 }, 61 57 }; 62 58 }; 63 59 64 - const createSingleConfig = ({ 65 - account, 66 - ...rest 67 - } = {}) => { 60 + const createSingleConfig = ({ account, ...rest } = {}) => { 68 61 return createSingleRunConfig({ 69 62 ...rest, 70 63 adapter: name, 71 64 account: createAccount({ 72 - role: 'primary', 65 + role: "primary", 73 66 ...account, 74 67 }), 75 68 }); 76 69 }; 77 70 78 - const createDualConfig = ({ 79 - primary, 80 - secondary, 81 - ...rest 82 - } = {}) => { 71 + const createDualConfig = ({ primary, secondary, ...rest } = {}) => { 83 72 return createDualRunConfig({ 84 73 ...dualSuiteDefaults, 85 74 ...rest, 86 75 adapter: name, 87 76 primary: createAccount({ 88 - role: 'primary', 77 + role: "primary", 89 78 ...primary, 90 79 }), 91 80 secondary: createAccount({ 92 - role: 'secondary', 81 + role: "secondary", 93 82 ...secondary, 94 83 }), 95 84 });
+17 -24
src/adapters/bring-your-own.mjs
··· 2 2 createAccountConfig, 3 3 createDualRunConfig, 4 4 createSingleRunConfig, 5 - } from '../config.mjs'; 5 + } from "../config.mjs"; 6 6 7 7 const createBringYourOwnExampleConfig = ({ mode }) => { 8 8 const base = { 9 - pdsUrl: 'https://your-pds.example', 9 + pdsUrl: "https://your-pds.example", 10 10 artifactsDir: `data/browser-smoke/bring-your-own-${mode}`, 11 - targetHandle: 'alice.mosphere.at', 11 + targetHandle: "alice.mosphere.at", 12 12 strictErrors: true, 13 13 }; 14 14 15 - if (mode === 'single') { 15 + if (mode === "single") { 16 16 return { 17 17 ...base, 18 18 editProfile: true, 19 19 account: { 20 - handle: 'smoke-primary.your-pds.example', 21 - password: 'replace-me', 20 + handle: "smoke-primary.your-pds.example", 21 + password: "replace-me", 22 22 }, 23 23 }; 24 24 } ··· 26 26 return { 27 27 ...base, 28 28 primary: { 29 - handle: 'smoke-primary.your-pds.example', 30 - password: 'replace-me', 29 + handle: "smoke-primary.your-pds.example", 30 + password: "replace-me", 31 31 }, 32 32 secondary: { 33 - handle: 'smoke-secondary.your-pds.example', 34 - password: 'replace-me-too', 33 + handle: "smoke-secondary.your-pds.example", 34 + password: "replace-me-too", 35 35 }, 36 36 }; 37 37 }; 38 38 39 - const createBringYourOwnSingleConfig = ({ 40 - account, 41 - ...rest 42 - } = {}) => { 39 + const createBringYourOwnSingleConfig = ({ account, ...rest } = {}) => { 43 40 return createSingleRunConfig({ 44 41 ...rest, 45 42 account: createAccountConfig(account), 46 43 }); 47 44 }; 48 45 49 - const createBringYourOwnDualConfig = ({ 50 - primary, 51 - secondary, 52 - ...rest 53 - } = {}) => { 46 + const createBringYourOwnDualConfig = ({ primary, secondary, ...rest } = {}) => { 54 47 return createDualRunConfig({ 55 48 ...rest, 56 49 primary: createAccountConfig(primary), ··· 59 52 }; 60 53 61 54 export const BRING_YOUR_OWN_ADAPTER = Object.freeze({ 62 - name: 'bring-your-own', 63 - description: 'Use existing accounts on any PDS with minimal configuration.', 64 - accountStrategy: 'existing-accounts', 55 + name: "bring-your-own", 56 + description: "Use existing accounts on any PDS with minimal configuration.", 57 + accountStrategy: "existing-accounts", 65 58 notes: [ 66 - 'This is the default adapter and the lowest-friction path for non-Perl PDS implementations.', 67 - 'The suite will not create accounts for you. Supply one account for single-mode or two for dual-mode.', 59 + "This is the default adapter and the lowest-friction path for non-Perl PDS implementations.", 60 + "The suite will not create accounts for you. Supply one account for single-mode or two for dual-mode.", 68 61 ], 69 62 createSingleConfig: createBringYourOwnSingleConfig, 70 63 createDualConfig: createBringYourOwnDualConfig,
+24 -25
src/adapters/perlsky.mjs
··· 1 - import { createRoleBasedAdapter } from './adapter-builder.mjs'; 1 + import { createRoleBasedAdapter } from "./adapter-builder.mjs"; 2 2 3 3 export const PERLSKY_PRIMARY_CLEANUP_PREFIXES = Object.freeze([ 4 - 'perlsky browser smoke ', 4 + "perlsky browser smoke ", 5 5 ]); 6 6 7 7 export const PERLSKY_SECONDARY_CLEANUP_PREFIXES = Object.freeze([ 8 - 'perlsky browser secondary ', 8 + "perlsky browser secondary ", 9 9 ]); 10 10 11 11 export const PERLSKY_REMOTE_REPLY_POST_URL = 12 - 'https://bsky.app/profile/alice.mosphere.at/post/3mgu5lgnsnk22'; 12 + "https://bsky.app/profile/alice.mosphere.at/post/3mgu5lgnsnk22"; 13 13 14 14 const perlskyRoleDefaults = (role) => { 15 - if (role === 'secondary') { 15 + if (role === "secondary") { 16 16 return { 17 - postText: 'perlsky browser secondary post', 18 - quoteText: 'perlsky browser secondary quote', 19 - replyText: 'perlsky browser secondary reply', 20 - profileNote: 'perlsky browser secondary profile edit', 17 + postText: "perlsky browser secondary post", 18 + quoteText: "perlsky browser secondary quote", 19 + replyText: "perlsky browser secondary reply", 20 + profileNote: "perlsky browser secondary profile edit", 21 21 }; 22 22 } 23 23 24 24 return { 25 - postText: 'perlsky browser smoke post', 26 - quoteText: 'perlsky browser smoke quote', 27 - replyText: 'perlsky browser smoke reply', 28 - profileNote: 'perlsky browser smoke profile edit', 25 + postText: "perlsky browser smoke post", 26 + quoteText: "perlsky browser smoke quote", 27 + replyText: "perlsky browser smoke reply", 28 + profileNote: "perlsky browser smoke profile edit", 29 29 }; 30 30 }; 31 31 32 - const { 33 - adapter: PERLSKY_ADAPTER, 34 - } = createRoleBasedAdapter({ 35 - name: 'perlsky', 36 - description: 'Use perlsky-flavored defaults like cleanup prefixes and adapter tagging.', 37 - accountStrategy: 'existing-accounts-or-bootstrap', 32 + const { adapter: PERLSKY_ADAPTER } = createRoleBasedAdapter({ 33 + name: "perlsky", 34 + description: 35 + "Use perlsky-flavored defaults like cleanup prefixes and adapter tagging.", 36 + accountStrategy: "existing-accounts-or-bootstrap", 38 37 notes: [ 39 - 'The standalone suite still expects credentials in the config.', 40 - 'perlsky-specific account bootstrap and reusable-pair helpers live in perlsky, not in atproto-smoke itself.', 38 + "The standalone suite still expects credentials in the config.", 39 + "perlsky-specific account bootstrap and reusable-pair helpers live in perlsky, not in atproto-smoke itself.", 41 40 ], 42 41 exampleBase: { 43 - pdsUrl: 'https://perlsky.mosphere.at', 44 - targetHandle: 'alice.mosphere.at', 42 + pdsUrl: "https://perlsky.mosphere.at", 43 + targetHandle: "alice.mosphere.at", 45 44 strictErrors: true, 46 45 remoteReplyPostUrl: PERLSKY_REMOTE_REPLY_POST_URL, 47 - primaryHandle: 'smoke-primary.perlsky.mosphere.at', 48 - secondaryHandle: 'smoke-secondary.perlsky.mosphere.at', 46 + primaryHandle: "smoke-primary.perlsky.mosphere.at", 47 + secondaryHandle: "smoke-secondary.perlsky.mosphere.at", 49 48 }, 50 49 roleDefaults: perlskyRoleDefaults, 51 50 primaryCleanupPrefixes: PERLSKY_PRIMARY_CLEANUP_PREFIXES,
+4 -4
src/adapters/registry.mjs
··· 1 - import { BRING_YOUR_OWN_ADAPTER } from './bring-your-own.mjs'; 2 - import { PERLSKY_ADAPTER } from './perlsky.mjs'; 3 - import { TRANQUIL_PDS_ADAPTER } from './tranquil-pds.mjs'; 1 + import { BRING_YOUR_OWN_ADAPTER } from "./bring-your-own.mjs"; 2 + import { PERLSKY_ADAPTER } from "./perlsky.mjs"; 3 + import { TRANQUIL_PDS_ADAPTER } from "./tranquil-pds.mjs"; 4 4 5 5 /** 6 6 * Adapter definitions normalize raw user config into smoke-suite config. ··· 21 21 22 22 export const ADAPTER_NAMES = Object.freeze(Object.keys(ADAPTERS)); 23 23 24 - export const getAdapter = (name = 'bring-your-own') => { 24 + export const getAdapter = (name = "bring-your-own") => { 25 25 const adapter = ADAPTERS[name]; 26 26 if (!adapter) { 27 27 throw new Error(`unsupported adapter: ${name}`);
+23 -24
src/adapters/tranquil-pds.mjs
··· 1 - import { createRoleBasedAdapter } from './adapter-builder.mjs'; 1 + import { createRoleBasedAdapter } from "./adapter-builder.mjs"; 2 2 3 3 export const TRANQUIL_PDS_PRIMARY_CLEANUP_PREFIXES = Object.freeze([ 4 - 'tranquil browser smoke ', 4 + "tranquil browser smoke ", 5 5 ]); 6 6 7 7 export const TRANQUIL_PDS_SECONDARY_CLEANUP_PREFIXES = Object.freeze([ 8 - 'tranquil browser secondary ', 8 + "tranquil browser secondary ", 9 9 ]); 10 10 11 11 const tranquilRoleDefaults = (role) => { 12 - if (role === 'secondary') { 12 + if (role === "secondary") { 13 13 return { 14 - postText: 'tranquil browser secondary post', 15 - quoteText: 'tranquil browser secondary quote', 16 - replyText: 'tranquil browser secondary reply', 17 - profileNote: 'tranquil browser secondary profile edit', 14 + postText: "tranquil browser secondary post", 15 + quoteText: "tranquil browser secondary quote", 16 + replyText: "tranquil browser secondary reply", 17 + profileNote: "tranquil browser secondary profile edit", 18 18 }; 19 19 } 20 20 21 21 return { 22 - postText: 'tranquil browser smoke post', 23 - quoteText: 'tranquil browser smoke quote', 24 - replyText: 'tranquil browser smoke reply', 25 - profileNote: 'tranquil browser smoke profile edit', 22 + postText: "tranquil browser smoke post", 23 + quoteText: "tranquil browser smoke quote", 24 + replyText: "tranquil browser smoke reply", 25 + profileNote: "tranquil browser smoke profile edit", 26 26 }; 27 27 }; 28 28 29 - const { 30 - adapter: TRANQUIL_PDS_ADAPTER, 31 - } = createRoleBasedAdapter({ 32 - name: 'tranquil-pds', 33 - description: 'Use tranquil-pds-flavored defaults like cleanup prefixes and hosted example handles.', 34 - accountStrategy: 'self-register-or-existing-accounts', 29 + const { adapter: TRANQUIL_PDS_ADAPTER } = createRoleBasedAdapter({ 30 + name: "tranquil-pds", 31 + description: 32 + "Use tranquil-pds-flavored defaults like cleanup prefixes and hosted example handles.", 33 + accountStrategy: "self-register-or-existing-accounts", 35 34 notes: [ 36 - 'The standalone suite still expects credentials in the config.', 37 - 'tranquil-pds can self-register accounts via com.atproto.server.createAccount, but that bootstrap stays outside the generic smoke runner.', 35 + "The standalone suite still expects credentials in the config.", 36 + "tranquil-pds can self-register accounts via com.atproto.server.createAccount, but that bootstrap stays outside the generic smoke runner.", 38 37 ], 39 38 exampleBase: { 40 - pdsUrl: 'https://tranquil.mosphere.at', 41 - targetHandle: 'alice.tranquil.mosphere.at', 39 + pdsUrl: "https://tranquil.mosphere.at", 40 + targetHandle: "alice.tranquil.mosphere.at", 42 41 strictErrors: true, 43 - primaryHandle: 'smoke-primary.tranquil.mosphere.at', 44 - secondaryHandle: 'smoke-secondary.tranquil.mosphere.at', 42 + primaryHandle: "smoke-primary.tranquil.mosphere.at", 43 + secondaryHandle: "smoke-secondary.tranquil.mosphere.at", 45 44 }, 46 45 roleDefaults: tranquilRoleDefaults, 47 46 primaryCleanupPrefixes: TRANQUIL_PDS_PRIMARY_CLEANUP_PREFIXES,
+3 -3
src/browser/lib/dual-actions.mjs
··· 1 - import { createDualFeedActions } from './dual-actions/feed.mjs'; 2 - import { createDualModerationActions } from './dual-actions/moderation.mjs'; 3 - import { createDualProfileActions } from './dual-actions/profile.mjs'; 1 + import { createDualFeedActions } from "./dual-actions/feed.mjs"; 2 + import { createDualModerationActions } from "./dual-actions/moderation.mjs"; 3 + import { createDualProfileActions } from "./dual-actions/profile.mjs"; 4 4 5 5 export const createDualActions = (options) => { 6 6 return {
+22 -14
src/browser/lib/dual-actions/feed.mjs
··· 1 - import { createPageAuthActions } from '../page-auth-actions.mjs'; 2 - import { createPageFeedActions } from '../page-feed-actions.mjs'; 3 - import { dismissBlockingOverlays } from '../runtime-utils.mjs'; 1 + import { createPageAuthActions } from "../page-auth-actions.mjs"; 2 + import { createPageFeedActions } from "../page-feed-actions.mjs"; 3 + import { dismissBlockingOverlays } from "../runtime-utils.mjs"; 4 4 5 5 export const createDualFeedActions = ({ 6 6 config, ··· 13 13 appUrl: config.appUrl, 14 14 appBaseUrl, 15 15 wait, 16 - loginToBlueskyApp: async () => undefined, 16 + loginToBlueskyApp: () => undefined, 17 17 }); 18 18 const feedActions = createPageFeedActions({ 19 19 wait, ··· 23 23 }); 24 24 25 25 const waitForNotificationsFeed = async (page) => { 26 - const feed = page.getByTestId('notifsFeed').first(); 26 + const feed = page.getByTestId("notifsFeed").first(); 27 27 if (await feed.count()) { 28 - await feed.waitFor({ state: 'visible', timeout: 15000 }); 28 + await feed.waitFor({ state: "visible", timeout: 15000 }); 29 29 return feed; 30 30 } 31 31 return null; ··· 33 33 34 34 const openReportPostDraft = async (page, row) => { 35 35 await feedActions.openPostOptions(page, row); 36 - await page.getByRole('menuitem', { name: /report post/i }).click({ noWaitAfter: true }); 36 + await page 37 + .getByRole("menuitem", { name: /report post/i }) 38 + .click({ noWaitAfter: true }); 37 39 const dialog = page.locator('[role="dialog"]').last(); 38 - await dialog.waitFor({ state: 'visible', timeout: 10000 }); 39 - await dialog.getByRole('button', { name: /create report for other/i }).click({ noWaitAfter: true }); 40 + await dialog.waitFor({ state: "visible", timeout: 10000 }); 41 + await dialog 42 + .getByRole("button", { name: /create report for other/i }) 43 + .click({ noWaitAfter: true }); 40 44 await wait(page, 1000); 41 - const submit = dialog.getByRole('button', { name: /submit report/i }).last(); 42 - await submit.waitFor({ state: 'visible', timeout: 10000 }); 45 + const submit = dialog 46 + .getByRole("button", { name: /submit report/i }) 47 + .last(); 48 + await submit.waitFor({ state: "visible", timeout: 10000 }); 43 49 const body = normalizeText(await dialog.textContent()); 44 - const close = dialog.getByRole('button', { name: /close active dialog/i }).last(); 50 + const close = dialog 51 + .getByRole("button", { name: /close active dialog/i }) 52 + .last(); 45 53 if (await close.count()) { 46 54 await close.click({ noWaitAfter: true }); 47 55 } else { 48 - await page.keyboard.press('Escape').catch(() => undefined); 56 + await page.keyboard.press("Escape").catch(() => undefined); 49 57 } 50 58 await wait(page, 1000); 51 59 return { 52 - note: 'opened report draft without submitting', 60 + note: "opened report draft without submitting", 53 61 submitVisible: true, 54 62 body, 55 63 };
+43 -29
src/browser/lib/dual-actions/moderation.mjs
··· 1 1 export const createDualModerationActions = ({ wait }) => { 2 2 const openProfileMenu = async (page) => { 3 - const btn = page.getByTestId('profileHeaderDropdownBtn').first(); 4 - await btn.waitFor({ state: 'visible', timeout: 15000 }); 3 + const btn = page.getByTestId("profileHeaderDropdownBtn").first(); 4 + await btn.waitFor({ state: "visible", timeout: 15000 }); 5 5 await btn.click({ noWaitAfter: true }); 6 6 const menu = page.locator('[role="menu"]').last(); 7 - await menu.waitFor({ state: 'visible', timeout: 10000 }); 7 + await menu.waitFor({ state: "visible", timeout: 10000 }); 8 8 return menu; 9 9 }; 10 10 11 - const menuItems = async (page) => 12 - page.locator('[role="menuitem"]').evaluateAll((els) => 13 - els.map((el) => (el.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean), 14 - ); 11 + const menuItems = (page) => 12 + page 13 + .locator('[role="menuitem"]') 14 + .evaluateAll((els) => 15 + els 16 + .map((el) => (el.textContent || "").replace(/\s+/g, " ").trim()) 17 + .filter(Boolean), 18 + ); 15 19 16 20 const closeActiveMenu = async (page) => { 17 21 const backdrop = page.locator('[aria-label*="backdrop"]').last(); 18 22 if (await backdrop.count()) { 19 - await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 23 + await backdrop 24 + .click({ force: true, noWaitAfter: true }) 25 + .catch(() => undefined); 20 26 await wait(page, 400); 21 27 return; 22 28 } 23 - await page.keyboard.press('Escape').catch(() => undefined); 29 + await page.keyboard.press("Escape").catch(() => undefined); 24 30 await wait(page, 400); 25 31 }; 26 32 ··· 29 35 const items = await menuItems(page); 30 36 if (items.some((item) => /unmute account/i.test(item))) { 31 37 await closeActiveMenu(page); 32 - return { note: 'already muted' }; 38 + return { note: "already muted" }; 33 39 } 34 - await page.getByRole('menuitem', { name: /mute account/i }).click({ noWaitAfter: true }); 40 + await page 41 + .getByRole("menuitem", { name: /mute account/i }) 42 + .click({ noWaitAfter: true }); 35 43 await wait(page, 1500); 36 44 await openProfileMenu(page); 37 45 const after = await menuItems(page); 38 46 await closeActiveMenu(page); 39 47 if (!after.some((item) => /unmute account/i.test(item))) { 40 - throw new Error('mute account did not switch menu state'); 48 + throw new Error("mute account did not switch menu state"); 41 49 } 42 - return { note: 'muted account' }; 50 + return { note: "muted account" }; 43 51 }; 44 52 45 53 const ensureProfileUnmuted = async (page) => { ··· 47 55 const items = await menuItems(page); 48 56 if (!items.some((item) => /unmute account/i.test(item))) { 49 57 await closeActiveMenu(page); 50 - return { note: 'already unmuted' }; 58 + return { note: "already unmuted" }; 51 59 } 52 - await page.getByRole('menuitem', { name: /unmute account/i }).click({ noWaitAfter: true }); 60 + await page 61 + .getByRole("menuitem", { name: /unmute account/i }) 62 + .click({ noWaitAfter: true }); 53 63 await wait(page, 1500); 54 64 await openProfileMenu(page); 55 65 const after = await menuItems(page); 56 66 await closeActiveMenu(page); 57 67 if (!after.some((item) => /mute account/i.test(item))) { 58 - throw new Error('unmute account did not restore menu state'); 68 + throw new Error("unmute account did not restore menu state"); 59 69 } 60 - return { note: 'unmuted account' }; 70 + return { note: "unmuted account" }; 61 71 }; 62 72 63 73 const blockProfile = async (page) => { ··· 65 75 const items = await menuItems(page); 66 76 if (items.some((item) => /unblock account/i.test(item))) { 67 77 await closeActiveMenu(page); 68 - return { note: 'already blocked' }; 78 + return { note: "already blocked" }; 69 79 } 70 - await page.getByRole('menuitem', { name: /block account/i }).click({ noWaitAfter: true }); 80 + await page 81 + .getByRole("menuitem", { name: /block account/i }) 82 + .click({ noWaitAfter: true }); 71 83 const dialog = page.locator('[role="dialog"]').last(); 72 - await dialog.waitFor({ state: 'visible', timeout: 10000 }); 73 - await dialog.getByRole('button', { name: /^Block$/i }).click({ noWaitAfter: true }); 84 + await dialog.waitFor({ state: "visible", timeout: 10000 }); 85 + await dialog 86 + .getByRole("button", { name: /^Block$/i }) 87 + .click({ noWaitAfter: true }); 74 88 await wait(page, 2500); 75 - const unblock = page.getByRole('button', { name: /unblock/i }).first(); 89 + const unblock = page.getByRole("button", { name: /unblock/i }).first(); 76 90 if (!(await unblock.count())) { 77 - throw new Error('block account did not expose an unblock button'); 91 + throw new Error("block account did not expose an unblock button"); 78 92 } 79 - return { note: 'blocked account' }; 93 + return { note: "blocked account" }; 80 94 }; 81 95 82 96 const unblockProfile = async (page) => { 83 - const unblock = page.getByRole('button', { name: /unblock/i }).first(); 97 + const unblock = page.getByRole("button", { name: /unblock/i }).first(); 84 98 if (!(await unblock.count())) { 85 - return { note: 'already unblocked' }; 99 + return { note: "already unblocked" }; 86 100 } 87 101 await unblock.click({ noWaitAfter: true }); 88 102 await wait(page, 1000); 89 103 const dialog = page.locator('[role="dialog"]').last(); 90 - const confirm = dialog.getByRole('button', { name: /unblock/i }).last(); 104 + const confirm = dialog.getByRole("button", { name: /unblock/i }).last(); 91 105 if (await confirm.count()) { 92 106 await confirm.click({ noWaitAfter: true }); 93 107 } 94 108 await wait(page, 1500); 95 109 const blockedBadge = page.getByText(/user blocked/i).first(); 96 110 if (await blockedBadge.count()) { 97 - throw new Error('profile still appears blocked after unblock'); 111 + throw new Error("profile still appears blocked after unblock"); 98 112 } 99 - return { note: 'unblocked account' }; 113 + return { note: "unblocked account" }; 100 114 }; 101 115 102 116 return {
+111 -49
src/browser/lib/dual-actions/profile.mjs
··· 4 4 loginToBlueskyApp, 5 5 normalizeText, 6 6 pollJsonUntil, 7 - } from '../runtime-utils.mjs'; 8 - import { createPageAuthActions } from '../page-auth-actions.mjs'; 9 - import { createPageFeedActions } from '../page-feed-actions.mjs'; 10 - import { createPageProfileEditActions } from '../page-profile-edit-actions.mjs'; 7 + } from "../runtime-utils.mjs"; 8 + import { createPageAuthActions } from "../page-auth-actions.mjs"; 9 + import { createPageFeedActions } from "../page-feed-actions.mjs"; 10 + import { createPageProfileEditActions } from "../page-profile-edit-actions.mjs"; 11 11 12 12 export const createDualProfileActions = ({ 13 13 appBaseUrl, ··· 41 41 }); 42 42 43 43 const parseCompactCount = (raw) => { 44 - if (typeof raw !== 'string') { 44 + if (typeof raw !== "string") { 45 45 return undefined; 46 46 } 47 - const normalized = raw.replace(/,/g, '').trim(); 47 + const normalized = raw.replace(/,/g, "").trim(); 48 48 const match = normalized.match(/^([0-9]+(?:\.[0-9]+)?)([KMB])?$/i); 49 49 if (!match) { 50 50 return undefined; 51 51 } 52 52 const base = Number(match[1]); 53 - const suffix = (match[2] || '').toUpperCase(); 54 - const multiplier = suffix === 'K' ? 1_000 : suffix === 'M' ? 1_000_000 : suffix === 'B' ? 1_000_000_000 : 1; 53 + const suffix = (match[2] || "").toUpperCase(); 54 + const multiplier = 55 + suffix === "K" 56 + ? 1_000 57 + : suffix === "M" 58 + ? 1_000_000 59 + : suffix === "B" 60 + ? 1_000_000_000 61 + : 1; 55 62 return Math.round(base * multiplier); 56 63 }; 57 64 ··· 66 73 }); 67 74 }; 68 75 69 - const completeAgeAssuranceIfNeeded = async (page, account) => 76 + const completeAgeAssuranceIfNeeded = (page, account) => 70 77 authActions.completeAgeAssuranceIfNeeded(page, { 71 78 birthdate: account.birthdate, 72 79 notes: summary.notes, ··· 79 86 80 87 const readRenderedProfileCounts = async (page) => { 81 88 const raw = await page.evaluate(() => { 82 - const normalize = (text) => (text || '').replace(/\s+/g, ' ').trim(); 83 - const entries = Array.from(document.querySelectorAll('a[href]')).map((node) => ({ 84 - href: node.getAttribute('href') || '', 85 - text: normalize(node.textContent || ''), 86 - })); 87 - const pick = (pattern) => entries.find((entry) => pattern.test(entry.href))?.text; 88 - const bodyText = normalize(document.body?.innerText || ''); 89 - const followersFallback = bodyText.match(/([0-9][0-9.,]*\s*[KMB]?)\s+followers?/i)?.[0]; 90 - const followsFallback = bodyText.match(/([0-9][0-9.,]*\s*[KMB]?)\s+(?:following|follows?)/i)?.[0]; 89 + const normalize = (text) => (text || "").replace(/\s+/g, " ").trim(); 90 + const entries = Array.from(document.querySelectorAll("a[href]")).map( 91 + (node) => ({ 92 + href: node.getAttribute("href") || "", 93 + text: normalize(node.textContent || ""), 94 + }), 95 + ); 96 + const pick = (pattern) => 97 + entries.find((entry) => pattern.test(entry.href))?.text; 98 + const bodyText = normalize(document.body?.innerText || ""); 99 + const followersFallback = bodyText.match( 100 + /([0-9][0-9.,]*\s*[KMB]?)\s+followers?/i, 101 + )?.[0]; 102 + const followsFallback = bodyText.match( 103 + /([0-9][0-9.,]*\s*[KMB]?)\s+(?:following|follows?)/i, 104 + )?.[0]; 91 105 return { 92 106 followersText: pick(/\/followers(?:[/?#]|$)/i) || followersFallback, 93 107 followsText: pick(/\/follows(?:[/?#]|$)/i) || followsFallback, ··· 95 109 }); 96 110 97 111 const parseLinkedCount = (text, label) => { 98 - if (typeof text !== 'string' || !text.length) { 112 + if (typeof text !== "string" || !text.length) { 99 113 throw new Error(`rendered ${label} link text not found`); 100 114 } 101 - const normalized = text.replace(/\s+/g, ' ').trim(); 115 + const normalized = text.replace(/\s+/g, " ").trim(); 102 116 const match = normalized.match(/([0-9][0-9.,]*\s*[KMB]?)/i); 103 117 if (!match) { 104 - throw new Error(`unable to parse rendered ${label} count from "${normalized}"`); 118 + throw new Error( 119 + `unable to parse rendered ${label} count from "${normalized}"`, 120 + ); 105 121 } 106 - const value = parseCompactCount(match[1].replace(/\s+/g, '')); 122 + const value = parseCompactCount(match[1].replace(/\s+/g, "")); 107 123 if (value === undefined) { 108 - throw new Error(`unable to normalize rendered ${label} count from "${normalized}"`); 124 + throw new Error( 125 + `unable to normalize rendered ${label} count from "${normalized}"`, 126 + ); 109 127 } 110 128 return value; 111 129 }; 112 130 113 131 return { 114 - followersCount: parseLinkedCount(raw.followersText, 'followers'), 115 - followsCount: parseLinkedCount(raw.followsText, 'follows'), 132 + followersCount: parseLinkedCount(raw.followersText, "followers"), 133 + followsCount: parseLinkedCount(raw.followsText, "follows"), 116 134 raw, 117 135 }; 118 136 }; 119 137 120 - const readProfileCountsSnapshot = async (page, viewerAccount, profileHandle) => { 138 + const readProfileCountsSnapshot = async ( 139 + page, 140 + viewerAccount, 141 + profileHandle, 142 + ) => { 121 143 await gotoProfile(page, profileHandle); 122 144 await waitForProfileHandle(page, profileHandle); 123 145 const rendered = await readRenderedProfileCounts(page); 124 - const apiResult = await xrpcJson('app.bsky.actor.getProfile', { 146 + const apiResult = await xrpcJson("app.bsky.actor.getProfile", { 125 147 token: viewerAccount?.accessJwt, 126 148 pdsUrl: viewerAccount?.pdsUrl, 127 149 params: { actor: profileHandle }, ··· 139 161 }; 140 162 }; 141 163 142 - const verifyProfileCountsAfterReload = async (page, viewerAccount, profileHandle, expected, timeoutMs = 30000) => { 164 + const verifyProfileCountsAfterReload = async ( 165 + page, 166 + viewerAccount, 167 + profileHandle, 168 + expected, 169 + timeoutMs = 30000, 170 + ) => { 143 171 const started = Date.now(); 144 172 let snapshot; 145 173 while (Date.now() - started < timeoutMs) { 146 174 try { 147 - snapshot = await readProfileCountsSnapshot(page, viewerAccount, profileHandle); 148 - const matches = Object.entries(expected).every(([key, value]) => 149 - snapshot?.rendered?.[key] === value && snapshot?.api?.[key] === value); 175 + snapshot = await readProfileCountsSnapshot( 176 + page, 177 + viewerAccount, 178 + profileHandle, 179 + ); 180 + const matches = Object.entries(expected).every( 181 + ([key, value]) => 182 + snapshot?.rendered?.[key] === value && 183 + snapshot?.api?.[key] === value, 184 + ); 150 185 if (matches) { 151 186 return snapshot; 152 187 } ··· 161 196 ); 162 197 }; 163 198 164 - const readProfileCountsAfterReload = async (page, viewerAccount, profileHandle, timeoutMs = 30000) => { 199 + const readProfileCountsAfterReload = async ( 200 + page, 201 + viewerAccount, 202 + profileHandle, 203 + timeoutMs = 30000, 204 + ) => { 165 205 const started = Date.now(); 166 206 let lastError; 167 207 while (Date.now() - started < timeoutMs) { 168 208 try { 169 - return await readProfileCountsSnapshot(page, viewerAccount, profileHandle); 209 + return await readProfileCountsSnapshot( 210 + page, 211 + viewerAccount, 212 + profileHandle, 213 + ); 170 214 } catch (error) { 171 215 lastError = error; 172 216 await wait(page, 2000); 173 217 } 174 218 } 175 - throw lastError || new Error(`failed to read profile counts for ${profileHandle}`); 219 + throw ( 220 + lastError || 221 + new Error(`failed to read profile counts for ${profileHandle}`) 222 + ); 176 223 }; 177 224 178 225 const composePost = feedActions.composePost; 179 226 180 227 const uploadComposerMedia = async (page) => { 181 228 const mediaFile = await profileEditActions.ensureAvatarFixture(); 182 - const openMedia = page.getByTestId('openMediaBtn').last(); 229 + const openMedia = page.getByTestId("openMediaBtn").last(); 183 230 if (!(await openMedia.count())) { 184 - throw new Error('composer media button unavailable'); 231 + throw new Error("composer media button unavailable"); 185 232 } 186 - const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 233 + const chooserPromise = page.waitForEvent("filechooser", { timeout: 10000 }); 187 234 await openMedia.click({ noWaitAfter: true }); 188 235 const chooser = await chooserPromise; 189 236 await chooser.setFiles(mediaFile); ··· 192 239 }; 193 240 194 241 const composePostWithImage = async (page, text) => { 195 - await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 242 + await page 243 + .locator('[aria-label="Compose new post"]') 244 + .last() 245 + .click({ noWaitAfter: true }); 196 246 await wait(page, 800); 197 247 const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 198 248 await editor.click({ noWaitAfter: true }); 199 249 await editor.fill(text); 200 250 const mediaFile = await uploadComposerMedia(page); 201 251 await wait(page, 500); 202 - await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 252 + await page 253 + .getByRole("button", { name: "Publish post" }) 254 + .click({ noWaitAfter: true }); 203 255 await wait(page, 5000); 204 256 return { mediaFile }; 205 257 }; 206 258 207 - const editProfile = async (page, account) => 259 + const editProfile = (page, account) => 208 260 profileEditActions.editProfile(page, { 209 261 profileNote: account.profileNote, 210 262 handle: account.handle, 211 263 }); 212 264 213 265 const verifyLocalProfileAfterEdit = async (account) => { 214 - const didResult = await xrpcJson('com.atproto.identity.resolveHandle', { 266 + const didResult = await xrpcJson("com.atproto.identity.resolveHandle", { 215 267 pdsUrl: account.pdsUrl, 216 268 params: { handle: account.handle }, 217 269 }); 218 270 if (!didResult.ok || didResult.json?.did !== account.did) { 219 271 throw new Error(`handle did mismatch for ${account.handle}`); 220 272 } 221 - const result = await xrpcJson('com.atproto.repo.getRecord', { 273 + const result = await xrpcJson("com.atproto.repo.getRecord", { 222 274 pdsUrl: account.pdsUrl, 223 275 params: { 224 276 repo: account.did, 225 - collection: 'app.bsky.actor.profile', 226 - rkey: 'self', 277 + collection: "app.bsky.actor.profile", 278 + rkey: "self", 227 279 }, 228 280 }); 229 281 if (!result.ok) { 230 - throw new Error(`profile record lookup failed for ${account.handle}: ${result.status} ${result.text}`); 282 + throw new Error( 283 + `profile record lookup failed for ${account.handle}: ${result.status} ${result.text}`, 284 + ); 231 285 } 232 286 const avatarCid = result.json?.value?.avatar?.ref?.$link; 233 287 const description = result.json?.value?.description; 234 - if (description !== account.profileNote || typeof avatarCid !== 'string' || !avatarCid.length) { 235 - throw new Error(`profile record did not contain expected avatar/description for ${account.handle}`); 288 + if ( 289 + description !== account.profileNote || 290 + typeof avatarCid !== "string" || 291 + !avatarCid.length 292 + ) { 293 + throw new Error( 294 + `profile record did not contain expected avatar/description for ${account.handle}`, 295 + ); 236 296 } 237 297 return { avatarCid, description }; 238 298 }; ··· 245 305 predicate: ({ ok, json }) => 246 306 ok && 247 307 json?.description === account.profileNote && 248 - typeof json?.avatar === 'string' && 308 + typeof json?.avatar === "string" && 249 309 json.avatar.length > 0, 250 310 timeoutMs: publicCheckTimeoutMs, 251 311 fetchJson, 252 312 }); 253 313 const avatarResult = await fetchStatus(result.json.avatar); 254 314 if (!avatarResult.ok) { 255 - throw new Error(`public avatar URL returned ${avatarResult.status} for ${account.handle}`); 315 + throw new Error( 316 + `public avatar URL returned ${avatarResult.status} for ${account.handle}`, 317 + ); 256 318 } 257 319 return { 258 320 avatar: result.json.avatar,
+115 -67
src/browser/lib/dual-api.mjs
··· 2 2 fetchJsonWithTimeout, 3 3 fetchStatusWithTimeout, 4 4 sleep, 5 - } from './runtime-utils.mjs'; 6 - import { derivePdsHost } from '../../config.mjs'; 5 + } from "./runtime-utils.mjs"; 6 + import { derivePdsHost } from "../../config.mjs"; 7 7 8 8 export const createDualApiHelpers = ({ config }) => { 9 9 const fetchJson = (url, options = {}) => fetchJsonWithTimeout(url, options); 10 10 11 - const fetchStatus = (url, options = {}) => fetchStatusWithTimeout(url, options); 11 + const fetchStatus = (url, options = {}) => 12 + fetchStatusWithTimeout(url, options); 12 13 13 14 const collectionFromUri = (uri) => { 14 15 // Example: at://did:plc:123/app.bsky.feed.post/3kabc -> app.bsky.feed.post 15 - if (typeof uri !== 'string') { 16 + if (typeof uri !== "string") { 16 17 return undefined; 17 18 } 18 - const parts = uri.split('/'); 19 + const parts = uri.split("/"); 19 20 return parts.length >= 4 ? parts[3] : undefined; 20 21 }; 21 22 ··· 26 27 if ( 27 28 record && 28 29 record.value && 29 - typeof record.value === 'object' && 30 - typeof innerValue === 'object' && 30 + typeof record.value === "object" && 31 + typeof innerValue === "object" && 31 32 innerValue && 32 33 record.value.$type === undefined && 33 - typeof innerType === 'string' && 34 + typeof innerType === "string" && 34 35 (!expectedCollection || innerType === expectedCollection) 35 36 ) { 36 37 return { ··· 41 42 return record; 42 43 }; 43 44 44 - const xrpcJson = async (nsid, { method = 'GET', token, params, body, timeoutMs, pdsUrl } = {}) => { 45 + const xrpcJson = async ( 46 + nsid, 47 + { method = "GET", token, params, body, timeoutMs, pdsUrl } = {}, 48 + ) => { 45 49 const basePdsUrl = pdsUrl || config.pdsUrl; 46 50 const url = new URL(`${basePdsUrl}/xrpc/${nsid}`); 47 51 if (params) { ··· 49 53 url.searchParams.set(key, value); 50 54 } 51 55 } 52 - const headers = { accept: 'application/json' }; 56 + const headers = { accept: "application/json" }; 53 57 if (token) { 54 58 headers.authorization = `Bearer ${token}`; 55 59 } 56 60 if (body !== undefined) { 57 - headers['content-type'] = 'application/json'; 61 + headers["content-type"] = "application/json"; 58 62 } 59 - const run = (extraHeaders = {}) => fetchJson(url.toString(), { 60 - method, 61 - headers: { 62 - ...headers, 63 - ...extraHeaders, 64 - }, 65 - timeoutMs, 66 - body: body === undefined ? undefined : JSON.stringify(body), 67 - }); 63 + const run = (extraHeaders = {}) => 64 + fetchJson(url.toString(), { 65 + method, 66 + headers: { 67 + ...headers, 68 + ...extraHeaders, 69 + }, 70 + timeoutMs, 71 + body: body === undefined ? undefined : JSON.stringify(body), 72 + }); 68 73 const result = await run(); 69 74 const shouldRetryWithAppViewProxy = 70 - !result.ok && 71 - nsid.startsWith('app.bsky.'); 75 + !result.ok && nsid.startsWith("app.bsky."); 72 76 if (shouldRetryWithAppViewProxy) { 73 77 return run({ 74 - 'atproto-proxy': 'did:web:api.bsky.app#bsky_appview', 78 + "atproto-proxy": "did:web:api.bsky.app#bsky_appview", 75 79 }); 76 80 } 77 81 return result; 78 82 }; 79 83 80 84 const listOwnRecords = async (account, collection, limit = 100) => { 81 - const result = await xrpcJson('com.atproto.repo.listRecords', { 85 + const result = await xrpcJson("com.atproto.repo.listRecords", { 82 86 token: account.accessJwt, 83 87 pdsUrl: account.pdsUrl, 84 88 params: { ··· 96 100 }; 97 101 98 102 const recordRkey = (recordOrUri) => { 99 - const uri = typeof recordOrUri === 'string' ? recordOrUri : recordOrUri?.uri; 100 - return uri?.split('/').pop(); 103 + const uri = 104 + typeof recordOrUri === "string" ? recordOrUri : recordOrUri?.uri; 105 + return uri?.split("/").pop(); 101 106 }; 102 107 103 108 const deleteOwnRecord = async (account, collection, record) => { 104 109 const rkey = recordRkey(record); 105 110 if (!rkey) { 106 - throw new Error(`unable to determine rkey for ${collection} on ${account.handle}`); 111 + throw new Error( 112 + `unable to determine rkey for ${collection} on ${account.handle}`, 113 + ); 107 114 } 108 - const result = await xrpcJson('com.atproto.repo.deleteRecord', { 109 - method: 'POST', 115 + const result = await xrpcJson("com.atproto.repo.deleteRecord", { 116 + method: "POST", 110 117 token: account.accessJwt, 111 118 pdsUrl: account.pdsUrl, 112 119 body: { ··· 123 130 return { rkey }; 124 131 }; 125 132 126 - const purgeOwnRecords = async (account, collection, predicate, limit = 100) => { 133 + const purgeOwnRecords = async ( 134 + account, 135 + collection, 136 + predicate, 137 + limit = 100, 138 + ) => { 127 139 const records = await listOwnRecords(account, collection, limit); 128 140 const doomed = records.filter(predicate); 129 141 for (const record of doomed) { ··· 133 145 return doomed.length; 134 146 }; 135 147 136 - const waitForOwnRecord = async (account, collection, predicate, timeoutMs = 60000) => { 148 + const waitForOwnRecord = async ( 149 + account, 150 + collection, 151 + predicate, 152 + timeoutMs = 60000, 153 + ) => { 137 154 const started = Date.now(); 138 155 while (Date.now() - started < timeoutMs) { 139 156 const records = await listOwnRecords(account, collection); ··· 143 160 } 144 161 await sleep(2000); 145 162 } 146 - throw new Error(`record not observed for ${account.handle} in ${collection}`); 163 + throw new Error( 164 + `record not observed for ${account.handle} in ${collection}`, 165 + ); 147 166 }; 148 167 149 - const waitForOwnPostRecord = async (account, text, timeoutMs = 60000) => { 168 + const waitForOwnPostRecord = (account, text, timeoutMs = 60000) => { 150 169 return waitForOwnRecord( 151 170 account, 152 - 'app.bsky.feed.post', 171 + "app.bsky.feed.post", 153 172 (record) => record?.value?.text === text, 154 173 timeoutMs, 155 174 ); 156 175 }; 157 176 158 - const waitForFollowRecord = async (account, subjectDid, timeoutMs = 60000) => 177 + const waitForFollowRecord = (account, subjectDid, timeoutMs = 60000) => 159 178 waitForOwnRecord( 160 179 account, 161 - 'app.bsky.graph.follow', 180 + "app.bsky.graph.follow", 162 181 (record) => record?.value?.subject === subjectDid, 163 182 timeoutMs, 164 183 ); 165 184 166 - const waitForNoOwnRecord = async (account, collection, predicate, timeoutMs = 60000) => { 185 + const waitForNoOwnRecord = async ( 186 + account, 187 + collection, 188 + predicate, 189 + timeoutMs = 60000, 190 + ) => { 167 191 const started = Date.now(); 168 192 while (Date.now() - started < timeoutMs) { 169 193 const records = await listOwnRecords(account, collection); ··· 172 196 } 173 197 await sleep(2000); 174 198 } 175 - throw new Error(`record still present for ${account.handle} in ${collection}`); 199 + throw new Error( 200 + `record still present for ${account.handle} in ${collection}`, 201 + ); 176 202 }; 177 203 178 - const waitForOwnListRecord = async (account, name, timeoutMs = 60000) => 204 + const waitForOwnListRecord = (account, name, timeoutMs = 60000) => 179 205 waitForOwnRecord( 180 206 account, 181 - 'app.bsky.graph.list', 207 + "app.bsky.graph.list", 182 208 (record) => record?.value?.name === name, 183 209 timeoutMs, 184 210 ); 185 211 186 - const waitForOwnListItemRecord = async (account, listUri, subjectDid, timeoutMs = 60000) => 212 + const waitForOwnListItemRecord = ( 213 + account, 214 + listUri, 215 + subjectDid, 216 + timeoutMs = 60000, 217 + ) => 187 218 waitForOwnRecord( 188 219 account, 189 - 'app.bsky.graph.listitem', 190 - (record) => record?.value?.list === listUri && record?.value?.subject === subjectDid, 220 + "app.bsky.graph.listitem", 221 + (record) => 222 + record?.value?.list === listUri && 223 + record?.value?.subject === subjectDid, 191 224 timeoutMs, 192 225 ); 193 226 194 227 const createSession = async (account) => { 195 228 const identifier = account.loginIdentifier || account.handle; 196 - const result = await xrpcJson('com.atproto.server.createSession', { 197 - method: 'POST', 229 + const result = await xrpcJson("com.atproto.server.createSession", { 230 + method: "POST", 198 231 pdsUrl: account.pdsUrl, 199 232 body: { 200 233 identifier, ··· 202 235 }, 203 236 }); 204 237 if (!result.ok) { 205 - throw new Error(`createSession failed for ${identifier}: ${result.status} ${result.text}`); 238 + throw new Error( 239 + `createSession failed for ${identifier}: ${result.status} ${result.text}`, 240 + ); 206 241 } 207 242 return result.json; 208 243 }; ··· 217 252 const started = Date.now(); 218 253 let last; 219 254 while (Date.now() - started < timeoutMs) { 220 - last = await xrpcJson('app.bsky.notification.listNotifications', { 255 + last = await xrpcJson("app.bsky.notification.listNotifications", { 221 256 token: account.accessJwt, 222 257 pdsUrl: account.pdsUrl, 223 - params: { limit: '100' }, 258 + params: { limit: "100" }, 224 259 timeoutMs: 15000, 225 260 }); 226 261 if (last.ok && Array.isArray(last.json?.notifications)) { ··· 228 263 if (item?.author?.handle !== authorHandle) { 229 264 return false; 230 265 } 231 - const indexedAt = Date.parse(item?.indexedAt || item?.record?.createdAt || 0); 266 + const indexedAt = Date.parse( 267 + item?.indexedAt || item?.record?.createdAt || 0, 268 + ); 232 269 if (Number.isFinite(minIndexedAt) && indexedAt < minIndexedAt) { 233 270 return false; 234 271 } ··· 245 282 await sleep(5000); 246 283 } 247 284 throw new Error( 248 - `notifications not observed for ${account.handle} within ${timeoutMs}ms; last status=${last?.status ?? 'none'} body=${last?.text ?? ''}`, 285 + `notifications not observed for ${account.handle} within ${timeoutMs}ms; last status=${last?.status ?? "none"} body=${last?.text ?? ""}`, 249 286 ); 250 287 }; 251 288 ··· 255 292 pdsHost: entry.pdsHost || derivePdsHost(entry.pdsUrl || config.pdsUrl), 256 293 loginIdentifier: entry.loginIdentifier || entry.handle, 257 294 mediaPostText: entry.mediaPostText || `${entry.postText} image`, 258 - shortHandle: entry.handle.replace(/^@/, ''), 295 + shortHandle: entry.handle.replace(/^@/, ""), 259 296 }); 260 297 261 298 const prepareAccounts = ({ primaryConfig, secondaryConfig, startedAt }) => { 262 - const runToken = startedAt.replace(/\D/g, '').slice(0, 14); 299 + const runToken = startedAt.replace(/\D/g, "").slice(0, 14); 263 300 const primary = accountFromConfig({ 264 301 ...primaryConfig, 265 302 listName: primaryConfig.listName || `Smoke List ${runToken}`, 266 - listDescription: primaryConfig.listDescription || `smoke list description ${runToken}`, 267 - listUpdatedName: primaryConfig.listUpdatedName || `Updated Smoke List ${runToken}`, 303 + listDescription: 304 + primaryConfig.listDescription || `smoke list description ${runToken}`, 305 + listUpdatedName: 306 + primaryConfig.listUpdatedName || `Updated Smoke List ${runToken}`, 268 307 listUpdatedDescription: 269 - primaryConfig.listUpdatedDescription || `updated smoke list description ${runToken}`, 308 + primaryConfig.listUpdatedDescription || 309 + `updated smoke list description ${runToken}`, 270 310 }); 271 311 const secondary = accountFromConfig(secondaryConfig); 272 312 return { primary, secondary }; 273 313 }; 274 314 275 315 const stalePostPrefixesFor = (account) => { 276 - if (Array.isArray(account.cleanupPostPrefixes) && account.cleanupPostPrefixes.length) { 316 + if ( 317 + Array.isArray(account.cleanupPostPrefixes) && 318 + account.cleanupPostPrefixes.length 319 + ) { 277 320 return account.cleanupPostPrefixes; 278 321 } 279 - return [account.postText].filter((value) => typeof value === 'string' && value.length > 0); 322 + return [account.postText].filter( 323 + (value) => typeof value === "string" && value.length > 0, 324 + ); 280 325 }; 281 326 282 - const staleListPrefixes = ['Smoke List ', 'Updated Smoke List ']; 327 + const staleListPrefixes = ["Smoke List ", "Updated Smoke List "]; 283 328 284 329 const cleanupStaleSmokeArtifacts = async (account) => { 285 330 const postPrefixes = stalePostPrefixesFor(account); 286 331 const deletedPosts = await purgeOwnRecords( 287 332 account, 288 - 'app.bsky.feed.post', 289 - (record) => postPrefixes.some((prefix) => (record?.value?.text || '').startsWith(prefix)), 333 + "app.bsky.feed.post", 334 + (record) => 335 + postPrefixes.some((prefix) => 336 + (record?.value?.text || "").startsWith(prefix), 337 + ), 290 338 ); 291 - const lists = await listOwnRecords(account, 'app.bsky.graph.list', 100); 339 + const lists = await listOwnRecords(account, "app.bsky.graph.list", 100); 292 340 const doomedLists = lists.filter((record) => 293 - staleListPrefixes.some((prefix) => (record?.value?.name || '').startsWith(prefix)), 341 + staleListPrefixes.some((prefix) => 342 + (record?.value?.name || "").startsWith(prefix), 343 + ), 294 344 ); 295 345 const doomedListUris = new Set(doomedLists.map((record) => record.uri)); 296 346 const deletedListItems = doomedListUris.size 297 - ? await purgeOwnRecords( 298 - account, 299 - 'app.bsky.graph.listitem', 300 - (record) => doomedListUris.has(record?.value?.list), 347 + ? await purgeOwnRecords(account, "app.bsky.graph.listitem", (record) => 348 + doomedListUris.has(record?.value?.list), 301 349 ) 302 350 : 0; 303 351 let deletedLists = 0; 304 352 for (const record of doomedLists) { 305 - await deleteOwnRecord(account, 'app.bsky.graph.list', record); 353 + await deleteOwnRecord(account, "app.bsky.graph.list", record); 306 354 deletedLists += 1; 307 355 await sleep(250); 308 356 }
+37 -13
src/browser/lib/dual-browser.mjs
··· 1 - import path from 'node:path'; 2 - import { chromium } from './playwright-runtime.mjs'; 1 + import path from "node:path"; 2 + import { chromium } from "./playwright-runtime.mjs"; 3 3 import { 4 4 attachPageLogging, 5 5 buttonText, ··· 9 9 finalizeSummary, 10 10 launchBrowserWithFallback, 11 11 normalizeText, 12 - } from './runtime-utils.mjs'; 12 + } from "./runtime-utils.mjs"; 13 13 import { 14 14 isIgnoredConsoleEntry, 15 15 isIgnoredHttpFailureEntry, 16 16 isIgnoredRequestFailureEntry, 17 - } from './failure-rules.mjs'; 17 + } from "./failure-rules.mjs"; 18 18 19 19 export const setupDualBrowser = async ({ config, summary }) => { 20 - const browser = await launchBrowserWithFallback({ chromium, config, summary }); 20 + const browser = await launchBrowserWithFallback({ 21 + chromium, 22 + config, 23 + summary, 24 + }); 21 25 const primaryContext = await browser.newContext({ 22 26 viewport: { width: 1440, height: 1000 }, 23 27 }); ··· 27 31 const primaryPage = await primaryContext.newPage(); 28 32 const secondaryPage = await secondaryContext.newPage(); 29 33 30 - attachPageLogging({ summary, page: primaryPage, pageName: 'primary', xrpcLimit: 300 }); 31 - attachPageLogging({ summary, page: secondaryPage, pageName: 'secondary', xrpcLimit: 300 }); 34 + attachPageLogging({ 35 + summary, 36 + page: primaryPage, 37 + pageName: "primary", 38 + xrpcLimit: 300, 39 + }); 40 + attachPageLogging({ 41 + summary, 42 + page: secondaryPage, 43 + pageName: "secondary", 44 + xrpcLimit: 300, 45 + }); 32 46 33 47 return { 34 48 browser, ··· 39 53 }; 40 54 }; 41 55 42 - export const createDualStepHelpers = ({ config, summary, primaryPage, secondaryPage }) => { 56 + export const createDualStepHelpers = ({ 57 + config, 58 + summary, 59 + primaryPage, 60 + secondaryPage, 61 + }) => { 43 62 const stepTimeoutMs = Number(config.stepTimeoutMs || 120000); 44 63 const progressEnabled = config.progress !== false; 45 - const pageFor = (name) => (name === 'primary' ? primaryPage : secondaryPage); 64 + const pageFor = (name) => (name === "primary" ? primaryPage : secondaryPage); 46 65 47 66 const emitProgress = createProgressEmitter({ enabled: progressEnabled }); 48 67 ··· 60 79 captureArtifacts: async ({ name, pageNames, failed }) => { 61 80 const screenshots = {}; 62 81 for (const pageName of pageNames) { 63 - screenshots[pageName] = await screenshot(pageName, failed ? `${name}-error` : name).catch(() => undefined); 82 + screenshots[pageName] = await screenshot( 83 + pageName, 84 + failed ? `${name}-error` : name, 85 + ).catch(() => undefined); 64 86 } 65 87 return { screenshots }; 66 88 }, ··· 99 121 isIgnoredHttpFailure, 100 122 }); 101 123 return Promise.all([ 102 - screenshot('primary', 'final').catch(() => undefined), 103 - screenshot('secondary', 'final').catch(() => undefined), 104 - ]).then(() => closeBrowserSafely({ browser, summary })).then(() => summary); 124 + screenshot("primary", "final").catch(() => undefined), 125 + screenshot("secondary", "final").catch(() => undefined), 126 + ]) 127 + .then(() => closeBrowserSafely({ browser, summary })) 128 + .then(() => summary); 105 129 };
+1 -1
src/browser/lib/dual-scenario.mjs
··· 3 3 runDualPrimaryWavePhase, 4 4 runDualSecondaryWaveAndSettingsPhase, 5 5 runDualSetupPhase, 6 - } from './dual-scenario/phases.mjs'; 6 + } from "./dual-scenario/phases.mjs"; 7 7 8 8 export const runDualScenario = async (ctx) => { 9 9 await runDualSetupPhase(ctx);
+944 -389
src/browser/lib/dual-scenario/phases.mjs
··· 38 38 maybeUnfollow, 39 39 } = ctx; 40 40 41 - await step('primary-login', () => login(primaryPage, primary), { pageNames: ['primary'] }); 42 - await step('primary-age-assurance', () => completeAgeAssuranceIfNeeded(primaryPage, primary), { 43 - optional: true, 44 - pageNames: ['primary'], 41 + await step("primary-login", () => login(primaryPage, primary), { 42 + pageNames: ["primary"], 45 43 }); 46 - await step('secondary-login', () => login(secondaryPage, secondary), { pageNames: ['secondary'] }); 47 - await step('secondary-age-assurance', () => completeAgeAssuranceIfNeeded(secondaryPage, secondary), { 48 - optional: true, 49 - pageNames: ['secondary'], 44 + await step( 45 + "primary-age-assurance", 46 + () => completeAgeAssuranceIfNeeded(primaryPage, primary), 47 + { 48 + optional: true, 49 + pageNames: ["primary"], 50 + }, 51 + ); 52 + await step("secondary-login", () => login(secondaryPage, secondary), { 53 + pageNames: ["secondary"], 50 54 }); 55 + await step( 56 + "secondary-age-assurance", 57 + () => completeAgeAssuranceIfNeeded(secondaryPage, secondary), 58 + { 59 + optional: true, 60 + pageNames: ["secondary"], 61 + }, 62 + ); 51 63 52 64 primary.session = await createSession(primary); 53 65 primary.accessJwt = primary.session.accessJwt; ··· 56 68 secondary.accessJwt = secondary.session.accessJwt; 57 69 secondary.did = secondary.session.did; 58 70 59 - await step('primary-preclean-stale-artifacts', async () => cleanupStaleSmokeArtifacts(primary)); 60 - await step('secondary-preclean-stale-artifacts', async () => cleanupStaleSmokeArtifacts(secondary)); 71 + await step("primary-preclean-stale-artifacts", () => 72 + cleanupStaleSmokeArtifacts(primary), 73 + ); 74 + await step("secondary-preclean-stale-artifacts", () => 75 + cleanupStaleSmokeArtifacts(secondary), 76 + ); 61 77 62 - await step('primary-preclean-reset-follow-secondary', async () => { 63 - await gotoProfile(primaryPage, secondary.handle); 64 - await waitForProfileHandle(primaryPage, secondary.handle); 65 - return maybeUnfollow(primaryPage); 66 - }, { optional: true, pageNames: ['primary'] }); 78 + await step( 79 + "primary-preclean-reset-follow-secondary", 80 + async () => { 81 + await gotoProfile(primaryPage, secondary.handle); 82 + await waitForProfileHandle(primaryPage, secondary.handle); 83 + return maybeUnfollow(primaryPage); 84 + }, 85 + { optional: true, pageNames: ["primary"] }, 86 + ); 67 87 68 - await step('secondary-preclean-reset-follow-primary', async () => { 69 - await gotoProfile(secondaryPage, primary.handle); 70 - await waitForProfileHandle(secondaryPage, primary.handle); 71 - return maybeUnfollow(secondaryPage); 72 - }, { optional: true, pageNames: ['secondary'] }); 88 + await step( 89 + "secondary-preclean-reset-follow-primary", 90 + async () => { 91 + await gotoProfile(secondaryPage, primary.handle); 92 + await waitForProfileHandle(secondaryPage, primary.handle); 93 + return maybeUnfollow(secondaryPage); 94 + }, 95 + { optional: true, pageNames: ["secondary"] }, 96 + ); 73 97 74 - await step('primary-compose-root-post', () => composePost(primaryPage, primary.postText), { 75 - pageNames: ['primary'], 76 - }); 98 + await step( 99 + "primary-compose-root-post", 100 + () => composePost(primaryPage, primary.postText), 101 + { 102 + pageNames: ["primary"], 103 + }, 104 + ); 77 105 78 106 primary.rootPost = await waitForOwnPostRecord(primary, primary.postText); 79 107 80 - await step('primary-own-profile', async () => { 81 - await gotoProfile(primaryPage, primary.handle); 82 - await waitForProfileHandle(primaryPage, primary.handle); 83 - const row = await findRowByPrimaryText(primaryPage, primary.postText, 60000); 84 - const rowTestId = await row.getAttribute('data-testid'); 85 - return { rowTestId }; 86 - }, { pageNames: ['primary'] }); 108 + await step( 109 + "primary-own-profile", 110 + async () => { 111 + await gotoProfile(primaryPage, primary.handle); 112 + await waitForProfileHandle(primaryPage, primary.handle); 113 + const row = await findRowByPrimaryText( 114 + primaryPage, 115 + primary.postText, 116 + 60000, 117 + ); 118 + const rowTestId = await row.getAttribute("data-testid"); 119 + return { rowTestId }; 120 + }, 121 + { pageNames: ["primary"] }, 122 + ); 87 123 88 - await step('primary-own-profile-reload', async () => { 89 - await gotoProfile(primaryPage, primary.handle); 90 - await primaryPage.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); 91 - await waitForProfileHandle(primaryPage, primary.handle); 92 - const row = await findRowByPrimaryText(primaryPage, primary.postText, 60000); 93 - const rowTestId = await row.getAttribute('data-testid'); 94 - return { rowTestId }; 95 - }, { pageNames: ['primary'] }); 124 + await step( 125 + "primary-own-profile-reload", 126 + async () => { 127 + await gotoProfile(primaryPage, primary.handle); 128 + await primaryPage.reload({ 129 + waitUntil: "domcontentloaded", 130 + timeout: 60000, 131 + }); 132 + await waitForProfileHandle(primaryPage, primary.handle); 133 + const row = await findRowByPrimaryText( 134 + primaryPage, 135 + primary.postText, 136 + 60000, 137 + ); 138 + const rowTestId = await row.getAttribute("data-testid"); 139 + return { rowTestId }; 140 + }, 141 + { pageNames: ["primary"] }, 142 + ); 96 143 97 - await step('primary-compose-image-post', async () => composePostWithImage(primaryPage, primary.mediaPostText), { 98 - pageNames: ['primary'], 99 - }); 144 + await step( 145 + "primary-compose-image-post", 146 + () => composePostWithImage(primaryPage, primary.mediaPostText), 147 + { 148 + pageNames: ["primary"], 149 + }, 150 + ); 100 151 101 - await step('primary-image-post-record', async () => { 102 - primary.imagePost = await waitForOwnPostRecord(primary, primary.mediaPostText); 152 + await step("primary-image-post-record", async () => { 153 + primary.imagePost = await waitForOwnPostRecord( 154 + primary, 155 + primary.mediaPostText, 156 + ); 103 157 const embed = primary.imagePost.value?.embed; 104 - if (embed?.$type !== 'app.bsky.embed.images' || !Array.isArray(embed.images) || embed.images.length < 1) { 105 - throw new Error('image post did not persist an app.bsky.embed.images record'); 158 + if ( 159 + embed?.$type !== "app.bsky.embed.images" || 160 + !Array.isArray(embed.images) || 161 + embed.images.length < 1 162 + ) { 163 + throw new Error( 164 + "image post did not persist an app.bsky.embed.images record", 165 + ); 106 166 } 107 167 return { 108 168 uri: primary.imagePost.uri, ··· 111 171 }; 112 172 }); 113 173 114 - await step('secondary-compose-root-post', () => composePost(secondaryPage, secondary.postText), { 115 - pageNames: ['secondary'], 116 - }); 174 + await step( 175 + "secondary-compose-root-post", 176 + () => composePost(secondaryPage, secondary.postText), 177 + { 178 + pageNames: ["secondary"], 179 + }, 180 + ); 117 181 118 - secondary.rootPost = await waitForOwnPostRecord(secondary, secondary.postText); 182 + secondary.rootPost = await waitForOwnPostRecord( 183 + secondary, 184 + secondary.postText, 185 + ); 119 186 120 - await step('secondary-own-profile', async () => { 121 - await gotoProfile(secondaryPage, secondary.handle); 122 - await waitForProfileHandle(secondaryPage, secondary.handle); 123 - const row = await findRowByPrimaryText(secondaryPage, secondary.postText, 60000); 124 - const rowTestId = await row.getAttribute('data-testid'); 125 - return { rowTestId }; 126 - }, { pageNames: ['secondary'] }); 187 + await step( 188 + "secondary-own-profile", 189 + async () => { 190 + await gotoProfile(secondaryPage, secondary.handle); 191 + await waitForProfileHandle(secondaryPage, secondary.handle); 192 + const row = await findRowByPrimaryText( 193 + secondaryPage, 194 + secondary.postText, 195 + 60000, 196 + ); 197 + const rowTestId = await row.getAttribute("data-testid"); 198 + return { rowTestId }; 199 + }, 200 + { pageNames: ["secondary"] }, 201 + ); 127 202 128 - await step('secondary-own-profile-reload', async () => { 129 - await gotoProfile(secondaryPage, secondary.handle); 130 - await secondaryPage.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); 131 - await waitForProfileHandle(secondaryPage, secondary.handle); 132 - const row = await findRowByPrimaryText(secondaryPage, secondary.postText, 60000); 133 - const rowTestId = await row.getAttribute('data-testid'); 134 - return { rowTestId }; 135 - }, { pageNames: ['secondary'] }); 203 + await step( 204 + "secondary-own-profile-reload", 205 + async () => { 206 + await gotoProfile(secondaryPage, secondary.handle); 207 + await secondaryPage.reload({ 208 + waitUntil: "domcontentloaded", 209 + timeout: 60000, 210 + }); 211 + await waitForProfileHandle(secondaryPage, secondary.handle); 212 + const row = await findRowByPrimaryText( 213 + secondaryPage, 214 + secondary.postText, 215 + 60000, 216 + ); 217 + const rowTestId = await row.getAttribute("data-testid"); 218 + return { rowTestId }; 219 + }, 220 + { pageNames: ["secondary"] }, 221 + ); 136 222 137 - await step('primary-edit-profile', () => editProfile(primaryPage, primary), { 138 - pageNames: ['primary'], 223 + await step("primary-edit-profile", () => editProfile(primaryPage, primary), { 224 + pageNames: ["primary"], 139 225 }); 140 226 141 - await step('primary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(primary)); 142 - await step('primary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(primary), { 143 - timeoutMs: Math.max(Number(config.publicCheckTimeoutMs || 180000) + 15000, 195000), 144 - }); 227 + await step("primary-local-profile-after-edit", () => 228 + verifyLocalProfileAfterEdit(primary), 229 + ); 230 + await step( 231 + "primary-public-profile-after-edit", 232 + () => verifyPublicProfileAfterEdit(primary), 233 + { 234 + timeoutMs: Math.max( 235 + Number(config.publicCheckTimeoutMs || 180000) + 15000, 236 + 195000, 237 + ), 238 + }, 239 + ); 145 240 146 - await step('secondary-edit-profile', () => editProfile(secondaryPage, secondary), { 147 - pageNames: ['secondary'], 148 - }); 241 + await step( 242 + "secondary-edit-profile", 243 + () => editProfile(secondaryPage, secondary), 244 + { 245 + pageNames: ["secondary"], 246 + }, 247 + ); 149 248 150 - await step('secondary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(secondary)); 151 - await step('secondary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(secondary), { 152 - timeoutMs: Math.max(Number(config.publicCheckTimeoutMs || 180000) + 15000, 195000), 153 - }); 249 + await step("secondary-local-profile-after-edit", () => 250 + verifyLocalProfileAfterEdit(secondary), 251 + ); 252 + await step( 253 + "secondary-public-profile-after-edit", 254 + () => verifyPublicProfileAfterEdit(secondary), 255 + { 256 + timeoutMs: Math.max( 257 + Number(config.publicCheckTimeoutMs || 180000) + 15000, 258 + 195000, 259 + ), 260 + }, 261 + ); 154 262 155 - await step('primary-baseline-profile-counts', async () => { 156 - primary.baselineCounts = await readProfileCountsAfterReload(primaryPage, primary, primary.handle); 157 - return primary.baselineCounts; 158 - }, { pageNames: ['primary'] }); 263 + await step( 264 + "primary-baseline-profile-counts", 265 + async () => { 266 + primary.baselineCounts = await readProfileCountsAfterReload( 267 + primaryPage, 268 + primary, 269 + primary.handle, 270 + ); 271 + return primary.baselineCounts; 272 + }, 273 + { pageNames: ["primary"] }, 274 + ); 159 275 160 - await step('secondary-baseline-profile-counts', async () => { 161 - secondary.baselineCounts = await readProfileCountsAfterReload(secondaryPage, secondary, secondary.handle); 162 - return secondary.baselineCounts; 163 - }, { pageNames: ['secondary'] }); 276 + await step( 277 + "secondary-baseline-profile-counts", 278 + async () => { 279 + secondary.baselineCounts = await readProfileCountsAfterReload( 280 + secondaryPage, 281 + secondary, 282 + secondary.handle, 283 + ); 284 + return secondary.baselineCounts; 285 + }, 286 + { pageNames: ["secondary"] }, 287 + ); 164 288 165 - await step('primary-create-list', async () => { 166 - return createList(primaryPage, primary.listName, primary.listDescription); 167 - }, { pageNames: ['primary'] }); 289 + await step( 290 + "primary-create-list", 291 + () => { 292 + return createList(primaryPage, primary.listName, primary.listDescription); 293 + }, 294 + { pageNames: ["primary"] }, 295 + ); 168 296 169 - await step('primary-list-record', async () => { 297 + await step("primary-list-record", async () => { 170 298 primary.listRecord = await waitForOwnListRecord(primary, primary.listName); 171 299 primary.listRkey = recordRkey(primary.listRecord); 172 300 if (primary.listRecord.value?.description !== primary.listDescription) { 173 - throw new Error('list record description did not match after create'); 301 + throw new Error("list record description did not match after create"); 174 302 } 175 303 return { 176 304 uri: primary.listRecord.uri, ··· 179 307 }; 180 308 }); 181 309 182 - await step('primary-edit-list', async () => { 183 - await openListPage(primaryPage, primary.handle, primary.listRkey); 184 - return editCurrentList(primaryPage, primary.listUpdatedName, primary.listUpdatedDescription); 185 - }, { pageNames: ['primary'] }); 310 + await step( 311 + "primary-edit-list", 312 + async () => { 313 + await openListPage(primaryPage, primary.handle, primary.listRkey); 314 + return editCurrentList( 315 + primaryPage, 316 + primary.listUpdatedName, 317 + primary.listUpdatedDescription, 318 + ); 319 + }, 320 + { pageNames: ["primary"] }, 321 + ); 186 322 187 - await step('primary-list-record-after-edit', async () => { 188 - primary.listRecord = await waitForOwnListRecord(primary, primary.listUpdatedName); 323 + await step("primary-list-record-after-edit", async () => { 324 + primary.listRecord = await waitForOwnListRecord( 325 + primary, 326 + primary.listUpdatedName, 327 + ); 189 328 primary.listRkey = recordRkey(primary.listRecord); 190 - if (primary.listRecord.value?.description !== primary.listUpdatedDescription) { 191 - throw new Error('list record description did not match after edit'); 329 + if ( 330 + primary.listRecord.value?.description !== primary.listUpdatedDescription 331 + ) { 332 + throw new Error("list record description did not match after edit"); 192 333 } 193 334 return { 194 335 uri: primary.listRecord.uri, ··· 197 338 }; 198 339 }); 199 340 200 - await step('primary-list-add-secondary-member', async () => { 201 - await openListPage(primaryPage, primary.handle, primary.listRkey); 202 - return addUserToCurrentList(primaryPage, secondary.handle); 203 - }, { pageNames: ['primary'] }); 341 + await step( 342 + "primary-list-add-secondary-member", 343 + async () => { 344 + await openListPage(primaryPage, primary.handle, primary.listRkey); 345 + return addUserToCurrentList(primaryPage, secondary.handle); 346 + }, 347 + { pageNames: ["primary"] }, 348 + ); 204 349 205 - await step('primary-list-member-record', async () => { 206 - primary.listItemRecord = await waitForOwnListItemRecord(primary, primary.listRecord.uri, secondary.did); 350 + await step("primary-list-member-record", async () => { 351 + primary.listItemRecord = await waitForOwnListItemRecord( 352 + primary, 353 + primary.listRecord.uri, 354 + secondary.did, 355 + ); 207 356 return { 208 357 uri: primary.listItemRecord.uri, 209 358 rkey: recordRkey(primary.listItemRecord), 210 359 }; 211 360 }); 212 361 213 - await step('primary-list-remove-secondary-member', async () => { 214 - await openListPage(primaryPage, primary.handle, primary.listRkey); 215 - return removeUserFromCurrentList(primaryPage, secondary.handle); 216 - }, { pageNames: ['primary'] }); 362 + await step( 363 + "primary-list-remove-secondary-member", 364 + async () => { 365 + await openListPage(primaryPage, primary.handle, primary.listRkey); 366 + return removeUserFromCurrentList(primaryPage, secondary.handle); 367 + }, 368 + { pageNames: ["primary"] }, 369 + ); 217 370 218 - await step('primary-list-member-record-removed', async () => { 371 + await step("primary-list-member-record-removed", async () => { 219 372 await waitForNoOwnRecord( 220 373 primary, 221 - 'app.bsky.graph.listitem', 374 + "app.bsky.graph.listitem", 222 375 (record) => 223 - record?.value?.list === primary.listRecord.uri && record?.value?.subject === secondary.did, 376 + record?.value?.list === primary.listRecord.uri && 377 + record?.value?.subject === secondary.did, 224 378 ); 225 379 return { listUri: primary.listRecord.uri, subject: secondary.did }; 226 380 }); 227 381 228 - await step('primary-delete-list', async () => { 229 - await openListPage(primaryPage, primary.handle, primary.listRkey); 230 - return deleteCurrentList(primaryPage); 231 - }, { pageNames: ['primary'] }); 382 + await step( 383 + "primary-delete-list", 384 + async () => { 385 + await openListPage(primaryPage, primary.handle, primary.listRkey); 386 + return deleteCurrentList(primaryPage); 387 + }, 388 + { pageNames: ["primary"] }, 389 + ); 232 390 233 - await step('primary-list-record-removed', async () => { 391 + await step("primary-list-record-removed", async () => { 234 392 await waitForNoOwnRecord( 235 393 primary, 236 - 'app.bsky.graph.list', 394 + "app.bsky.graph.list", 237 395 (record) => recordRkey(record) === primary.listRkey, 238 396 ); 239 397 return { rkey: primary.listRkey }; ··· 268 426 } = ctx; 269 427 270 428 const primaryWaveStarted = Date.now() - 1000; 271 - await step('primary-open-secondary-profile', async () => { 272 - await gotoProfile(primaryPage, secondary.handle); 273 - await waitForProfileHandle(primaryPage, secondary.handle); 274 - }, { pageNames: ['primary'] }); 429 + await step( 430 + "primary-open-secondary-profile", 431 + async () => { 432 + await gotoProfile(primaryPage, secondary.handle); 433 + await waitForProfileHandle(primaryPage, secondary.handle); 434 + }, 435 + { pageNames: ["primary"] }, 436 + ); 275 437 276 - await step('primary-reset-follow-secondary', () => maybeUnfollow(primaryPage), { 277 - optional: true, 278 - pageNames: ['primary'], 279 - }); 438 + await step( 439 + "primary-reset-follow-secondary", 440 + () => maybeUnfollow(primaryPage), 441 + { 442 + optional: true, 443 + pageNames: ["primary"], 444 + }, 445 + ); 280 446 281 - await step('primary-follow-secondary', () => maybeFollow(primaryPage), { 282 - pageNames: ['primary'], 447 + await step("primary-follow-secondary", () => maybeFollow(primaryPage), { 448 + pageNames: ["primary"], 283 449 }); 284 450 285 - await step('primary-follow-secondary-record', async () => { 451 + await step("primary-follow-secondary-record", async () => { 286 452 const record = await waitForFollowRecord(primary, secondary.did); 287 453 return { uri: record.uri }; 288 454 }); 289 455 290 - await step('primary-own-profile-counts-after-follow', async () => { 291 - return verifyProfileCountsAfterReload(primaryPage, primary, primary.handle, { 292 - followsCount: (primary.baselineCounts?.api?.followsCount ?? 0) + 1, 293 - }); 294 - }, { pageNames: ['primary'] }); 456 + await step( 457 + "primary-own-profile-counts-after-follow", 458 + () => { 459 + return verifyProfileCountsAfterReload( 460 + primaryPage, 461 + primary, 462 + primary.handle, 463 + { 464 + followsCount: (primary.baselineCounts?.api?.followsCount ?? 0) + 1, 465 + }, 466 + ); 467 + }, 468 + { pageNames: ["primary"] }, 469 + ); 295 470 296 - await step('secondary-own-profile-counts-after-being-followed', async () => { 297 - return verifyProfileCountsAfterReload(secondaryPage, secondary, secondary.handle, { 298 - followersCount: (secondary.baselineCounts?.api?.followersCount ?? 0) + 1, 299 - }); 300 - }, { pageNames: ['secondary'] }); 471 + await step( 472 + "secondary-own-profile-counts-after-being-followed", 473 + () => { 474 + return verifyProfileCountsAfterReload( 475 + secondaryPage, 476 + secondary, 477 + secondary.handle, 478 + { 479 + followersCount: 480 + (secondary.baselineCounts?.api?.followersCount ?? 0) + 1, 481 + }, 482 + ); 483 + }, 484 + { pageNames: ["secondary"] }, 485 + ); 301 486 302 - await step('primary-like-secondary-post', async () => { 303 - await gotoProfile(primaryPage, secondary.handle); 304 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 305 - return ensureLiked(primaryPage, row); 306 - }, { pageNames: ['primary'] }); 487 + await step( 488 + "primary-like-secondary-post", 489 + async () => { 490 + await gotoProfile(primaryPage, secondary.handle); 491 + const row = await findRowByPrimaryText( 492 + primaryPage, 493 + secondary.postText, 494 + 60000, 495 + ); 496 + return ensureLiked(primaryPage, row); 497 + }, 498 + { pageNames: ["primary"] }, 499 + ); 307 500 308 - await step('primary-bookmark-secondary-post', async () => { 309 - await gotoProfile(primaryPage, secondary.handle); 310 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 311 - return ensureBookmarked(primaryPage, row); 312 - }, { pageNames: ['primary'] }); 501 + await step( 502 + "primary-bookmark-secondary-post", 503 + async () => { 504 + await gotoProfile(primaryPage, secondary.handle); 505 + const row = await findRowByPrimaryText( 506 + primaryPage, 507 + secondary.postText, 508 + 60000, 509 + ); 510 + return ensureBookmarked(primaryPage, row); 511 + }, 512 + { pageNames: ["primary"] }, 513 + ); 313 514 314 - await step('primary-saved-posts-secondary', async () => { 315 - await openSavedPosts(primaryPage); 316 - await primaryPage.getByText(`@${secondary.handle.replace(/^@/, '')}`).first().waitFor({ 317 - state: 'visible', 318 - timeout: 20000, 319 - }); 320 - return { note: `saved post by ${secondary.handle}` }; 321 - }, { pageNames: ['primary'] }); 515 + await step( 516 + "primary-saved-posts-secondary", 517 + async () => { 518 + await openSavedPosts(primaryPage); 519 + await primaryPage 520 + .getByText(`@${secondary.handle.replace(/^@/, "")}`) 521 + .first() 522 + .waitFor({ 523 + state: "visible", 524 + timeout: 20000, 525 + }); 526 + return { note: `saved post by ${secondary.handle}` }; 527 + }, 528 + { pageNames: ["primary"] }, 529 + ); 322 530 323 - await step('primary-repost-secondary-post', async () => { 324 - await gotoProfile(primaryPage, secondary.handle); 325 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 326 - return ensureReposted(primaryPage, row); 327 - }, { pageNames: ['primary'] }); 531 + await step( 532 + "primary-repost-secondary-post", 533 + async () => { 534 + await gotoProfile(primaryPage, secondary.handle); 535 + const row = await findRowByPrimaryText( 536 + primaryPage, 537 + secondary.postText, 538 + 60000, 539 + ); 540 + return ensureReposted(primaryPage, row); 541 + }, 542 + { pageNames: ["primary"] }, 543 + ); 328 544 329 - await step('primary-quote-secondary-post', async () => { 330 - await gotoProfile(primaryPage, secondary.handle); 331 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 332 - await clickQuote(primaryPage, row, primary.quoteText); 333 - primary.quotePost = await waitForOwnPostRecord(primary, primary.quoteText); 334 - return { quoteText: primary.quoteText, uri: primary.quotePost.uri }; 335 - }, { pageNames: ['primary'] }); 545 + await step( 546 + "primary-quote-secondary-post", 547 + async () => { 548 + await gotoProfile(primaryPage, secondary.handle); 549 + const row = await findRowByPrimaryText( 550 + primaryPage, 551 + secondary.postText, 552 + 60000, 553 + ); 554 + await clickQuote(primaryPage, row, primary.quoteText); 555 + primary.quotePost = await waitForOwnPostRecord( 556 + primary, 557 + primary.quoteText, 558 + ); 559 + return { quoteText: primary.quoteText, uri: primary.quotePost.uri }; 560 + }, 561 + { pageNames: ["primary"] }, 562 + ); 336 563 337 - await step('primary-reply-secondary-post', async () => { 338 - await gotoProfile(primaryPage, secondary.handle); 339 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 340 - await clickReply(primaryPage, row, primary.replyText); 341 - primary.replyPost = await waitForOwnPostRecord(primary, primary.replyText); 342 - return { replyText: primary.replyText, uri: primary.replyPost.uri }; 343 - }, { pageNames: ['primary'] }); 564 + await step( 565 + "primary-reply-secondary-post", 566 + async () => { 567 + await gotoProfile(primaryPage, secondary.handle); 568 + const row = await findRowByPrimaryText( 569 + primaryPage, 570 + secondary.postText, 571 + 60000, 572 + ); 573 + await clickReply(primaryPage, row, primary.replyText); 574 + primary.replyPost = await waitForOwnPostRecord( 575 + primary, 576 + primary.replyText, 577 + ); 578 + return { replyText: primary.replyText, uri: primary.replyPost.uri }; 579 + }, 580 + { pageNames: ["primary"] }, 581 + ); 344 582 345 583 if (config.remoteReplyPostUrl) { 346 584 const remoteReplyText = `${primary.replyText} remote`; 347 - const remoteReplyHandle = remoteReplyHandleFromUrl(config.remoteReplyPostUrl); 585 + const remoteReplyHandle = remoteReplyHandleFromUrl( 586 + config.remoteReplyPostUrl, 587 + ); 348 588 349 589 if (remoteReplyHandle) { 350 - await step('primary-prepare-configured-remote-reply-target', async () => { 351 - await gotoProfile(primaryPage, remoteReplyHandle); 352 - await waitForProfileHandle(primaryPage, remoteReplyHandle); 353 - const wasFollowing = (await primaryPage.getByTestId('unfollowBtn').first().count()) > 0; 354 - primary.remoteReplyWasFollowingTarget = wasFollowing; 355 - if (!wasFollowing) { 356 - await maybeFollow(primaryPage); 357 - } 590 + await step( 591 + "primary-prepare-configured-remote-reply-target", 592 + async () => { 593 + await gotoProfile(primaryPage, remoteReplyHandle); 594 + await waitForProfileHandle(primaryPage, remoteReplyHandle); 595 + const wasFollowing = 596 + (await primaryPage.getByTestId("unfollowBtn").first().count()) > 0; 597 + primary.remoteReplyWasFollowingTarget = wasFollowing; 598 + if (!wasFollowing) { 599 + await maybeFollow(primaryPage); 600 + } 601 + return { 602 + remoteReplyHandle, 603 + wasFollowing, 604 + nowFollowing: 605 + (await primaryPage.getByTestId("unfollowBtn").first().count()) > 606 + 0, 607 + }; 608 + }, 609 + { pageNames: ["primary"] }, 610 + ); 611 + } 612 + 613 + await step( 614 + "primary-reply-configured-remote-post", 615 + async () => { 616 + await primaryPage.goto(config.remoteReplyPostUrl, { 617 + waitUntil: "domcontentloaded", 618 + timeout: 60000, 619 + }); 620 + await primaryPage.getByTestId("replyBtn").first().waitFor({ 621 + state: "visible", 622 + timeout: 20000, 623 + }); 624 + await clickReply(primaryPage, primaryPage, remoteReplyText); 625 + primary.remoteReplyPost = await waitForOwnPostRecord( 626 + primary, 627 + remoteReplyText, 628 + ); 358 629 return { 630 + replyText: remoteReplyText, 631 + uri: primary.remoteReplyPost.uri, 632 + remoteReplyPostUrl: config.remoteReplyPostUrl, 359 633 remoteReplyHandle, 360 - wasFollowing, 361 - nowFollowing: (await primaryPage.getByTestId('unfollowBtn').first().count()) > 0, 362 634 }; 363 - }, { pageNames: ['primary'] }); 364 - } 365 - 366 - await step('primary-reply-configured-remote-post', async () => { 367 - await primaryPage.goto(config.remoteReplyPostUrl, { 368 - waitUntil: 'domcontentloaded', 369 - timeout: 60000, 370 - }); 371 - await primaryPage.getByTestId('replyBtn').first().waitFor({ 372 - state: 'visible', 373 - timeout: 20000, 374 - }); 375 - await clickReply(primaryPage, primaryPage, remoteReplyText); 376 - primary.remoteReplyPost = await waitForOwnPostRecord(primary, remoteReplyText); 377 - return { 378 - replyText: remoteReplyText, 379 - uri: primary.remoteReplyPost.uri, 380 - remoteReplyPostUrl: config.remoteReplyPostUrl, 381 - remoteReplyHandle, 382 - }; 383 - }, { pageNames: ['primary'] }); 635 + }, 636 + { pageNames: ["primary"] }, 637 + ); 384 638 } 385 639 386 - await step('secondary-notification-api-primary-engagement-wave', async () => { 640 + await step("secondary-notification-api-primary-engagement-wave", async () => { 387 641 const result = await pollNotifications({ 388 642 account: secondary, 389 643 authorHandle: primary.handle, 390 - reasons: ['like', 'repost', 'quote', 'reply'], 644 + reasons: ["like", "repost", "quote", "reply"], 391 645 minIndexedAt: primaryWaveStarted, 392 646 }); 393 647 return { ··· 396 650 }; 397 651 }); 398 652 399 - await step('secondary-notifications-page', async () => { 400 - await openNotifications(secondaryPage); 401 - const feed = await waitForNotificationsFeed(secondaryPage); 402 - return { note: feed ? 'notifications feed visible' : 'notifications page visible without explicit feed testid' }; 403 - }, { pageNames: ['secondary'] }); 653 + await step( 654 + "secondary-notifications-page", 655 + async () => { 656 + await openNotifications(secondaryPage); 657 + const feed = await waitForNotificationsFeed(secondaryPage); 658 + return { 659 + note: feed 660 + ? "notifications feed visible" 661 + : "notifications page visible without explicit feed testid", 662 + }; 663 + }, 664 + { pageNames: ["secondary"] }, 665 + ); 404 666 }; 405 667 406 668 export const runDualSecondaryWaveAndSettingsPhase = async (ctx) => { ··· 430 692 } = ctx; 431 693 432 694 const secondaryWaveStarted = Date.now() - 1000; 433 - await step('secondary-open-primary-profile', async () => { 434 - await gotoProfile(secondaryPage, primary.handle); 435 - await waitForProfileHandle(secondaryPage, primary.handle); 436 - }, { pageNames: ['secondary'] }); 695 + await step( 696 + "secondary-open-primary-profile", 697 + async () => { 698 + await gotoProfile(secondaryPage, primary.handle); 699 + await waitForProfileHandle(secondaryPage, primary.handle); 700 + }, 701 + { pageNames: ["secondary"] }, 702 + ); 437 703 438 - await step('secondary-reset-follow-primary', () => maybeUnfollow(secondaryPage), { 439 - optional: true, 440 - pageNames: ['secondary'], 441 - }); 704 + await step( 705 + "secondary-reset-follow-primary", 706 + () => maybeUnfollow(secondaryPage), 707 + { 708 + optional: true, 709 + pageNames: ["secondary"], 710 + }, 711 + ); 442 712 443 - await step('secondary-follow-primary', () => maybeFollow(secondaryPage), { 444 - pageNames: ['secondary'], 713 + await step("secondary-follow-primary", () => maybeFollow(secondaryPage), { 714 + pageNames: ["secondary"], 445 715 }); 446 716 447 - await step('secondary-follow-primary-record', async () => { 717 + await step("secondary-follow-primary-record", async () => { 448 718 const record = await waitForFollowRecord(secondary, primary.did); 449 719 return { uri: record.uri }; 450 720 }); 451 721 452 - await step('secondary-own-profile-counts-after-follow', async () => { 453 - return verifyProfileCountsAfterReload(secondaryPage, secondary, secondary.handle, { 454 - followersCount: (secondary.baselineCounts?.api?.followersCount ?? 0) + 1, 455 - followsCount: (secondary.baselineCounts?.api?.followsCount ?? 0) + 1, 456 - }); 457 - }, { pageNames: ['secondary'] }); 722 + await step( 723 + "secondary-own-profile-counts-after-follow", 724 + () => { 725 + return verifyProfileCountsAfterReload( 726 + secondaryPage, 727 + secondary, 728 + secondary.handle, 729 + { 730 + followersCount: 731 + (secondary.baselineCounts?.api?.followersCount ?? 0) + 1, 732 + followsCount: (secondary.baselineCounts?.api?.followsCount ?? 0) + 1, 733 + }, 734 + ); 735 + }, 736 + { pageNames: ["secondary"] }, 737 + ); 458 738 459 - await step('primary-own-profile-counts-after-being-followed', async () => { 460 - return verifyProfileCountsAfterReload(primaryPage, primary, primary.handle, { 461 - followersCount: (primary.baselineCounts?.api?.followersCount ?? 0) + 1, 462 - followsCount: (primary.baselineCounts?.api?.followsCount ?? 0) + 1, 463 - }); 464 - }, { pageNames: ['primary'] }); 739 + await step( 740 + "primary-own-profile-counts-after-being-followed", 741 + () => { 742 + return verifyProfileCountsAfterReload( 743 + primaryPage, 744 + primary, 745 + primary.handle, 746 + { 747 + followersCount: 748 + (primary.baselineCounts?.api?.followersCount ?? 0) + 1, 749 + followsCount: (primary.baselineCounts?.api?.followsCount ?? 0) + 1, 750 + }, 751 + ); 752 + }, 753 + { pageNames: ["primary"] }, 754 + ); 465 755 466 - await step('primary-notification-api-secondary-follow', async () => { 467 - const result = await pollNotifications({ 468 - account: primary, 469 - authorHandle: secondary.handle, 470 - reasons: ['follow'], 471 - minIndexedAt: secondaryWaveStarted, 472 - timeoutMs: 30000, 473 - }); 474 - return { 475 - reasons: result.notifications.map((item) => item.reason), 476 - sample: result.allNotifications.slice(0, 5), 477 - }; 478 - }, { optional: true }); 756 + await step( 757 + "primary-notification-api-secondary-follow", 758 + async () => { 759 + const result = await pollNotifications({ 760 + account: primary, 761 + authorHandle: secondary.handle, 762 + reasons: ["follow"], 763 + minIndexedAt: secondaryWaveStarted, 764 + timeoutMs: 30000, 765 + }); 766 + return { 767 + reasons: result.notifications.map((item) => item.reason), 768 + sample: result.allNotifications.slice(0, 5), 769 + }; 770 + }, 771 + { optional: true }, 772 + ); 479 773 480 - await step('primary-notifications-page', async () => { 481 - await openNotifications(primaryPage); 482 - const feed = await waitForNotificationsFeed(primaryPage); 483 - return { note: feed ? 'notifications feed visible' : 'notifications page visible without explicit feed testid' }; 484 - }, { pageNames: ['primary'] }); 774 + await step( 775 + "primary-notifications-page", 776 + async () => { 777 + await openNotifications(primaryPage); 778 + const feed = await waitForNotificationsFeed(primaryPage); 779 + return { 780 + note: feed 781 + ? "notifications feed visible" 782 + : "notifications page visible without explicit feed testid", 783 + }; 784 + }, 785 + { pageNames: ["primary"] }, 786 + ); 485 787 486 - await step('primary-mute-secondary', async () => { 487 - await gotoProfile(primaryPage, secondary.handle); 488 - return ensureProfileMuted(primaryPage); 489 - }, { pageNames: ['primary'] }); 788 + await step( 789 + "primary-mute-secondary", 790 + async () => { 791 + await gotoProfile(primaryPage, secondary.handle); 792 + return ensureProfileMuted(primaryPage); 793 + }, 794 + { pageNames: ["primary"] }, 795 + ); 490 796 491 - await step('primary-unmute-secondary', async () => { 492 - await gotoProfile(primaryPage, secondary.handle); 493 - return ensureProfileUnmuted(primaryPage); 494 - }, { pageNames: ['primary'] }); 797 + await step( 798 + "primary-unmute-secondary", 799 + async () => { 800 + await gotoProfile(primaryPage, secondary.handle); 801 + return ensureProfileUnmuted(primaryPage); 802 + }, 803 + { pageNames: ["primary"] }, 804 + ); 495 805 496 - await step('secondary-report-primary-post-draft', async () => { 497 - await gotoProfile(secondaryPage, primary.handle); 498 - const row = await findRowByPrimaryText(secondaryPage, primary.postText, 60000); 499 - return openReportPostDraft(secondaryPage, row); 500 - }, { pageNames: ['secondary'] }); 806 + await step( 807 + "secondary-report-primary-post-draft", 808 + async () => { 809 + await gotoProfile(secondaryPage, primary.handle); 810 + const row = await findRowByPrimaryText( 811 + secondaryPage, 812 + primary.postText, 813 + 60000, 814 + ); 815 + return openReportPostDraft(secondaryPage, row); 816 + }, 817 + { pageNames: ["secondary"] }, 818 + ); 501 819 502 - await step('secondary-block-primary', async () => { 503 - await gotoProfile(secondaryPage, primary.handle); 504 - return blockProfile(secondaryPage); 505 - }, { pageNames: ['secondary'] }); 820 + await step( 821 + "secondary-block-primary", 822 + async () => { 823 + await gotoProfile(secondaryPage, primary.handle); 824 + return blockProfile(secondaryPage); 825 + }, 826 + { pageNames: ["secondary"] }, 827 + ); 506 828 507 - await step('secondary-unblock-primary', async () => { 508 - return unblockProfile(secondaryPage); 509 - }, { pageNames: ['secondary'] }); 829 + await step( 830 + "secondary-unblock-primary", 831 + () => { 832 + return unblockProfile(secondaryPage); 833 + }, 834 + { pageNames: ["secondary"] }, 835 + ); 510 836 511 - await step('primary-settings-likes-people-i-follow', async () => { 512 - return setRadioSetting(primaryPage, '/settings/notifications/likes', 'People I follow'); 513 - }, { pageNames: ['primary'] }); 837 + await step( 838 + "primary-settings-likes-people-i-follow", 839 + () => { 840 + return setRadioSetting( 841 + primaryPage, 842 + "/settings/notifications/likes", 843 + "People I follow", 844 + ); 845 + }, 846 + { pageNames: ["primary"] }, 847 + ); 514 848 515 - await step('primary-settings-likes-everyone', async () => { 516 - return setRadioSetting(primaryPage, '/settings/notifications/likes', 'Everyone'); 517 - }, { pageNames: ['primary'] }); 849 + await step( 850 + "primary-settings-likes-everyone", 851 + () => { 852 + return setRadioSetting( 853 + primaryPage, 854 + "/settings/notifications/likes", 855 + "Everyone", 856 + ); 857 + }, 858 + { pageNames: ["primary"] }, 859 + ); 518 860 519 - await step('primary-settings-threads-oldest', async () => { 520 - return setRadioSetting(primaryPage, '/settings/threads', 'Oldest replies first'); 521 - }, { pageNames: ['primary'] }); 861 + await step( 862 + "primary-settings-threads-oldest", 863 + () => { 864 + return setRadioSetting( 865 + primaryPage, 866 + "/settings/threads", 867 + "Oldest replies first", 868 + ); 869 + }, 870 + { pageNames: ["primary"] }, 871 + ); 522 872 523 - await step('primary-settings-threads-tree-view-on', async () => { 524 - return setCheckboxSetting(primaryPage, '/settings/threads', 'Tree view', true); 525 - }, { pageNames: ['primary'] }); 873 + await step( 874 + "primary-settings-threads-tree-view-on", 875 + () => { 876 + return setCheckboxSetting( 877 + primaryPage, 878 + "/settings/threads", 879 + "Tree view", 880 + true, 881 + ); 882 + }, 883 + { pageNames: ["primary"] }, 884 + ); 526 885 527 - await step('primary-settings-threads-tree-view-off', async () => { 528 - return setCheckboxSetting(primaryPage, '/settings/threads', 'Tree view', false); 529 - }, { pageNames: ['primary'] }); 886 + await step( 887 + "primary-settings-threads-tree-view-off", 888 + () => { 889 + return setCheckboxSetting( 890 + primaryPage, 891 + "/settings/threads", 892 + "Tree view", 893 + false, 894 + ); 895 + }, 896 + { pageNames: ["primary"] }, 897 + ); 530 898 531 - await step('primary-settings-threads-top-replies', async () => { 532 - return setRadioSetting(primaryPage, '/settings/threads', 'Top replies first'); 533 - }, { pageNames: ['primary'] }); 899 + await step( 900 + "primary-settings-threads-top-replies", 901 + () => { 902 + return setRadioSetting( 903 + primaryPage, 904 + "/settings/threads", 905 + "Top replies first", 906 + ); 907 + }, 908 + { pageNames: ["primary"] }, 909 + ); 534 910 535 - await step('primary-settings-following-feed-hide-replies', async () => { 536 - return setCheckboxSetting(primaryPage, '/settings/following-feed', 'Show replies', false); 537 - }, { pageNames: ['primary'] }); 911 + await step( 912 + "primary-settings-following-feed-hide-replies", 913 + () => { 914 + return setCheckboxSetting( 915 + primaryPage, 916 + "/settings/following-feed", 917 + "Show replies", 918 + false, 919 + ); 920 + }, 921 + { pageNames: ["primary"] }, 922 + ); 538 923 539 - await step('primary-settings-following-feed-show-replies', async () => { 540 - return setCheckboxSetting(primaryPage, '/settings/following-feed', 'Show replies', true); 541 - }, { pageNames: ['primary'] }); 924 + await step( 925 + "primary-settings-following-feed-show-replies", 926 + () => { 927 + return setCheckboxSetting( 928 + primaryPage, 929 + "/settings/following-feed", 930 + "Show replies", 931 + true, 932 + ); 933 + }, 934 + { pageNames: ["primary"] }, 935 + ); 542 936 543 - await step('primary-settings-autoplay-off', async () => { 544 - return setCheckboxSetting(primaryPage, '/settings/content-and-media', 'Autoplay videos and GIFs', false); 545 - }, { pageNames: ['primary'] }); 937 + await step( 938 + "primary-settings-autoplay-off", 939 + () => { 940 + return setCheckboxSetting( 941 + primaryPage, 942 + "/settings/content-and-media", 943 + "Autoplay videos and GIFs", 944 + false, 945 + ); 946 + }, 947 + { pageNames: ["primary"] }, 948 + ); 546 949 547 - await step('primary-settings-autoplay-on', async () => { 548 - return setCheckboxSetting(primaryPage, '/settings/content-and-media', 'Autoplay videos and GIFs', true); 549 - }, { pageNames: ['primary'] }); 950 + await step( 951 + "primary-settings-autoplay-on", 952 + () => { 953 + return setCheckboxSetting( 954 + primaryPage, 955 + "/settings/content-and-media", 956 + "Autoplay videos and GIFs", 957 + true, 958 + ); 959 + }, 960 + { pageNames: ["primary"] }, 961 + ); 550 962 551 - await step('primary-settings-accessibility-require-alt-on', async () => { 552 - return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Require alt text before posting', true); 553 - }, { pageNames: ['primary'] }); 963 + await step( 964 + "primary-settings-accessibility-require-alt-on", 965 + () => { 966 + return setCheckboxSetting( 967 + primaryPage, 968 + "/settings/accessibility", 969 + "Require alt text before posting", 970 + true, 971 + ); 972 + }, 973 + { pageNames: ["primary"] }, 974 + ); 554 975 555 - await step('primary-settings-accessibility-require-alt-off', async () => { 556 - return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Require alt text before posting', false); 557 - }, { pageNames: ['primary'] }); 976 + await step( 977 + "primary-settings-accessibility-require-alt-off", 978 + () => { 979 + return setCheckboxSetting( 980 + primaryPage, 981 + "/settings/accessibility", 982 + "Require alt text before posting", 983 + false, 984 + ); 985 + }, 986 + { pageNames: ["primary"] }, 987 + ); 558 988 559 - await step('primary-settings-accessibility-large-badges-on', async () => { 560 - return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Display larger alt text badges', true); 561 - }, { pageNames: ['primary'] }); 989 + await step( 990 + "primary-settings-accessibility-large-badges-on", 991 + () => { 992 + return setCheckboxSetting( 993 + primaryPage, 994 + "/settings/accessibility", 995 + "Display larger alt text badges", 996 + true, 997 + ); 998 + }, 999 + { pageNames: ["primary"] }, 1000 + ); 562 1001 563 - await step('primary-settings-accessibility-large-badges-off', async () => { 564 - return setCheckboxSetting(primaryPage, '/settings/accessibility', 'Display larger alt text badges', false); 565 - }, { pageNames: ['primary'] }); 1002 + await step( 1003 + "primary-settings-accessibility-large-badges-off", 1004 + () => { 1005 + return setCheckboxSetting( 1006 + primaryPage, 1007 + "/settings/accessibility", 1008 + "Display larger alt text badges", 1009 + false, 1010 + ); 1011 + }, 1012 + { pageNames: ["primary"] }, 1013 + ); 566 1014 }; 567 1015 568 1016 export const runDualCleanupPhase = async (ctx) => { ··· 585 1033 maybeDeleteOwnPostByText, 586 1034 } = ctx; 587 1035 588 - await step('primary-cleanup-unlike-secondary-post', async () => { 589 - await gotoProfile(primaryPage, secondary.handle); 590 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 591 - return ensureNotLiked(primaryPage, row); 592 - }, { optional: true, pageNames: ['primary'] }); 1036 + await step( 1037 + "primary-cleanup-unlike-secondary-post", 1038 + async () => { 1039 + await gotoProfile(primaryPage, secondary.handle); 1040 + const row = await findRowByPrimaryText( 1041 + primaryPage, 1042 + secondary.postText, 1043 + 60000, 1044 + ); 1045 + return ensureNotLiked(primaryPage, row); 1046 + }, 1047 + { optional: true, pageNames: ["primary"] }, 1048 + ); 593 1049 594 - await step('primary-cleanup-unbookmark-secondary-post', async () => { 595 - await gotoProfile(primaryPage, secondary.handle); 596 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 597 - return ensureNotBookmarked(primaryPage, row); 598 - }, { optional: true, pageNames: ['primary'] }); 1050 + await step( 1051 + "primary-cleanup-unbookmark-secondary-post", 1052 + async () => { 1053 + await gotoProfile(primaryPage, secondary.handle); 1054 + const row = await findRowByPrimaryText( 1055 + primaryPage, 1056 + secondary.postText, 1057 + 60000, 1058 + ); 1059 + return ensureNotBookmarked(primaryPage, row); 1060 + }, 1061 + { optional: true, pageNames: ["primary"] }, 1062 + ); 599 1063 600 - await step('primary-cleanup-undo-repost-secondary-post', async () => { 601 - await gotoProfile(primaryPage, secondary.handle); 602 - const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 603 - return ensureNotReposted(primaryPage, row); 604 - }, { optional: true, pageNames: ['primary'] }); 1064 + await step( 1065 + "primary-cleanup-undo-repost-secondary-post", 1066 + async () => { 1067 + await gotoProfile(primaryPage, secondary.handle); 1068 + const row = await findRowByPrimaryText( 1069 + primaryPage, 1070 + secondary.postText, 1071 + 60000, 1072 + ); 1073 + return ensureNotReposted(primaryPage, row); 1074 + }, 1075 + { optional: true, pageNames: ["primary"] }, 1076 + ); 605 1077 606 - await step('primary-cleanup-unfollow-secondary', async () => { 607 - await gotoProfile(primaryPage, secondary.handle); 608 - return maybeUnfollow(primaryPage); 609 - }, { optional: true, pageNames: ['primary'] }); 1078 + await step( 1079 + "primary-cleanup-unfollow-secondary", 1080 + async () => { 1081 + await gotoProfile(primaryPage, secondary.handle); 1082 + return maybeUnfollow(primaryPage); 1083 + }, 1084 + { optional: true, pageNames: ["primary"] }, 1085 + ); 610 1086 611 - await step('secondary-cleanup-unfollow-primary', async () => { 612 - await gotoProfile(secondaryPage, primary.handle); 613 - return maybeUnfollow(secondaryPage); 614 - }, { optional: true, pageNames: ['secondary'] }); 1087 + await step( 1088 + "secondary-cleanup-unfollow-primary", 1089 + async () => { 1090 + await gotoProfile(secondaryPage, primary.handle); 1091 + return maybeUnfollow(secondaryPage); 1092 + }, 1093 + { optional: true, pageNames: ["secondary"] }, 1094 + ); 615 1095 616 - if (config.remoteReplyPostUrl && primary.remoteReplyWasFollowingTarget === false) { 617 - const remoteReplyHandle = remoteReplyHandleFromUrl(config.remoteReplyPostUrl); 1096 + if ( 1097 + config.remoteReplyPostUrl && 1098 + primary.remoteReplyWasFollowingTarget === false 1099 + ) { 1100 + const remoteReplyHandle = remoteReplyHandleFromUrl( 1101 + config.remoteReplyPostUrl, 1102 + ); 618 1103 if (remoteReplyHandle) { 619 - await step('primary-cleanup-remote-reply-target-follow', async () => { 620 - await gotoProfile(primaryPage, remoteReplyHandle); 621 - await waitForProfileHandle(primaryPage, remoteReplyHandle); 622 - return maybeUnfollow(primaryPage); 623 - }, { optional: true, pageNames: ['primary'] }); 1104 + await step( 1105 + "primary-cleanup-remote-reply-target-follow", 1106 + async () => { 1107 + await gotoProfile(primaryPage, remoteReplyHandle); 1108 + await waitForProfileHandle(primaryPage, remoteReplyHandle); 1109 + return maybeUnfollow(primaryPage); 1110 + }, 1111 + { optional: true, pageNames: ["primary"] }, 1112 + ); 624 1113 } 625 1114 } 626 1115 627 - await step('primary-own-profile-counts-after-unfollow-cleanup', async () => { 628 - return verifyProfileCountsAfterReload(primaryPage, primary, primary.handle, { 629 - followersCount: primary.baselineCounts?.api?.followersCount ?? 0, 630 - followsCount: primary.baselineCounts?.api?.followsCount ?? 0, 631 - }); 632 - }, { pageNames: ['primary'] }); 1116 + await step( 1117 + "primary-own-profile-counts-after-unfollow-cleanup", 1118 + () => { 1119 + return verifyProfileCountsAfterReload( 1120 + primaryPage, 1121 + primary, 1122 + primary.handle, 1123 + { 1124 + followersCount: primary.baselineCounts?.api?.followersCount ?? 0, 1125 + followsCount: primary.baselineCounts?.api?.followsCount ?? 0, 1126 + }, 1127 + ); 1128 + }, 1129 + { pageNames: ["primary"] }, 1130 + ); 633 1131 634 - await step('secondary-own-profile-counts-after-unfollow-cleanup', async () => { 635 - return verifyProfileCountsAfterReload(secondaryPage, secondary, secondary.handle, { 636 - followersCount: secondary.baselineCounts?.api?.followersCount ?? 0, 637 - followsCount: secondary.baselineCounts?.api?.followsCount ?? 0, 638 - }); 639 - }, { pageNames: ['secondary'] }); 1132 + await step( 1133 + "secondary-own-profile-counts-after-unfollow-cleanup", 1134 + () => { 1135 + return verifyProfileCountsAfterReload( 1136 + secondaryPage, 1137 + secondary, 1138 + secondary.handle, 1139 + { 1140 + followersCount: secondary.baselineCounts?.api?.followersCount ?? 0, 1141 + followsCount: secondary.baselineCounts?.api?.followsCount ?? 0, 1142 + }, 1143 + ); 1144 + }, 1145 + { pageNames: ["secondary"] }, 1146 + ); 640 1147 641 - await step('primary-cleanup-delete-quote', async () => { 642 - await gotoProfile(primaryPage, primary.handle); 643 - await openProfileTab(primaryPage, 'Posts'); 644 - return maybeDeleteOwnPostByText(primaryPage, primary.quoteText, 'deleted quote post'); 645 - }, { pageNames: ['primary'] }); 1148 + await step( 1149 + "primary-cleanup-delete-quote", 1150 + async () => { 1151 + await gotoProfile(primaryPage, primary.handle); 1152 + await openProfileTab(primaryPage, "Posts"); 1153 + return maybeDeleteOwnPostByText( 1154 + primaryPage, 1155 + primary.quoteText, 1156 + "deleted quote post", 1157 + ); 1158 + }, 1159 + { pageNames: ["primary"] }, 1160 + ); 646 1161 647 - await step('primary-cleanup-delete-image-post', async () => { 648 - await gotoProfile(primaryPage, primary.handle); 649 - await openProfileTab(primaryPage, 'Posts'); 650 - return maybeDeleteOwnPostByText(primaryPage, primary.mediaPostText, 'deleted image post'); 651 - }, { pageNames: ['primary'] }); 1162 + await step( 1163 + "primary-cleanup-delete-image-post", 1164 + async () => { 1165 + await gotoProfile(primaryPage, primary.handle); 1166 + await openProfileTab(primaryPage, "Posts"); 1167 + return maybeDeleteOwnPostByText( 1168 + primaryPage, 1169 + primary.mediaPostText, 1170 + "deleted image post", 1171 + ); 1172 + }, 1173 + { pageNames: ["primary"] }, 1174 + ); 652 1175 653 - await step('primary-cleanup-delete-reply', async () => { 654 - await gotoProfile(primaryPage, primary.handle); 655 - await openProfileTab(primaryPage, 'Replies'); 656 - return maybeDeleteOwnPostByText(primaryPage, primary.replyText, 'deleted reply post'); 657 - }, { optional: true, pageNames: ['primary'] }); 1176 + await step( 1177 + "primary-cleanup-delete-reply", 1178 + async () => { 1179 + await gotoProfile(primaryPage, primary.handle); 1180 + await openProfileTab(primaryPage, "Replies"); 1181 + return maybeDeleteOwnPostByText( 1182 + primaryPage, 1183 + primary.replyText, 1184 + "deleted reply post", 1185 + ); 1186 + }, 1187 + { optional: true, pageNames: ["primary"] }, 1188 + ); 658 1189 659 1190 if (config.remoteReplyPostUrl) { 660 - await step('primary-cleanup-delete-remote-reply', async () => { 661 - await gotoProfile(primaryPage, primary.handle); 662 - await openProfileTab(primaryPage, 'Replies'); 663 - return maybeDeleteOwnPostByText(primaryPage, `${primary.replyText} remote`, 'deleted remote reply post'); 664 - }, { optional: true, pageNames: ['primary'] }); 1191 + await step( 1192 + "primary-cleanup-delete-remote-reply", 1193 + async () => { 1194 + await gotoProfile(primaryPage, primary.handle); 1195 + await openProfileTab(primaryPage, "Replies"); 1196 + return maybeDeleteOwnPostByText( 1197 + primaryPage, 1198 + `${primary.replyText} remote`, 1199 + "deleted remote reply post", 1200 + ); 1201 + }, 1202 + { optional: true, pageNames: ["primary"] }, 1203 + ); 665 1204 } 666 1205 667 - await step('secondary-cleanup-delete-root-post', async () => { 668 - await gotoProfile(secondaryPage, secondary.handle); 669 - await openProfileTab(secondaryPage, 'Posts'); 670 - return maybeDeleteOwnPostByText(secondaryPage, secondary.postText, 'deleted root post'); 671 - }, { pageNames: ['secondary'] }); 1206 + await step( 1207 + "secondary-cleanup-delete-root-post", 1208 + async () => { 1209 + await gotoProfile(secondaryPage, secondary.handle); 1210 + await openProfileTab(secondaryPage, "Posts"); 1211 + return maybeDeleteOwnPostByText( 1212 + secondaryPage, 1213 + secondary.postText, 1214 + "deleted root post", 1215 + ); 1216 + }, 1217 + { pageNames: ["secondary"] }, 1218 + ); 672 1219 673 - await step('primary-cleanup-delete-root-post', async () => { 674 - await gotoProfile(primaryPage, primary.handle); 675 - await openProfileTab(primaryPage, 'Posts'); 676 - return maybeDeleteOwnPostByText(primaryPage, primary.postText, 'deleted root post'); 677 - }, { optional: true, pageNames: ['primary'] }); 1220 + await step( 1221 + "primary-cleanup-delete-root-post", 1222 + async () => { 1223 + await gotoProfile(primaryPage, primary.handle); 1224 + await openProfileTab(primaryPage, "Posts"); 1225 + return maybeDeleteOwnPostByText( 1226 + primaryPage, 1227 + primary.postText, 1228 + "deleted root post", 1229 + ); 1230 + }, 1231 + { optional: true, pageNames: ["primary"] }, 1232 + ); 678 1233 };
+26 -8
src/browser/lib/failure-rules.mjs
··· 13 13 { url: /cdn\.bsky\.app\/img\/avatar_thumbnail\//i, error: /ERR_ABORTED/i }, 14 14 { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 15 15 { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 16 - { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts|.*\.vtt)/i, error: /ERR_ABORTED/i }, 16 + { 17 + url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts|.*\.vtt)/i, 18 + error: /ERR_ABORTED/i, 19 + }, 17 20 { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 18 - { url: /\/xrpc\/app\.bsky\.graph\.(?:muteActor|unmuteActor)/i, error: /ERR_ABORTED/i }, 19 - { url: /\/xrpc\/com\.atproto\.identity\.resolveHandle/i, error: /ERR_ABORTED/i }, 21 + { 22 + url: /\/xrpc\/app\.bsky\.graph\.(?:muteActor|unmuteActor)/i, 23 + error: /ERR_ABORTED/i, 24 + }, 25 + { 26 + url: /\/xrpc\/com\.atproto\.identity\.resolveHandle/i, 27 + error: /ERR_ABORTED/i, 28 + }, 20 29 { url: /\/xrpc\/app\.bsky\.feed\.getAuthorFeed/i, error: /ERR_ABORTED/i }, 21 - { url: /\/xrpc\/app\.bsky\.graph\.getSuggestedFollowsByActor/i, error: /ERR_ABORTED/i }, 22 - { url: /\/xrpc\/chat\.bsky\.convo\.getConvoAvailability/i, error: /ERR_ABORTED/i }, 30 + { 31 + url: /\/xrpc\/app\.bsky\.graph\.getSuggestedFollowsByActor/i, 32 + error: /ERR_ABORTED/i, 33 + }, 34 + { 35 + url: /\/xrpc\/chat\.bsky\.convo\.getConvoAvailability/i, 36 + error: /ERR_ABORTED/i, 37 + }, 23 38 ]; 24 39 25 40 export const IGNORED_HTTP_FAILURE = [ ··· 29 44 ]; 30 45 31 46 export const isIgnoredConsoleEntry = (entry) => 32 - IGNORED_CONSOLE.some((pattern) => pattern.test(entry.text || '')); 47 + IGNORED_CONSOLE.some((pattern) => pattern.test(entry.text || "")); 33 48 34 49 export const isIgnoredRequestFailureEntry = (entry) => 35 50 IGNORED_REQUEST_FAILURE.some( 36 - (rule) => rule.url.test(entry.url || '') && rule.error.test(entry.errorText || ''), 51 + (rule) => 52 + rule.url.test(entry.url || "") && rule.error.test(entry.errorText || ""), 37 53 ); 38 54 39 55 export const isIgnoredHttpFailureEntry = (entry) => 40 56 IGNORED_HTTP_FAILURE.some( 41 - (rule) => rule.url.test(entry.url || '') && (!rule.status || rule.status === entry.status), 57 + (rule) => 58 + rule.url.test(entry.url || "") && 59 + (!rule.status || rule.status === entry.status), 42 60 );
+89 -50
src/browser/lib/lists.mjs
··· 2 2 const waitForListPageReady = async (page, timeout = 30000) => { 3 3 const started = Date.now(); 4 4 while (Date.now() - started < timeout) { 5 - const moreOptions = page.getByTestId('moreOptionsBtn').first(); 5 + const moreOptions = page.getByTestId("moreOptionsBtn").first(); 6 6 if (await moreOptions.isVisible().catch(() => false)) { 7 7 return; 8 8 } 9 9 await wait(page, 1500); 10 - await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => undefined); 10 + await page 11 + .reload({ waitUntil: "domcontentloaded", timeout: 60000 }) 12 + .catch(() => undefined); 11 13 await wait(page, 2000); 12 14 } 13 - throw new Error('list page did not become interactive'); 15 + throw new Error("list page did not become interactive"); 14 16 }; 15 17 16 18 const openLists = async (page) => { 17 19 await page.goto(`${appBaseUrl}/lists`, { 18 - waitUntil: 'domcontentloaded', 20 + waitUntil: "domcontentloaded", 19 21 timeout: 60000, 20 22 }); 21 23 await wait(page, 3000); 22 - const newList = page.getByTestId('newUserListBtn').first(); 24 + const newList = page.getByTestId("newUserListBtn").first(); 23 25 if (await newList.count()) { 24 - await newList.waitFor({ state: 'visible', timeout: 15000 }); 26 + await newList.waitFor({ state: "visible", timeout: 15000 }); 25 27 } 26 28 }; 27 29 ··· 29 31 await page.goto( 30 32 `${appBaseUrl}/profile/${encodeURIComponent(handle)}/lists/${encodeURIComponent(listRkey)}`, 31 33 { 32 - waitUntil: 'domcontentloaded', 34 + waitUntil: "domcontentloaded", 33 35 timeout: 60000, 34 36 }, 35 37 ); ··· 37 39 await waitForListPageReady(page); 38 40 }; 39 41 40 - const waitForListTitle = async (page, title, timeout = 20000) => { 41 - await page.getByText(title, { exact: true }).first().waitFor({ state: 'visible', timeout }); 42 - }; 43 - 44 42 const fillListEditor = async (page, name, description) => { 45 43 const dialog = page.locator('[role="dialog"]').last(); 46 - await dialog.waitFor({ state: 'visible', timeout: 15000 }); 47 - await dialog.getByTestId('editListNameInput').fill(name); 48 - await dialog.getByTestId('editListDescriptionInput').fill(description); 44 + await dialog.waitFor({ state: "visible", timeout: 15000 }); 45 + await dialog.getByTestId("editListNameInput").fill(name); 46 + await dialog.getByTestId("editListDescriptionInput").fill(description); 49 47 return dialog; 50 48 }; 51 49 52 50 const saveListEditor = async (page) => { 53 51 const dialog = page.locator('[role="dialog"]').last(); 54 - const save = dialog.getByTestId('editProfileSaveBtn').last(); 55 - await save.waitFor({ state: 'visible', timeout: 15000 }); 56 - await page.waitForFunction(() => { 57 - const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 58 - return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 59 - }, undefined, { timeout: 15000 }); 52 + const save = dialog.getByTestId("editProfileSaveBtn").last(); 53 + await save.waitFor({ state: "visible", timeout: 15000 }); 54 + await page.waitForFunction( 55 + () => { 56 + const btn = document.querySelector( 57 + '[data-testid="editProfileSaveBtn"]', 58 + ); 59 + return ( 60 + Boolean(btn) && 61 + !btn.hasAttribute("disabled") && 62 + btn.getAttribute("aria-disabled") !== "true" 63 + ); 64 + }, 65 + undefined, 66 + { timeout: 15000 }, 67 + ); 60 68 await save.click({ noWaitAfter: true }); 61 - await dialog.waitFor({ state: 'hidden', timeout: 20000 }); 69 + await dialog.waitFor({ state: "hidden", timeout: 20000 }); 62 70 await wait(page, 3000); 63 71 }; 64 72 65 73 const createList = async (page, name, description) => { 66 74 await openLists(page); 67 - await page.getByTestId('newUserListBtn').first().click({ noWaitAfter: true }); 75 + await page 76 + .getByTestId("newUserListBtn") 77 + .first() 78 + .click({ noWaitAfter: true }); 68 79 await wait(page, 1000); 69 80 await fillListEditor(page, name, description); 70 81 await saveListEditor(page); ··· 73 84 }; 74 85 75 86 const openCurrentListOptions = async (page) => { 76 - const btn = page.getByTestId('moreOptionsBtn').first(); 77 - await btn.waitFor({ state: 'visible', timeout: 15000 }); 87 + const btn = page.getByTestId("moreOptionsBtn").first(); 88 + await btn.waitFor({ state: "visible", timeout: 15000 }); 78 89 await btn.click({ noWaitAfter: true }); 79 90 const menu = page.locator('[role="menu"]').last(); 80 - await menu.waitFor({ state: 'visible', timeout: 10000 }); 91 + await menu.waitFor({ state: "visible", timeout: 10000 }); 81 92 return menu; 82 93 }; 83 94 84 95 const editCurrentList = async (page, name, description) => { 85 96 await openCurrentListOptions(page); 86 - await page.getByRole('menuitem', { name: /edit list details/i }).click({ noWaitAfter: true }); 97 + await page 98 + .getByRole("menuitem", { name: /edit list details/i }) 99 + .click({ noWaitAfter: true }); 87 100 await wait(page, 800); 88 101 await fillListEditor(page, name, description); 89 102 await saveListEditor(page); ··· 94 107 const deleteCurrentList = async (page) => { 95 108 const beforeUrl = page.url(); 96 109 await openCurrentListOptions(page); 97 - await page.getByRole('menuitem', { name: /delete list/i }).click({ noWaitAfter: true }); 110 + await page 111 + .getByRole("menuitem", { name: /delete list/i }) 112 + .click({ noWaitAfter: true }); 98 113 const dialog = page.locator('[role="dialog"]').last(); 99 - await dialog.waitFor({ state: 'visible', timeout: 15000 }); 100 - const confirm = dialog.getByRole('button', { name: /^delete$/i }).last(); 114 + await dialog.waitFor({ state: "visible", timeout: 15000 }); 115 + const confirm = dialog.getByRole("button", { name: /^delete$/i }).last(); 101 116 await confirm.click({ noWaitAfter: true }); 102 - await dialog.waitFor({ state: 'hidden', timeout: 20000 }); 117 + await dialog.waitFor({ state: "hidden", timeout: 20000 }); 103 118 await page.waitForFunction( 104 - (url) => window.location.href !== url && !/\/lists\/[^/?#]+/.test(window.location.pathname), 119 + (url) => 120 + window.location.href !== url && 121 + !/\/lists\/[^/?#]+/.test(window.location.pathname), 105 122 beforeUrl, 106 123 { timeout: 20000 }, 107 124 ); ··· 110 127 }; 111 128 112 129 const openListPeopleTab = async (page) => { 113 - await page.getByRole('tab', { name: /^People$/i }).click({ noWaitAfter: true }); 130 + await page 131 + .getByRole("tab", { name: /^People$/i }) 132 + .click({ noWaitAfter: true }); 114 133 await wait(page, 1500); 115 134 }; 116 135 117 136 const openAddPeopleToList = async (page) => { 118 137 await openListPeopleTab(page); 119 - const add = page.getByRole('button', { name: /start adding people|add people/i }).last(); 120 - await add.waitFor({ state: 'visible', timeout: 15000 }); 138 + const add = page 139 + .getByRole("button", { name: /start adding people|add people/i }) 140 + .last(); 141 + await add.waitFor({ state: "visible", timeout: 15000 }); 121 142 await add.click({ noWaitAfter: true }); 122 - await page.getByText(/^Add people to list$/i).last().waitFor({ state: 'visible', timeout: 15000 }); 143 + await page 144 + .getByText(/^Add people to list$/i) 145 + .last() 146 + .waitFor({ state: "visible", timeout: 15000 }); 123 147 await wait(page, 1000); 124 148 }; 125 149 126 150 const closeAddPeopleToList = async (page) => { 127 - const close = page.getByRole('button', { name: /^close$/i }).last(); 151 + const close = page.getByRole("button", { name: /^close$/i }).last(); 128 152 if (await close.count()) { 129 153 await close.click({ noWaitAfter: true }).catch(() => undefined); 130 154 } else { 131 - await page.keyboard.press('Escape').catch(() => undefined); 155 + await page.keyboard.press("Escape").catch(() => undefined); 132 156 } 133 157 await wait(page, 1000); 134 158 }; 135 159 136 160 const searchAddPeopleList = async (page, handle) => { 137 - const search = page.getByPlaceholder('Search').last(); 138 - await search.fill(handle.replace(/^@/, '')); 161 + const search = page.getByPlaceholder("Search").last(); 162 + await search.fill(handle.replace(/^@/, "")); 139 163 await wait(page, 2500); 140 - await page.getByText(`@${handle.replace(/^@/, '')}`).last().waitFor({ state: 'visible', timeout: 15000 }); 164 + await page 165 + .getByText(`@${handle.replace(/^@/, "")}`) 166 + .last() 167 + .waitFor({ state: "visible", timeout: 15000 }); 141 168 }; 142 169 143 170 const addUserToCurrentList = async (page, handle) => { 144 171 await openAddPeopleToList(page); 145 172 await searchAddPeopleList(page, handle); 146 - const add = page.getByRole('button', { name: /add user to list/i }).last(); 173 + const add = page.getByRole("button", { name: /add user to list/i }).last(); 147 174 if (!(await add.count())) { 148 - const shortHandle = handle.replace(/^@/, ''); 175 + const shortHandle = handle.replace(/^@/, ""); 149 176 const profileLink = page 150 177 .locator('[role="dialog"]') 151 178 .last() 152 - .getByRole('link', { name: new RegExp(`@?${shortHandle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i') }) 179 + .getByRole("link", { 180 + name: new RegExp( 181 + `@?${shortHandle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, 182 + "i", 183 + ), 184 + }) 153 185 .last(); 154 186 if (await profileLink.count()) { 155 187 throw new Error( 156 188 `search result for @${shortHandle} rendered in "Add people to list" modal, but no add action was available`, 157 189 ); 158 190 } 159 - throw new Error(`no add action was available for @${shortHandle} in "Add people to list" modal`); 191 + throw new Error( 192 + `no add action was available for @${shortHandle} in "Add people to list" modal`, 193 + ); 160 194 } 161 195 await add.click({ noWaitAfter: true }); 162 196 await wait(page, 2000); 163 - const remove = page.getByRole('button', { name: /remove user from list/i }).last(); 164 - await remove.waitFor({ state: 'visible', timeout: 15000 }); 197 + const remove = page 198 + .getByRole("button", { name: /remove user from list/i }) 199 + .last(); 200 + await remove.waitFor({ state: "visible", timeout: 15000 }); 165 201 await closeAddPeopleToList(page); 166 - await page.getByText(`@${handle.replace(/^@/, '')}`).first().waitFor({ state: 'visible', timeout: 15000 }); 202 + await page 203 + .getByText(`@${handle.replace(/^@/, "")}`) 204 + .first() 205 + .waitFor({ state: "visible", timeout: 15000 }); 167 206 return { handle }; 168 207 }; 169 208 170 209 const removeUserFromCurrentList = async (page, handle) => { 171 210 await openListPeopleTab(page); 172 211 const edit = page.getByTestId(`user-${handle}-editBtn`).first(); 173 - await edit.waitFor({ state: 'visible', timeout: 15000 }); 212 + await edit.waitFor({ state: "visible", timeout: 15000 }); 174 213 await edit.click({ noWaitAfter: true }); 175 214 await wait(page, 1000); 176 215 let remove = page.getByTestId(`user-${handle}-addBtn`).first(); 177 216 if (!(await remove.count())) { 178 - remove = page.getByRole('button', { name: /^remove$/i }).last(); 217 + remove = page.getByRole("button", { name: /^remove$/i }).last(); 179 218 } 180 219 await remove.click({ noWaitAfter: true }); 181 220 await wait(page, 2000); 182 - const done = page.getByRole('button', { name: /^done$/i }).last(); 221 + const done = page.getByRole("button", { name: /^done$/i }).last(); 183 222 if (await done.count()) { 184 223 await done.click({ noWaitAfter: true }); 185 224 await wait(page, 1000);
+44 -34
src/browser/lib/page-auth-actions.mjs
··· 4 4 wait, 5 5 loginToBlueskyApp, 6 6 }) => { 7 - const login = async ( 7 + const login = ( 8 8 page, 9 - { 10 - pdsHost, 11 - loginIdentifier, 12 - password, 13 - notes, 14 - noteTarget, 15 - }, 9 + { pdsHost, loginIdentifier, password, notes, noteTarget }, 16 10 ) => 17 11 loginToBlueskyApp({ 18 12 page, ··· 24 18 noteTarget, 25 19 }); 26 20 27 - const completeAgeAssuranceIfNeeded = async (page, { birthdate, notes, noteText }) => { 28 - const addBirthdate = page.getByRole('button', { name: /(?:update|add) your birthdate/i }); 21 + const completeAgeAssuranceIfNeeded = async ( 22 + page, 23 + { birthdate, notes, noteText }, 24 + ) => { 25 + const addBirthdate = page.getByRole("button", { 26 + name: /(?:update|add) your birthdate/i, 27 + }); 29 28 if (await addBirthdate.count()) { 30 29 await addBirthdate.click({ noWaitAfter: true }); 31 30 await wait(page, 800); 32 - await page.getByTestId('birthdayInput').fill(birthdate); 33 - await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 31 + await page.getByTestId("birthdayInput").fill(birthdate); 32 + await page 33 + .getByRole("button", { name: /save birthdate/i }) 34 + .click({ noWaitAfter: true }); 34 35 await wait(page, 3000); 35 36 if (Array.isArray(notes) && noteText) { 36 37 notes.push(noteText); ··· 40 41 41 42 const gotoProfile = async (page, handle) => { 42 43 await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 43 - waitUntil: 'domcontentloaded', 44 + waitUntil: "domcontentloaded", 44 45 timeout: 60000, 45 46 }); 46 47 await wait(page, 3000); 47 48 }; 48 49 49 50 const waitForProfileHandle = async (page, handle, timeout = 20000) => { 50 - const shortHandle = handle.replace(/^@/, ''); 51 - await page.getByText(`@${shortHandle}`).first().waitFor({ state: 'visible', timeout }); 51 + const shortHandle = handle.replace(/^@/, ""); 52 + await page 53 + .getByText(`@${shortHandle}`) 54 + .first() 55 + .waitFor({ state: "visible", timeout }); 52 56 }; 53 57 54 58 const maybeFollow = async (page) => { 55 - const follow = page.getByTestId('followBtn').first(); 59 + const follow = page.getByTestId("followBtn").first(); 56 60 if (await follow.count()) { 57 - const label = (await follow.getAttribute('aria-label')) ?? ''; 58 - if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 59 - return { note: 'already following' }; 61 + const label = (await follow.getAttribute("aria-label")) ?? ""; 62 + if ( 63 + /following/i.test(label) || 64 + /^Following$/i.test((await follow.innerText()).trim()) 65 + ) { 66 + return { note: "already following" }; 60 67 } 61 68 await follow.click({ noWaitAfter: true }); 62 69 await wait(page, 2000); 63 - return { note: 'follow attempted' }; 70 + return { note: "follow attempted" }; 64 71 } 65 - const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 72 + const roleFollow = page.getByRole("button", { name: /follow/i }).first(); 66 73 if (!(await roleFollow.count())) { 67 - return { note: 'follow button unavailable' }; 74 + return { note: "follow button unavailable" }; 68 75 } 69 - const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 70 - if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 71 - return { note: 'already following' }; 76 + const label = (await roleFollow.getAttribute("aria-label")) ?? ""; 77 + if ( 78 + /following/i.test(label) || 79 + /^Following$/i.test((await roleFollow.innerText()).trim()) 80 + ) { 81 + return { note: "already following" }; 72 82 } 73 83 await roleFollow.click({ noWaitAfter: true }); 74 84 await wait(page, 2000); 75 - return { note: 'follow attempted via role button' }; 85 + return { note: "follow attempted via role button" }; 76 86 }; 77 87 78 88 const maybeUnfollow = async (page) => { 79 - const btn = page.getByTestId('unfollowBtn').first(); 89 + const btn = page.getByTestId("unfollowBtn").first(); 80 90 if (!(await btn.count())) { 81 - return { note: 'already not following' }; 91 + return { note: "already not following" }; 82 92 } 83 93 await btn.click({ noWaitAfter: true }); 84 94 await wait(page, 2000); 85 - return { note: 'unfollow attempted' }; 95 + return { note: "unfollow attempted" }; 86 96 }; 87 97 88 98 const openNotifications = async (page) => { 89 99 await page.goto(`${appBaseUrl}/notifications`, { 90 - waitUntil: 'domcontentloaded', 100 + waitUntil: "domcontentloaded", 91 101 timeout: 60000, 92 102 }); 93 103 await wait(page, 3000); 94 104 const heading = page.getByText(/^Notifications$/).first(); 95 105 if (await heading.count()) { 96 - await heading.waitFor({ state: 'visible', timeout: 15000 }); 106 + await heading.waitFor({ state: "visible", timeout: 15000 }); 97 107 } 98 108 }; 99 109 100 110 const openSavedPosts = async (page) => { 101 111 await page.goto(`${appBaseUrl}/saved`, { 102 - waitUntil: 'domcontentloaded', 112 + waitUntil: "domcontentloaded", 103 113 timeout: 60000, 104 114 }); 105 115 await wait(page, 3000); 106 116 }; 107 117 108 118 const openProfileTab = async (page, name) => { 109 - const tab = page.getByRole('tab', { name }).first(); 110 - await tab.waitFor({ state: 'visible', timeout: 15000 }); 119 + const tab = page.getByRole("tab", { name }).first(); 120 + await tab.waitFor({ state: "visible", timeout: 15000 }); 111 121 await tab.click({ noWaitAfter: true }); 112 122 await wait(page, 2000); 113 123 };
+76 -50
src/browser/lib/page-feed-actions.mjs
··· 5 5 dismissBlockingOverlays, 6 6 }) => { 7 7 const composePost = async (page, text) => { 8 - await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 8 + await page 9 + .locator('[aria-label="Compose new post"]') 10 + .last() 11 + .click({ noWaitAfter: true }); 9 12 await wait(page, 800); 10 13 const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 11 14 await editor.click({ noWaitAfter: true }); 12 15 await editor.fill(text); 13 16 await wait(page, 300); 14 - await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 17 + await page 18 + .getByRole("button", { name: "Publish post" }) 19 + .click({ noWaitAfter: true }); 15 20 await wait(page, 4000); 16 21 }; 17 22 ··· 28 33 } 29 34 const text = normalizeText(await primaryText.textContent()); 30 35 if (text === needle) { 31 - await row.waitFor({ state: 'visible', timeout: 10000 }); 36 + await row.waitFor({ state: "visible", timeout: 10000 }); 32 37 return row; 33 38 } 34 39 } ··· 52 57 const count = await rows.count(); 53 58 if (count > 0) { 54 59 const row = rows.first(); 55 - await row.waitFor({ state: 'visible', timeout: 10000 }); 60 + await row.waitFor({ state: "visible", timeout: 10000 }); 56 61 return row; 57 62 } 58 63 await wait(page, 500); 59 64 } 60 - throw new Error('feed item not found'); 65 + throw new Error("feed item not found"); 61 66 }; 62 67 63 68 const clickLike = async (page, row) => { 64 - const btn = row.getByTestId('likeBtn').first(); 69 + const btn = row.getByTestId("likeBtn").first(); 65 70 await btn.click({ noWaitAfter: true }); 66 71 await wait(page, 1500); 67 72 }; 68 73 69 74 const clickRepost = async (page, row, actionPattern = /^Repost$/i) => { 70 75 await dismissBlockingOverlays(page); 71 - const btn = row.getByTestId('repostBtn').first(); 76 + const btn = row.getByTestId("repostBtn").first(); 72 77 await btn.click({ noWaitAfter: true }); 73 78 await wait(page, 500); 74 79 const repost = page.getByText(actionPattern).last(); ··· 82 87 }; 83 88 84 89 const ensureLiked = async (page, row) => { 85 - const btn = row.getByTestId('likeBtn').first(); 90 + const btn = row.getByTestId("likeBtn").first(); 86 91 const before = await buttonText(btn); 87 92 if (/unlike/i.test(before)) { 88 - return { note: 'already liked' }; 93 + return { note: "already liked" }; 89 94 } 90 95 await clickLike(page, row); 91 96 return { note: await buttonText(btn) }; 92 97 }; 93 98 94 99 const ensureNotLiked = async (page, row) => { 95 - const btn = row.getByTestId('likeBtn').first(); 100 + const btn = row.getByTestId("likeBtn").first(); 96 101 const before = await buttonText(btn); 97 102 if (!/unlike/i.test(before)) { 98 - return { note: 'already not liked' }; 103 + return { note: "already not liked" }; 99 104 } 100 105 await clickLike(page, row); 101 106 return { note: await buttonText(btn) }; 102 107 }; 103 108 104 109 const ensureReposted = async (page, row) => { 105 - const btn = row.getByTestId('repostBtn').first(); 110 + const btn = row.getByTestId("repostBtn").first(); 106 111 const before = await buttonText(btn); 107 112 if (/undo repost|remove repost/i.test(before)) { 108 - return { note: 'already reposted' }; 113 + return { note: "already reposted" }; 109 114 } 110 115 await clickRepost(page, row); 111 116 return { note: await buttonText(btn) }; 112 117 }; 113 118 114 119 const ensureNotReposted = async (page, row) => { 115 - const btn = row.getByTestId('repostBtn').first(); 120 + const btn = row.getByTestId("repostBtn").first(); 116 121 const before = await buttonText(btn); 117 122 if (!/undo repost|remove repost/i.test(before)) { 118 - return { note: 'already not reposted' }; 123 + return { note: "already not reposted" }; 119 124 } 120 125 await clickRepost(page, row, /^(?:Undo repost|Remove repost)$/i); 121 126 return { note: await buttonText(btn) }; 122 127 }; 123 128 124 129 const ensureBookmarked = async (page, row) => { 125 - const btn = row.getByTestId('postBookmarkBtn').first(); 130 + const btn = row.getByTestId("postBookmarkBtn").first(); 126 131 const before = await buttonText(btn); 127 132 if (/remove from saved posts/i.test(before)) { 128 - return { note: 'already bookmarked' }; 133 + return { note: "already bookmarked" }; 129 134 } 130 135 await btn.click({ noWaitAfter: true }); 131 136 await wait(page, 1500); ··· 133 138 }; 134 139 135 140 const ensureNotBookmarked = async (page, row) => { 136 - const btn = row.getByTestId('postBookmarkBtn').first(); 141 + const btn = row.getByTestId("postBookmarkBtn").first(); 137 142 const before = await buttonText(btn); 138 143 if (!/remove from saved posts/i.test(before)) { 139 - return { note: 'already not bookmarked' }; 144 + return { note: "already not bookmarked" }; 140 145 } 141 146 await btn.click({ noWaitAfter: true }); 142 147 await wait(page, 1500); ··· 149 154 '[aria-label="Rich-Text Editor"]', 150 155 '[contenteditable="true"][role="textbox"]', 151 156 '[contenteditable="true"][aria-multiline="true"]', 152 - ].join(', '), 157 + ].join(", "), 153 158 ); 154 159 155 160 const waitForVisibleEditor = async (page, timeout = 20000) => { ··· 165 170 } 166 171 await wait(page, 250); 167 172 } 168 - throw new Error('visible rich-text editor not found'); 173 + throw new Error("visible rich-text editor not found"); 169 174 }; 170 175 171 176 const firstVisibleLocator = async (locator) => { ··· 179 184 return null; 180 185 }; 181 186 182 - const publishComposer = async (page, text, { applyWritesLabel, publishLabel }) => { 187 + const publishComposer = async ( 188 + page, 189 + text, 190 + { applyWritesLabel, publishLabel }, 191 + ) => { 183 192 const editor = await waitForVisibleEditor(page); 184 193 await editor.click({ noWaitAfter: true }); 185 194 await editor.fill(text); 186 195 187 - const publish = page.getByTestId('composerPublishBtn').last(); 188 - await publish.waitFor({ state: 'visible', timeout: 15000 }); 196 + const publish = page.getByTestId("composerPublishBtn").last(); 197 + await publish.waitFor({ state: "visible", timeout: 15000 }); 189 198 const responsePromise = page.waitForResponse( 190 199 (res) => 191 - res.url().includes('/xrpc/com.atproto.repo.applyWrites') && 192 - res.request().method() === 'POST', 200 + res.url().includes("/xrpc/com.atproto.repo.applyWrites") && 201 + res.request().method() === "POST", 193 202 { timeout: 30000 }, 194 203 ); 195 204 await publish.click({ noWaitAfter: true }); 196 205 const response = await responsePromise; 197 206 if (response.status() !== 200) { 198 - throw new Error(`${applyWritesLabel} failed with status ${response.status()}`); 207 + throw new Error( 208 + `${applyWritesLabel} failed with status ${response.status()}`, 209 + ); 199 210 } 200 211 await wait(page, 4000); 201 212 202 - const buttonName = publishLabel instanceof RegExp ? publishLabel : /publish/i; 213 + const buttonName = 214 + publishLabel instanceof RegExp ? publishLabel : /publish/i; 203 215 await page 204 - .getByTestId('composerPublishBtn') 205 - .getByRole('button', { name: buttonName }) 216 + .getByTestId("composerPublishBtn") 217 + .getByRole("button", { name: buttonName }) 206 218 .waitFor({ 207 - state: 'detached', 219 + state: "detached", 208 220 timeout: 15000, 209 221 }) 210 222 .catch(() => undefined); ··· 212 224 213 225 const clickQuote = async (page, row, text) => { 214 226 await dismissBlockingOverlays(page); 215 - const btn = row.getByTestId('repostBtn').first(); 227 + const btn = row.getByTestId("repostBtn").first(); 216 228 await btn.click({ noWaitAfter: true }); 217 229 await wait(page, 500); 218 230 const quote = page.getByText(/^Quote post$/).last(); 219 231 if (!(await quote.count())) { 220 - throw new Error('quote option not available'); 232 + throw new Error("quote option not available"); 221 233 } 222 234 await quote.click({ noWaitAfter: true }); 223 235 await publishComposer(page, text, { 224 - applyWritesLabel: 'quote publish', 236 + applyWritesLabel: "quote publish", 225 237 publishLabel: /publish post/i, 226 238 }); 227 239 await dismissBlockingOverlays(page); ··· 234 246 return true; 235 247 } 236 248 237 - const composeReply = await firstVisibleLocator(page.getByRole('button', { name: /compose reply/i })); 249 + const composeReply = await firstVisibleLocator( 250 + page.getByRole("button", { name: /compose reply/i }), 251 + ); 238 252 if (composeReply) { 239 253 await composeReply.click({ noWaitAfter: true }); 240 254 await wait(page, 500); 241 - const afterComposeClick = await waitForVisibleEditor(page, 2000).catch(() => null); 255 + const afterComposeClick = await waitForVisibleEditor(page, 2000).catch( 256 + () => null, 257 + ); 242 258 if (afterComposeClick) { 243 259 return true; 244 260 } 245 261 } 246 262 247 - const writeYourReply = await firstVisibleLocator(page.getByText(/Write your reply/i)); 263 + const writeYourReply = await firstVisibleLocator( 264 + page.getByText(/Write your reply/i), 265 + ); 248 266 if (writeYourReply) { 249 267 await writeYourReply.click({ noWaitAfter: true, force: true }); 250 268 await wait(page, 500); 251 - const afterInlineClick = await waitForVisibleEditor(page, 2000).catch(() => null); 269 + const afterInlineClick = await waitForVisibleEditor(page, 2000).catch( 270 + () => null, 271 + ); 252 272 if (afterInlineClick) { 253 273 return true; 254 274 } 255 275 } 256 276 257 - const btn = await firstVisibleLocator(scope.getByTestId('replyBtn')); 277 + const btn = await firstVisibleLocator(scope.getByTestId("replyBtn")); 258 278 if (!btn) { 259 279 return false; 260 280 } ··· 269 289 await dismissBlockingOverlays(page); 270 290 271 291 await openReplyComposer(row); 272 - const firstAttempt = await waitForVisibleEditor(page, 4000).catch(() => null); 292 + const firstAttempt = await waitForVisibleEditor(page, 4000).catch( 293 + () => null, 294 + ); 273 295 if (!firstAttempt) { 274 - const postText = row.getByTestId('postText').first(); 296 + const postText = row.getByTestId("postText").first(); 275 297 if (await postText.count()) { 276 - await postText.click({ noWaitAfter: true, force: true }).catch(() => undefined); 298 + await postText 299 + .click({ noWaitAfter: true, force: true }) 300 + .catch(() => undefined); 277 301 await wait(page, 1500); 278 302 await dismissBlockingOverlays(page); 279 303 } ··· 281 305 } 282 306 283 307 await publishComposer(page, text, { 284 - applyWritesLabel: 'reply publish', 308 + applyWritesLabel: "reply publish", 285 309 publishLabel: /publish reply|reply/i, 286 310 }); 287 311 await dismissBlockingOverlays(page); 288 312 }; 289 313 290 314 const openPostOptions = async (page, row) => { 291 - const btn = row.getByTestId('postDropdownBtn').first(); 315 + const btn = row.getByTestId("postDropdownBtn").first(); 292 316 await btn.click({ noWaitAfter: true }); 293 317 const menu = page.locator('[role="menu"]').last(); 294 - await menu.waitFor({ state: 'visible', timeout: 10000 }); 318 + await menu.waitFor({ state: "visible", timeout: 10000 }); 295 319 return menu; 296 320 }; 297 321 298 322 const deletePostRow = async (page, row) => { 299 323 await openPostOptions(page, row); 300 - const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 301 - await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 324 + const deleteItem = page 325 + .getByRole("menuitem", { name: /delete post/i }) 326 + .first(); 327 + await deleteItem.waitFor({ state: "visible", timeout: 10000 }); 302 328 await deleteItem.click({ noWaitAfter: true }); 303 329 const dialog = page.locator('[role="dialog"]').last(); 304 - await dialog.waitFor({ state: 'visible', timeout: 10000 }); 305 - const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 330 + await dialog.waitFor({ state: "visible", timeout: 10000 }); 331 + const confirm = page.getByRole("button", { name: /^Delete$/i }).last(); 306 332 await confirm.click({ noWaitAfter: true }); 307 - await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 333 + await dialog.waitFor({ state: "hidden", timeout: 15000 }); 308 334 await wait(page, 3000); 309 335 }; 310 336
+49 -26
src/browser/lib/page-profile-edit-actions.mjs
··· 1 - import fs from 'node:fs/promises'; 2 - import path from 'node:path'; 1 + import fs from "node:fs/promises"; 2 + import path from "node:path"; 3 3 4 4 export const createPageProfileEditActions = ({ 5 5 artifactsDir, ··· 9 9 notes, 10 10 }) => { 11 11 const ensureAvatarFixture = async () => { 12 - const file = path.join(artifactsDir, 'avatar-fixture.png'); 13 - await fs.writeFile(file, Buffer.from(avatarPngBase64, 'base64')); 12 + const file = path.join(artifactsDir, "avatar-fixture.png"); 13 + await fs.writeFile(file, Buffer.from(avatarPngBase64, "base64")); 14 14 return file; 15 15 }; 16 16 ··· 20 20 const count = await fileInputs.count(); 21 21 22 22 if (count === 0) { 23 - const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 23 + const changeAvatar = page.getByTestId("changeAvatarBtn").first(); 24 24 if (await changeAvatar.count()) { 25 25 await changeAvatar.click({ noWaitAfter: true }); 26 26 await wait(page, 500); 27 - const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 27 + const uploadFromFiles = page 28 + .getByTestId("changeAvatarLibraryBtn") 29 + .first(); 28 30 if (await uploadFromFiles.count()) { 29 - const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 31 + const chooserPromise = page.waitForEvent("filechooser", { 32 + timeout: 10000, 33 + }); 30 34 await uploadFromFiles.click({ noWaitAfter: true }); 31 35 const chooser = await chooserPromise; 32 36 await chooser.setFiles(avatarFile); 33 37 await wait(page, 750); 34 38 const editImageHeading = page.getByText(/^Edit image$/).last(); 35 39 if (await editImageHeading.count()) { 36 - await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 37 - const cropSave = page.getByRole('button', { name: 'Save' }).last(); 40 + await editImageHeading.waitFor({ 41 + state: "visible", 42 + timeout: 10000, 43 + }); 44 + const cropSave = page.getByRole("button", { name: "Save" }).last(); 38 45 await cropSave.click({ noWaitAfter: true }); 39 - await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 46 + await editImageHeading.waitFor({ state: "hidden", timeout: 15000 }); 40 47 if (Array.isArray(notes)) { 41 - notes.push('profile avatar crop saved'); 48 + notes.push("profile avatar crop saved"); 42 49 } 43 50 } 44 51 if (Array.isArray(notes)) { 45 - notes.push('profile avatar uploaded via file chooser'); 52 + notes.push("profile avatar uploaded via file chooser"); 46 53 } 47 54 await wait(page, 1500); 48 55 return avatarFile; ··· 51 58 } 52 59 53 60 if (count === 0) { 54 - throw new Error('profile avatar file input unavailable'); 61 + throw new Error("profile avatar file input unavailable"); 55 62 } 56 63 57 64 await fileInputs.first().setInputFiles(avatarFile); ··· 63 70 }; 64 71 65 72 const editProfile = async (page, { profileNote, handle }) => { 66 - const edit = page.getByRole('button', { name: /edit profile/i }); 73 + const edit = page.getByRole("button", { name: /edit profile/i }); 67 74 if (!(await edit.count())) { 68 - const detail = handle ? ` for ${handle}` : ''; 75 + const detail = handle ? ` for ${handle}` : ""; 69 76 throw new Error(`edit profile button unavailable${detail}`); 70 77 } 71 78 await edit.click({ noWaitAfter: true }); ··· 77 84 await bioField.fill(profileNote); 78 85 const actual = await bioField.inputValue(); 79 86 if (actual !== profileNote) { 80 - const detail = handle ? ` for ${handle}` : ''; 81 - throw new Error(`profile description fill did not stick${detail}: ${actual}`); 87 + const detail = handle ? ` for ${handle}` : ""; 88 + throw new Error( 89 + `profile description fill did not stick${detail}: ${actual}`, 90 + ); 82 91 } 83 92 } 84 - const save = page.getByTestId('editProfileSaveBtn'); 85 - await save.waitFor({ state: 'visible', timeout: 15000 }); 86 - await page.waitForFunction(() => { 87 - const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 88 - return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 89 - }, undefined, { timeout: 15000 }); 93 + const save = page.getByTestId("editProfileSaveBtn"); 94 + await save.waitFor({ state: "visible", timeout: 15000 }); 95 + await page.waitForFunction( 96 + () => { 97 + const btn = document.querySelector( 98 + '[data-testid="editProfileSaveBtn"]', 99 + ); 100 + return ( 101 + Boolean(btn) && 102 + !btn.hasAttribute("disabled") && 103 + btn.getAttribute("aria-disabled") !== "true" 104 + ); 105 + }, 106 + undefined, 107 + { timeout: 15000 }, 108 + ); 90 109 await save.click({ noWaitAfter: true }); 91 - await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 92 - timeout: 15000, 93 - }); 110 + await page.waitForFunction( 111 + () => !document.querySelector('[data-testid="editProfileSaveBtn"]'), 112 + undefined, 113 + { 114 + timeout: 15000, 115 + }, 116 + ); 94 117 await wait(page, 3000); 95 118 return { avatarFile, profileNote }; 96 119 };
+6 -5
src/browser/lib/playwright-runtime.mjs
··· 1 1 let playwright; 2 2 3 3 try { 4 - playwright = await import('playwright'); 4 + playwright = await import("playwright"); 5 5 } catch (primaryError) { 6 6 try { 7 - playwright = await import('../../../../tools/browser-automation/node_modules/playwright/index.mjs'); 7 + playwright = 8 + await import("../../../../tools/browser-automation/node_modules/playwright/index.mjs"); 8 9 } catch { 9 10 throw new Error( 10 11 [ 11 - 'Unable to load Playwright.', 12 - 'Install dependencies with `npm install` and then install a browser with `npx playwright install chromium`.', 12 + "Unable to load Playwright.", 13 + "Install dependencies with `npm install` and then install a browser with `npx playwright install chromium`.", 13 14 `Original error: ${String(primaryError?.message ?? primaryError)}`, 14 - ].join(' '), 15 + ].join(" "), 15 16 ); 16 17 } 17 18 }
+134 -72
src/browser/lib/runtime-utils.mjs
··· 1 - import fs from 'node:fs/promises'; 1 + import fs from "node:fs/promises"; 2 2 3 - const SYSTEM_GOOGLE_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 3 + const SYSTEM_GOOGLE_CHROME = 4 + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; 4 5 5 6 export const AVATAR_PNG_BASE64 = 6 - 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAV0lEQVR4nO3PQQ0AIBDAMMC/58MCP7KkVbDX1pk5A6gWUC2gWkC1gGoB1QKqBVQLqBZQLaBaQLWAagHVAqoFVAuoFlAtoFpAtYBqAdUCqgVUC6gWUC2gWkD1B4a2AX/y3CvgAAAAAElFTkSuQmCC'; 7 + "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAV0lEQVR4nO3PQQ0AIBDAMMC/58MCP7KkVbDX1pk5A6gWUC2gWkC1gGoB1QKqBVQLqBZQLaBaQLWAagHVAqoFVAuoFlAtoFpAtYBqAdUCqgVUC6gWUC2gWkD1B4a2AX/y3CvgAAAAAElFTkSuQmCC"; 7 8 8 9 export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 9 10 10 - export const normalizeText = (text) => (text || '').replace(/\s+/g, ' ').trim(); 11 + export const normalizeText = (text) => (text || "").replace(/\s+/g, " ").trim(); 11 12 12 13 export const createBaseSummary = (fields = {}) => ({ 13 14 startedAt: new Date().toISOString(), ··· 36 37 captureArtifacts, 37 38 defaultTimeoutMs, 38 39 }) => { 39 - return async (name, fn, { optional = false, timeoutMs, pageNames = [] } = {}) => { 40 + return async ( 41 + name, 42 + fn, 43 + { optional = false, timeoutMs, pageNames = [] } = {}, 44 + ) => { 40 45 const effectiveTimeoutMs = Number(timeoutMs || defaultTimeoutMs || 0); 41 - emitProgress('start', name); 46 + emitProgress("start", name); 42 47 let timeoutId; 43 48 try { 44 - const result = effectiveTimeoutMs > 0 45 - ? await Promise.race([ 46 - fn(), 47 - new Promise((_, reject) => { 48 - timeoutId = setTimeout(() => { 49 - reject(new Error(`step timed out after ${effectiveTimeoutMs}ms`)); 50 - }, effectiveTimeoutMs); 51 - }), 52 - ]) 53 - : await fn(); 54 - const artifacts = await captureArtifacts({ name, pageNames, failed: false }); 55 - recordStep(summary, name, 'ok', { ...artifacts, ...(result ?? {}) }); 56 - emitProgress('ok', name); 49 + const result = 50 + effectiveTimeoutMs > 0 51 + ? await Promise.race([ 52 + fn(), 53 + new Promise((_, reject) => { 54 + timeoutId = setTimeout(() => { 55 + reject( 56 + new Error(`step timed out after ${effectiveTimeoutMs}ms`), 57 + ); 58 + }, effectiveTimeoutMs); 59 + }), 60 + ]) 61 + : await fn(); 62 + const artifacts = await captureArtifacts({ 63 + name, 64 + pageNames, 65 + failed: false, 66 + }); 67 + recordStep(summary, name, "ok", { ...artifacts, ...(result ?? {}) }); 68 + emitProgress("ok", name); 57 69 return result; 58 70 } catch (error) { 59 - const artifacts = await captureArtifacts({ name, pageNames, failed: true }); 60 - recordStep(summary, name, optional ? 'skipped' : 'failed', { 71 + const artifacts = await captureArtifacts({ 72 + name, 73 + pageNames, 74 + failed: true, 75 + }); 76 + recordStep(summary, name, optional ? "skipped" : "failed", { 61 77 ...artifacts, 62 78 error: String(error?.message ?? error), 63 79 }); 64 - emitProgress(optional ? 'skip' : 'fail', name, String(error?.message ?? error)); 80 + emitProgress( 81 + optional ? "skip" : "fail", 82 + name, 83 + String(error?.message ?? error), 84 + ); 65 85 if (!optional) { 66 86 throw error; 67 87 } ··· 90 110 try { 91 111 await fs.access(SYSTEM_GOOGLE_CHROME); 92 112 candidates.push({ 93 - label: 'system-google-chrome', 113 + label: "system-google-chrome", 94 114 options: { ...base, executablePath: SYSTEM_GOOGLE_CHROME }, 95 115 }); 96 116 } catch { ··· 98 118 } 99 119 } 100 120 candidates.push({ 101 - label: 'playwright-chromium', 102 - options: { ...base, channel: 'chromium' }, 121 + label: "playwright-chromium", 122 + options: { ...base, channel: "chromium" }, 103 123 }); 104 124 return candidates; 105 125 }; ··· 136 156 try { 137 157 const res = await fetch(url, { 138 158 ...options, 139 - redirect: options.redirect || 'follow', 159 + redirect: options.redirect || "follow", 140 160 signal: controller.signal, 141 161 }); 142 162 return { ok: res.ok, status: res.status, url: res.url }; ··· 146 166 }; 147 167 148 168 export const buttonText = async (locator) => { 149 - const label = await locator.getAttribute('aria-label'); 169 + const label = await locator.getAttribute("aria-label"); 150 170 if (label && label.trim()) { 151 171 return label.trim(); 152 172 } 153 - const text = await locator.innerText().catch(() => ''); 173 + const text = await locator.innerText().catch(() => ""); 154 174 return text.trim(); 155 175 }; 156 176 157 177 export const dismissBlockingOverlays = async (page) => { 158 178 const backdrop = page.locator('[aria-label*="click to close"]').last(); 159 179 if (await backdrop.count()) { 160 - await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 180 + await backdrop 181 + .click({ force: true, noWaitAfter: true }) 182 + .catch(() => undefined); 161 183 await page.waitForTimeout(400); 162 184 } 163 185 164 186 const dialog = page.locator('[role="dialog"][aria-modal="true"]').last(); 165 187 if (await dialog.count()) { 166 - const close = dialog.getByRole('button', { name: /close/i }).last(); 188 + const close = dialog.getByRole("button", { name: /close/i }).last(); 167 189 if (await close.count()) { 168 190 await close.click({ noWaitAfter: true }).catch(() => undefined); 169 191 await page.waitForTimeout(400); 170 192 } 171 - await page.keyboard.press('Escape').catch(() => undefined); 193 + await page.keyboard.press("Escape").catch(() => undefined); 172 194 await page.waitForTimeout(400); 173 195 } 174 196 }; ··· 182 204 notes, 183 205 noteTarget, 184 206 }) => { 185 - let loginPath = 'legacy-service-picker'; 207 + let loginPath = "legacy-service-picker"; 186 208 const activeScope = () => page.locator('[role="dialog"]').last(); 187 209 188 210 const clickNamedControl = async (name) => { 189 211 const scope = activeScope(); 190 - const asButton = scope.getByRole('button', { name }).first(); 212 + const asButton = scope.getByRole("button", { name }).first(); 191 213 if (await asButton.count()) { 192 214 await asButton.click({ noWaitAfter: true, force: true }); 193 215 return; 194 216 } 195 - const asLink = scope.getByRole('link', { name }).first(); 217 + const asLink = scope.getByRole("link", { name }).first(); 196 218 if (await asLink.count()) { 197 219 await asLink.click({ noWaitAfter: true, force: true }); 198 220 return; 199 221 } 200 - await scope.getByText(name).last().click({ noWaitAfter: true, force: true }); 222 + await scope 223 + .getByText(name) 224 + .last() 225 + .click({ noWaitAfter: true, force: true }); 201 226 }; 202 227 203 228 // The service picker dialog can animate an overlay layer over its own buttons. 204 229 // Force-click the in-dialog choices so login is not gated on that transient layer. 205 - await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); 206 - await clickNamedControl('Sign in'); 230 + await page.goto(appUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); 231 + await clickNamedControl("Sign in"); 207 232 await page.waitForTimeout(1000); 208 233 209 - const loginIdentifierField = page.getByPlaceholder('Username or email address'); 234 + const loginIdentifierField = page.getByPlaceholder( 235 + "Username or email address", 236 + ); 210 237 if (await loginIdentifierField.count()) { 211 - const serviceButton = page.getByTestId('selectServiceButton').first(); 212 - const currentService = await buttonText(serviceButton).catch(() => ''); 213 - if (!(new RegExp(pdsHost.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).test(currentService)) { 214 - loginPath = 'inline-provider-switcher'; 238 + const serviceButton = page.getByTestId("selectServiceButton").first(); 239 + const currentService = await buttonText(serviceButton).catch(() => ""); 240 + if ( 241 + !new RegExp(pdsHost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").test( 242 + currentService, 243 + ) 244 + ) { 245 + loginPath = "inline-provider-switcher"; 215 246 await serviceButton.click({ noWaitAfter: true, force: true }); 216 247 await page.waitForTimeout(500); 217 - await clickNamedControl('Custom'); 248 + await clickNamedControl("Custom"); 218 249 await page.waitForTimeout(500); 219 - await page.getByPlaceholder('my-server.com').fill(pdsHost); 220 - await page.getByRole('button', { name: 'Done' }).click({ noWaitAfter: true }); 250 + await page.getByPlaceholder("my-server.com").fill(pdsHost); 251 + await page 252 + .getByRole("button", { name: "Done" }) 253 + .click({ noWaitAfter: true }); 221 254 await page.waitForTimeout(500); 222 255 } else { 223 - loginPath = 'inline-provider-already-selected'; 256 + loginPath = "inline-provider-already-selected"; 224 257 } 225 258 } else { 226 - loginPath = 'legacy-service-picker'; 227 - await clickNamedControl('Bluesky Social'); 259 + loginPath = "legacy-service-picker"; 260 + await clickNamedControl("Bluesky Social"); 228 261 await page.waitForTimeout(500); 229 - await clickNamedControl('Custom'); 262 + await clickNamedControl("Custom"); 230 263 await page.waitForTimeout(500); 231 - await page.getByPlaceholder('my-server.com').fill(pdsHost); 232 - await page.getByRole('button', { name: 'Done' }).click({ noWaitAfter: true }); 264 + await page.getByPlaceholder("my-server.com").fill(pdsHost); 265 + await page 266 + .getByRole("button", { name: "Done" }) 267 + .click({ noWaitAfter: true }); 233 268 await page.waitForTimeout(500); 234 269 } 235 270 236 - const close = page.getByRole('button', { name: 'Close welcome modal' }); 271 + const close = page.getByRole("button", { name: "Close welcome modal" }); 237 272 if (await close.count()) { 238 273 await close.click({ noWaitAfter: true }).catch(() => undefined); 239 274 await page.waitForTimeout(300); 240 275 } 241 - await page.getByPlaceholder('Username or email address').fill(loginIdentifier); 242 - await page.getByPlaceholder('Password').fill(password); 243 - await page.getByTestId('loginNextButton').click({ noWaitAfter: true }); 276 + await page 277 + .getByPlaceholder("Username or email address") 278 + .fill(loginIdentifier); 279 + await page.getByPlaceholder("Password").fill(password); 280 + await page.getByTestId("loginNextButton").click({ noWaitAfter: true }); 244 281 await page.waitForTimeout(3000); 245 282 if (Array.isArray(notes)) { 246 283 notes.push(`login path for ${noteTarget || pdsHost}: ${loginPath}`); ··· 267 304 } 268 305 await sleep(intervalMs); 269 306 } 270 - throw new Error(`${name} did not succeed before timeout; last status=${last?.status ?? 'none'}`); 307 + throw new Error( 308 + `${name} did not succeed before timeout; last status=${last?.status ?? "none"}`, 309 + ); 271 310 }; 272 311 273 - export const launchBrowserWithFallback = async ({ chromium, config, summary }) => { 312 + export const launchBrowserWithFallback = async ({ 313 + chromium, 314 + config, 315 + summary, 316 + }) => { 274 317 const errors = []; 275 318 for (const candidate of await buildBrowserLaunchCandidates(config)) { 276 319 try { 277 320 const browser = await chromium.launch(candidate.options); 278 - summary.notes.push(`browser launch candidate succeeded: ${candidate.label}`); 321 + summary.notes.push( 322 + `browser launch candidate succeeded: ${candidate.label}`, 323 + ); 279 324 return browser; 280 325 } catch (error) { 281 326 errors.push(`${candidate.label}: ${String(error?.message ?? error)}`); 282 327 } 283 328 } 284 - throw new Error(`unable to launch browser via any candidate: ${errors.join(' | ')}`); 329 + throw new Error( 330 + `unable to launch browser via any candidate: ${errors.join(" | ")}`, 331 + ); 285 332 }; 286 333 287 334 export const attachPageLogging = ({ ··· 292 339 }) => { 293 340 const maybePage = pageName ? { page: pageName } : {}; 294 341 295 - page.on('console', (msg) => { 342 + page.on("console", (msg) => { 296 343 summary.console.push({ 297 344 ...maybePage, 298 345 type: msg.type(), ··· 300 347 }); 301 348 }); 302 349 303 - page.on('pageerror', (error) => { 350 + page.on("pageerror", (error) => { 304 351 summary.pageErrors.push({ 305 352 ...maybePage, 306 353 message: String(error?.message ?? error), ··· 308 355 }); 309 356 }); 310 357 311 - page.on('requestfailed', (req) => { 358 + page.on("requestfailed", (req) => { 312 359 summary.requestFailures.push({ 313 360 ...maybePage, 314 361 url: req.url(), 315 362 method: req.method(), 316 - errorText: req.failure()?.errorText ?? 'unknown', 363 + errorText: req.failure()?.errorText ?? "unknown", 317 364 }); 318 365 }); 319 366 320 - page.on('response', (res) => { 367 + page.on("response", (res) => { 321 368 const status = res.status(); 322 - if (res.url().includes('/xrpc/')) { 369 + if (res.url().includes("/xrpc/")) { 323 370 summary.xrpc.push({ 324 371 ...maybePage, 325 372 url: res.url(), ··· 342 389 }; 343 390 344 391 export const createProgressEmitter = ({ enabled, write = console.error }) => { 345 - return (status, name, detail = '') => { 392 + return (status, name, detail = "") => { 346 393 if (!enabled) { 347 394 return; 348 395 } 349 396 const timestamp = new Date().toISOString(); 350 - const suffix = detail ? ` ${detail}` : ''; 397 + const suffix = detail ? ` ${detail}` : ""; 351 398 write(`[${timestamp}] [${status}] ${name}${suffix}`); 352 399 }; 353 400 }; ··· 362 409 summary.finishedAt = new Date().toISOString(); 363 410 summary.unexpected = { 364 411 console: summary.console.filter((entry) => !isIgnoredConsole(entry)), 365 - requestFailures: summary.requestFailures.filter((entry) => !isIgnoredRequestFailure(entry)), 366 - httpFailures: summary.httpFailures.filter((entry) => !isIgnoredHttpFailure(entry)), 412 + requestFailures: summary.requestFailures.filter( 413 + (entry) => !isIgnoredRequestFailure(entry), 414 + ), 415 + httpFailures: summary.httpFailures.filter( 416 + (entry) => !isIgnoredHttpFailure(entry), 417 + ), 367 418 pageErrors: summary.pageErrors, 368 419 }; 369 420 summary.unexpected.total = ··· 371 422 summary.unexpected.requestFailures.length + 372 423 summary.unexpected.httpFailures.length + 373 424 summary.unexpected.pageErrors.length; 374 - if (!summary.fatal && strictErrors !== false && summary.unexpected.total > 0) { 425 + if ( 426 + !summary.fatal && 427 + strictErrors !== false && 428 + summary.unexpected.total > 0 429 + ) { 375 430 summary.fatal = `Unexpected browser/runtime errors: ${summary.unexpected.total}`; 376 431 } 377 432 summary.ok = !summary.fatal; 378 433 return summary; 379 434 }; 380 435 381 - export const closeBrowserSafely = async ({ browser, summary, timeoutMs = 15000 }) => { 436 + export const closeBrowserSafely = async ({ 437 + browser, 438 + summary, 439 + timeoutMs = 15000, 440 + }) => { 382 441 await Promise.race([ 383 442 browser.close(), 384 443 new Promise((_, reject) => { 385 - setTimeout(() => reject(new Error(`browser close timed out after ${timeoutMs}ms`)), timeoutMs); 444 + setTimeout( 445 + () => reject(new Error(`browser close timed out after ${timeoutMs}ms`)), 446 + timeoutMs, 447 + ); 386 448 }), 387 449 ]).catch((error) => { 388 450 summary.notes.push(String(error?.message ?? error));
+9 -7
src/browser/lib/settings.mjs
··· 1 1 export const createSettingsHelpers = ({ appBaseUrl, wait }) => { 2 2 const openSettingRoute = async (page, route) => { 3 3 await page.goto(`${appBaseUrl}${route}`, { 4 - waitUntil: 'domcontentloaded', 4 + waitUntil: "domcontentloaded", 5 5 timeout: 60000, 6 6 }); 7 7 await wait(page, 3000); 8 8 }; 9 9 10 - const roleSetting = (page, role, name) => page.getByRole(role, { name }).first(); 10 + const roleSetting = (page, role, name) => 11 + page.getByRole(role, { name }).first(); 11 12 12 13 const settingState = async (page, role, name) => { 13 14 const locator = roleSetting(page, role, name); 14 - await locator.waitFor({ state: 'visible', timeout: 15000 }); 15 - return (await locator.getAttribute('aria-checked')) === 'true'; 15 + await locator.waitFor({ state: "visible", timeout: 15000 }); 16 + return (await locator.getAttribute("aria-checked")) === "true"; 16 17 }; 17 18 18 19 const setPersistedSetting = async ({ ··· 43 44 return await setPersistedSetting({ 44 45 page, 45 46 route, 46 - role: 'checkbox', 47 + role: "checkbox", 47 48 name, 48 49 desired, 49 - verifyError: (verified) => `checkbox setting ${name} on ${route} expected ${desired} but saw ${verified}`, 50 + verifyError: (verified) => 51 + `checkbox setting ${name} on ${route} expected ${desired} but saw ${verified}`, 50 52 result: (verified) => ({ desired, verified }), 51 53 }); 52 54 }; ··· 55 57 return await setPersistedSetting({ 56 58 page, 57 59 route, 58 - role: 'radio', 60 + role: "radio", 59 61 name, 60 62 desired: true, 61 63 verifyError: () => `radio setting ${name} on ${route} did not persist`,
+3 -3
src/browser/lib/single-actions.mjs
··· 1 - import { createSingleAuthActions } from './single-actions/auth.mjs'; 2 - import { createSingleFeedActions } from './single-actions/feed.mjs'; 3 - import { createSingleProfileActions } from './single-actions/profile.mjs'; 1 + import { createSingleAuthActions } from "./single-actions/auth.mjs"; 2 + import { createSingleFeedActions } from "./single-actions/feed.mjs"; 3 + import { createSingleProfileActions } from "./single-actions/profile.mjs"; 4 4 5 5 export const createSingleActions = (options) => { 6 6 return {
+4 -4
src/browser/lib/single-actions/auth.mjs
··· 1 - import { loginToBlueskyApp } from '../runtime-utils.mjs'; 2 - import { createPageAuthActions } from '../page-auth-actions.mjs'; 1 + import { loginToBlueskyApp } from "../runtime-utils.mjs"; 2 + import { createPageAuthActions } from "../page-auth-actions.mjs"; 3 3 4 4 export const createSingleAuthActions = ({ 5 5 config, ··· 26 26 }); 27 27 }; 28 28 29 - const completeAgeAssuranceIfNeeded = async () => 29 + const completeAgeAssuranceIfNeeded = () => 30 30 actions.completeAgeAssuranceIfNeeded(page, { 31 31 birthdate: config.birthdate, 32 32 notes: summary.notes, 33 - noteText: 'Completed age-assurance birthdate gate', 33 + noteText: "Completed age-assurance birthdate gate", 34 34 }); 35 35 36 36 const gotoProfile = (handle) => actions.gotoProfile(page, handle);
+8 -5
src/browser/lib/single-actions/feed.mjs
··· 1 - import { dismissBlockingOverlays } from '../runtime-utils.mjs'; 2 - import { createPageFeedActions } from '../page-feed-actions.mjs'; 1 + import { dismissBlockingOverlays } from "../runtime-utils.mjs"; 2 + import { createPageFeedActions } from "../page-feed-actions.mjs"; 3 3 4 4 export const createSingleFeedActions = ({ 5 5 page, ··· 16 16 17 17 return { 18 18 composePost: (text) => actions.composePost(page, text), 19 - findRowByPrimaryText: (needle, timeout) => actions.findRowByPrimaryText(page, needle, timeout), 20 - findFirstFeedItem: (timeout) => actions.findFirstFeedItem(page, timeout || 60000), 19 + findRowByPrimaryText: (needle, timeout) => 20 + actions.findRowByPrimaryText(page, needle, timeout), 21 + findFirstFeedItem: (timeout) => 22 + actions.findFirstFeedItem(page, timeout || 60000), 21 23 clickQuote: (row, text) => actions.clickQuote(page, row, text), 22 24 clickReply: (row, text) => actions.clickReply(page, row, text), 23 25 ensureBookmarked: (row) => actions.ensureBookmarked(page, row), ··· 26 28 ensureNotLiked: (row) => actions.ensureNotLiked(page, row), 27 29 ensureReposted: (row) => actions.ensureReposted(page, row), 28 30 ensureNotReposted: (row) => actions.ensureNotReposted(page, row), 29 - maybeDeleteOwnPostByText: (text, successNote) => actions.maybeDeleteOwnPostByText(page, text, successNote), 31 + maybeDeleteOwnPostByText: (text, successNote) => 32 + actions.maybeDeleteOwnPostByText(page, text, successNote), 30 33 }; 31 34 };
+34 -23
src/browser/lib/single-actions/profile.mjs
··· 1 - import { 2 - dismissBlockingOverlays, 3 - } from '../runtime-utils.mjs'; 4 - import { createPageProfileEditActions } from '../page-profile-edit-actions.mjs'; 1 + import { dismissBlockingOverlays } from "../runtime-utils.mjs"; 2 + import { createPageProfileEditActions } from "../page-profile-edit-actions.mjs"; 5 3 6 4 export const createSingleProfileActions = ({ 7 5 config, ··· 23 21 24 22 const verifyPublicHandleResolution = async () => { 25 23 const result = await pollJson( 26 - 'public handle resolution', 27 - () => `${config.publicApiUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 28 - ({ ok, json }) => ok && typeof json?.did === 'string' && json.did.length > 0, 24 + "public handle resolution", 25 + () => 26 + `${config.publicApiUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 27 + ({ ok, json }) => 28 + ok && typeof json?.did === "string" && json.did.length > 0, 29 29 publicCheckTimeoutMs, 30 30 ); 31 31 return { did: result.json.did }; ··· 33 33 34 34 const verifyPublicAuthorFeed = async () => { 35 35 const result = await pollJson( 36 - 'public author feed indexing', 37 - () => `${config.publicApiUrl}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(config.handle)}&limit=20`, 36 + "public author feed indexing", 37 + () => 38 + `${config.publicApiUrl}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(config.handle)}&limit=20`, 38 39 ({ ok, json }) => 39 - ok && Array.isArray(json?.feed) && json.feed.some((item) => item?.post?.record?.text === config.postText), 40 + ok && 41 + Array.isArray(json?.feed) && 42 + json.feed.some((item) => item?.post?.record?.text === config.postText), 40 43 publicCheckTimeoutMs, 41 44 ); 42 - const matching = result.json.feed.find((item) => item?.post?.record?.text === config.postText); 45 + const matching = result.json.feed.find( 46 + (item) => item?.post?.record?.text === config.postText, 47 + ); 43 48 return { 44 49 uri: matching?.post?.uri, 45 50 cid: matching?.post?.cid, ··· 48 53 49 54 const verifyPublicProfile = async () => { 50 55 const result = await pollJson( 51 - 'public profile indexing', 52 - () => `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 53 - ({ ok, json }) => ok && typeof json?.postsCount === 'number' && json.postsCount > 0, 56 + "public profile indexing", 57 + () => 58 + `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 59 + ({ ok, json }) => 60 + ok && typeof json?.postsCount === "number" && json.postsCount > 0, 54 61 publicCheckTimeoutMs, 55 62 ); 56 63 return { ··· 64 71 65 72 const verifyPublicProfileAfterEdit = async () => { 66 73 const result = await pollJson( 67 - 'public profile edit indexing', 68 - () => `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 74 + "public profile edit indexing", 75 + () => 76 + `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 69 77 ({ ok, json }) => 70 78 ok && 71 79 json?.description === config.profileNote && 72 - typeof json?.avatar === 'string' && 80 + typeof json?.avatar === "string" && 73 81 json.avatar.length > 0, 74 82 publicCheckTimeoutMs, 75 83 ); ··· 86 94 87 95 const verifyLocalProfileAfterEdit = async () => { 88 96 const didResult = await pollJson( 89 - 'local handle resolution after profile edit', 90 - () => `${config.pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 91 - ({ ok, json }) => ok && typeof json?.did === 'string' && json.did.length > 0, 97 + "local handle resolution after profile edit", 98 + () => 99 + `${config.pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 100 + ({ ok, json }) => 101 + ok && typeof json?.did === "string" && json.did.length > 0, 92 102 30000, 93 103 ); 94 104 const did = didResult.json.did; 95 105 const result = await pollJson( 96 - 'local profile record after edit', 106 + "local profile record after edit", 97 107 () => 98 108 `${config.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, 99 109 ({ ok, json }) => 100 110 ok && 101 111 json?.value?.description === config.profileNote && 102 - typeof json?.value?.avatar?.ref?.$link === 'string' && 112 + typeof json?.value?.avatar?.ref?.$link === "string" && 103 113 json.value.avatar.ref.$link.length > 0, 104 114 30000, 105 115 ); ··· 110 120 }; 111 121 }; 112 122 113 - const editProfile = () => pageActions.editProfile(page, { profileNote: config.profileNote }); 123 + const editProfile = () => 124 + pageActions.editProfile(page, { profileNote: config.profileNote }); 114 125 115 126 return { 116 127 verifyPublicHandleResolution,
+1 -1
src/browser/lib/single-scenario.mjs
··· 3 3 runSingleCleanupPhase, 4 4 runSingleProfilePhase, 5 5 runSingleTargetInteractionPhase, 6 - } from './single-scenario/phases.mjs'; 6 + } from "./single-scenario/phases.mjs"; 7 7 8 8 export const runSingleScenario = async (ctx) => { 9 9 await runSingleBootstrapPhase(ctx);
+211 -124
src/browser/lib/single-scenario/phases.mjs
··· 19 19 ensureNotReposted, 20 20 } = ctx; 21 21 22 - await step('login', login); 23 - await step('age-assurance', completeAgeAssuranceIfNeeded, { optional: true }); 24 - await step('compose-own-post', () => composePost(config.postText)); 22 + await step("login", login); 23 + await step("age-assurance", completeAgeAssuranceIfNeeded, { optional: true }); 24 + await step("compose-own-post", () => composePost(config.postText)); 25 25 if (config.publicChecks !== false) { 26 - await step('public-resolve-handle', verifyPublicHandleResolution); 27 - await step('public-profile', verifyPublicProfile); 28 - await step('public-author-feed', verifyPublicAuthorFeed); 26 + await step("public-resolve-handle", verifyPublicHandleResolution); 27 + await step("public-profile", verifyPublicProfile); 28 + await step("public-author-feed", verifyPublicAuthorFeed); 29 29 } 30 - await step('own-profile', () => gotoProfile(config.handle)); 30 + await step("own-profile", () => gotoProfile(config.handle)); 31 31 32 - const ownPost = await step('find-own-post', async () => { 32 + const ownPost = await step("find-own-post", async () => { 33 33 await gotoProfile(config.handle); 34 - await page.getByTestId('postsFeed').first().waitFor({ state: 'visible', timeout: 60000 }); 34 + await page 35 + .getByTestId("postsFeed") 36 + .first() 37 + .waitFor({ state: "visible", timeout: 60000 }); 35 38 const row = await findRowByPrimaryText(config.postText, 60000); 36 - const rowTestId = await row.getAttribute('data-testid'); 37 - return { note: 'found own post', rowFound: true, rowTestId }; 39 + const rowTestId = await row.getAttribute("data-testid"); 40 + return { note: "found own post", rowFound: true, rowTestId }; 38 41 }); 39 42 40 43 if (!ownPost) { ··· 42 45 } 43 46 44 47 const row = await findRowByPrimaryText(config.postText); 45 - await step('like-own-post', () => ensureLiked(row), { optional: true }); 46 - await step('repost-own-post', () => ensureReposted(row), { optional: true }); 47 - await step('quote-own-post', () => clickQuote(row, config.quoteText), { optional: true }); 48 - await step('reply-own-post', async () => { 49 - await gotoProfile(config.handle); 50 - const refreshed = await findRowByPrimaryText(config.postText, 60000); 51 - await clickReply(refreshed, config.replyText); 52 - }, { optional: true }); 53 - await step('unlike-own-post', async () => { 54 - await gotoProfile(config.handle); 55 - const refreshed = await findRowByPrimaryText(config.postText, 60000); 56 - return ensureNotLiked(refreshed); 57 - }, { optional: true }); 58 - await step('undo-repost-own-post', async () => { 59 - await gotoProfile(config.handle); 60 - const refreshed = await findRowByPrimaryText(config.postText, 60000); 61 - return ensureNotReposted(refreshed); 62 - }, { optional: true }); 48 + await step("like-own-post", () => ensureLiked(row), { optional: true }); 49 + await step("repost-own-post", () => ensureReposted(row), { optional: true }); 50 + await step("quote-own-post", () => clickQuote(row, config.quoteText), { 51 + optional: true, 52 + }); 53 + await step( 54 + "reply-own-post", 55 + async () => { 56 + await gotoProfile(config.handle); 57 + const refreshed = await findRowByPrimaryText(config.postText, 60000); 58 + await clickReply(refreshed, config.replyText); 59 + }, 60 + { optional: true }, 61 + ); 62 + await step( 63 + "unlike-own-post", 64 + async () => { 65 + await gotoProfile(config.handle); 66 + const refreshed = await findRowByPrimaryText(config.postText, 60000); 67 + return ensureNotLiked(refreshed); 68 + }, 69 + { optional: true }, 70 + ); 71 + await step( 72 + "undo-repost-own-post", 73 + async () => { 74 + await gotoProfile(config.handle); 75 + const refreshed = await findRowByPrimaryText(config.postText, 60000); 76 + return ensureNotReposted(refreshed); 77 + }, 78 + { optional: true }, 79 + ); 63 80 }; 64 81 65 82 export const runSingleTargetInteractionPhase = async (ctx) => { ··· 83 100 openNotifications, 84 101 } = ctx; 85 102 86 - await step('target-profile', async () => { 103 + await step("target-profile", async () => { 87 104 await gotoProfile(config.targetHandle); 88 105 }); 89 - await step('follow-target', maybeFollowTarget, { optional: true }); 106 + await step("follow-target", maybeFollowTarget, { optional: true }); 90 107 91 - await step('inspect-target-post', async () => { 92 - const row = await findFirstFeedItem(20000); 93 - const preview = ((await row.textContent()) || '').replace(/\s+/g, ' ').slice(0, 160); 94 - return { note: preview }; 95 - }, { optional: true }); 108 + await step( 109 + "inspect-target-post", 110 + async () => { 111 + const row = await findFirstFeedItem(20000); 112 + const preview = ((await row.textContent()) || "") 113 + .replace(/\s+/g, " ") 114 + .slice(0, 160); 115 + return { note: preview }; 116 + }, 117 + { optional: true }, 118 + ); 96 119 97 - await step('bookmark-target-post', async () => { 98 - const row = await findFirstFeedItem(20000); 99 - return ensureBookmarked(row); 100 - }, { optional: true }); 120 + await step( 121 + "bookmark-target-post", 122 + async () => { 123 + const row = await findFirstFeedItem(20000); 124 + return ensureBookmarked(row); 125 + }, 126 + { optional: true }, 127 + ); 101 128 102 - await step('saved-posts-page', async () => { 103 - await openSavedPosts(); 104 - const handleText = page.getByText(`@${config.targetHandle.replace(/^@/, '')}`).first(); 105 - await handleText.waitFor({ state: 'visible', timeout: 20000 }); 106 - return { note: `saved post by ${config.targetHandle}` }; 107 - }, { optional: true }); 129 + await step( 130 + "saved-posts-page", 131 + async () => { 132 + await openSavedPosts(); 133 + const handleText = page 134 + .getByText(`@${config.targetHandle.replace(/^@/, "")}`) 135 + .first(); 136 + await handleText.waitFor({ state: "visible", timeout: 20000 }); 137 + return { note: `saved post by ${config.targetHandle}` }; 138 + }, 139 + { optional: true }, 140 + ); 108 141 109 - await step('like-target-post', async () => { 110 - await gotoProfile(config.targetHandle); 111 - const row = await findFirstFeedItem(20000); 112 - return ensureLiked(row); 113 - }, { optional: true }); 142 + await step( 143 + "like-target-post", 144 + async () => { 145 + await gotoProfile(config.targetHandle); 146 + const row = await findFirstFeedItem(20000); 147 + return ensureLiked(row); 148 + }, 149 + { optional: true }, 150 + ); 114 151 115 - await step('repost-target-post', async () => { 116 - await gotoProfile(config.targetHandle); 117 - const row = await findFirstFeedItem(20000); 118 - return ensureReposted(row); 119 - }, { optional: true }); 152 + await step( 153 + "repost-target-post", 154 + async () => { 155 + await gotoProfile(config.targetHandle); 156 + const row = await findFirstFeedItem(20000); 157 + return ensureReposted(row); 158 + }, 159 + { optional: true }, 160 + ); 120 161 121 - await step('quote-target-post', async () => { 122 - await gotoProfile(config.targetHandle); 123 - const row = await findFirstFeedItem(20000); 124 - await clickQuote(row, `${config.quoteText} to @${config.targetHandle.replace(/^@/, '')}`); 125 - return { note: 'quoted target post' }; 126 - }, { optional: true }); 162 + await step( 163 + "quote-target-post", 164 + async () => { 165 + await gotoProfile(config.targetHandle); 166 + const row = await findFirstFeedItem(20000); 167 + await clickQuote( 168 + row, 169 + `${config.quoteText} to @${config.targetHandle.replace(/^@/, "")}`, 170 + ); 171 + return { note: "quoted target post" }; 172 + }, 173 + { optional: true }, 174 + ); 127 175 128 - await step('reply-target-post', async () => { 129 - await gotoProfile(config.targetHandle); 130 - const row = await findFirstFeedItem(20000); 131 - await clickReply(row, `${config.replyText} to @${config.targetHandle.replace(/^@/, '')}`); 132 - return { note: 'replied to target post' }; 133 - }, { optional: true }); 176 + await step( 177 + "reply-target-post", 178 + async () => { 179 + await gotoProfile(config.targetHandle); 180 + const row = await findFirstFeedItem(20000); 181 + await clickReply( 182 + row, 183 + `${config.replyText} to @${config.targetHandle.replace(/^@/, "")}`, 184 + ); 185 + return { note: "replied to target post" }; 186 + }, 187 + { optional: true }, 188 + ); 134 189 135 - await step('unlike-target-post', async () => { 136 - await gotoProfile(config.targetHandle); 137 - const row = await findFirstFeedItem(20000); 138 - return ensureNotLiked(row); 139 - }, { optional: true }); 190 + await step( 191 + "unlike-target-post", 192 + async () => { 193 + await gotoProfile(config.targetHandle); 194 + const row = await findFirstFeedItem(20000); 195 + return ensureNotLiked(row); 196 + }, 197 + { optional: true }, 198 + ); 140 199 141 - await step('undo-repost-target-post', async () => { 142 - await gotoProfile(config.targetHandle); 143 - const row = await findFirstFeedItem(20000); 144 - return ensureNotReposted(row); 145 - }, { optional: true }); 200 + await step( 201 + "undo-repost-target-post", 202 + async () => { 203 + await gotoProfile(config.targetHandle); 204 + const row = await findFirstFeedItem(20000); 205 + return ensureNotReposted(row); 206 + }, 207 + { optional: true }, 208 + ); 146 209 147 - await step('unbookmark-target-post', async () => { 148 - await gotoProfile(config.targetHandle); 149 - const row = await findFirstFeedItem(20000); 150 - return ensureNotBookmarked(row); 151 - }, { optional: true }); 210 + await step( 211 + "unbookmark-target-post", 212 + async () => { 213 + await gotoProfile(config.targetHandle); 214 + const row = await findFirstFeedItem(20000); 215 + return ensureNotBookmarked(row); 216 + }, 217 + { optional: true }, 218 + ); 152 219 153 - await step('unfollow-target', async () => { 154 - await gotoProfile(config.targetHandle); 155 - return maybeUnfollowTarget(); 156 - }, { optional: true }); 220 + await step( 221 + "unfollow-target", 222 + async () => { 223 + await gotoProfile(config.targetHandle); 224 + return maybeUnfollowTarget(); 225 + }, 226 + { optional: true }, 227 + ); 157 228 158 - await step('refollow-target', async () => { 159 - await gotoProfile(config.targetHandle); 160 - return maybeFollowTarget(); 161 - }, { optional: true }); 229 + await step( 230 + "refollow-target", 231 + async () => { 232 + await gotoProfile(config.targetHandle); 233 + return maybeFollowTarget(); 234 + }, 235 + { optional: true }, 236 + ); 162 237 163 - await step('notifications-page', async () => { 164 - await openNotifications(); 165 - const tab = page.getByRole('tab', { name: /all|priority/i }).first(); 166 - if (await tab.count()) { 167 - await tab.waitFor({ state: 'visible', timeout: 15000 }); 168 - } 169 - return { note: 'notifications page loaded' }; 170 - }, { optional: true }); 238 + await step( 239 + "notifications-page", 240 + async () => { 241 + await openNotifications(); 242 + const tab = page.getByRole("tab", { name: /all|priority/i }).first(); 243 + if (await tab.count()) { 244 + await tab.waitFor({ state: "visible", timeout: 15000 }); 245 + } 246 + return { note: "notifications page loaded" }; 247 + }, 248 + { optional: true }, 249 + ); 171 250 }; 172 251 173 252 export const runSingleProfilePhase = async (ctx) => { ··· 184 263 return; 185 264 } 186 265 187 - await step('edit-profile', async () => { 266 + await step("edit-profile", async () => { 188 267 await gotoProfile(config.handle); 189 268 await editProfile(); 190 269 }); 191 - await step('local-profile-after-edit', verifyLocalProfileAfterEdit); 270 + await step("local-profile-after-edit", verifyLocalProfileAfterEdit); 192 271 if (config.publicChecks !== false) { 193 - await step('public-profile-after-edit', verifyPublicProfileAfterEdit); 272 + await step("public-profile-after-edit", verifyPublicProfileAfterEdit); 194 273 } 195 274 }; 196 275 ··· 203 282 maybeDeleteOwnPostByText, 204 283 } = ctx; 205 284 206 - await step('cleanup-own-posts-tab', async () => { 207 - await gotoProfile(config.handle); 208 - await openProfileTab('Posts'); 209 - return { note: 'opened own posts tab for cleanup' }; 210 - }, { optional: true }); 285 + await step( 286 + "cleanup-own-posts-tab", 287 + async () => { 288 + await gotoProfile(config.handle); 289 + await openProfileTab("Posts"); 290 + return { note: "opened own posts tab for cleanup" }; 291 + }, 292 + { optional: true }, 293 + ); 211 294 212 - await step('delete-own-target-quote', async () => { 295 + await step("delete-own-target-quote", () => { 213 296 return maybeDeleteOwnPostByText( 214 - `${config.quoteText} to @${config.targetHandle.replace(/^@/, '')}`, 215 - 'deleted target quote post', 297 + `${config.quoteText} to @${config.targetHandle.replace(/^@/, "")}`, 298 + "deleted target quote post", 216 299 ); 217 300 }); 218 301 219 - await step('delete-own-quote-post', async () => { 220 - return maybeDeleteOwnPostByText(config.quoteText, 'deleted own quote post'); 302 + await step("delete-own-quote-post", () => { 303 + return maybeDeleteOwnPostByText(config.quoteText, "deleted own quote post"); 221 304 }); 222 305 223 - await step('delete-own-root-post', async () => { 224 - return maybeDeleteOwnPostByText(config.postText, 'deleted root smoke post'); 306 + await step("delete-own-root-post", () => { 307 + return maybeDeleteOwnPostByText(config.postText, "deleted root smoke post"); 225 308 }); 226 309 227 - await step('cleanup-own-replies-tab', async () => { 228 - await gotoProfile(config.handle); 229 - await openProfileTab('Replies'); 230 - return { note: 'opened own replies tab for cleanup' }; 231 - }, { optional: true }); 310 + await step( 311 + "cleanup-own-replies-tab", 312 + async () => { 313 + await gotoProfile(config.handle); 314 + await openProfileTab("Replies"); 315 + return { note: "opened own replies tab for cleanup" }; 316 + }, 317 + { optional: true }, 318 + ); 232 319 233 - await step('delete-own-target-reply', async () => { 320 + await step("delete-own-target-reply", () => { 234 321 return maybeDeleteOwnPostByText( 235 - `${config.replyText} to @${config.targetHandle.replace(/^@/, '')}`, 236 - 'deleted target reply post', 322 + `${config.replyText} to @${config.targetHandle.replace(/^@/, "")}`, 323 + "deleted target reply post", 237 324 ); 238 325 }); 239 326 240 - await step('delete-own-reply-post', async () => { 241 - return maybeDeleteOwnPostByText(config.replyText, 'deleted own reply post'); 327 + await step("delete-own-reply-post", () => { 328 + return maybeDeleteOwnPostByText(config.replyText, "deleted own reply post"); 242 329 }); 243 330 };
+33 -21
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, finalizeDualSummary } 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 - import { AVATAR_PNG_BASE64, createBaseSummary, sleep } from './lib/runtime-utils.mjs'; 1 + import fs from "node:fs/promises"; 2 + import path from "node:path"; 3 + import { fileURLToPath } from "node:url"; 4 + import { 5 + setupDualBrowser, 6 + createDualStepHelpers, 7 + finalizeDualSummary, 8 + } from "./lib/dual-browser.mjs"; 9 + import { createDualApiHelpers } from "./lib/dual-api.mjs"; 10 + import { createListHelpers } from "./lib/lists.mjs"; 11 + import { createSettingsHelpers } from "./lib/settings.mjs"; 12 + import { runDualScenario } from "./lib/dual-scenario.mjs"; 13 + import { createDualActions } from "./lib/dual-actions.mjs"; 14 + import { 15 + AVATAR_PNG_BASE64, 16 + createBaseSummary, 17 + sleep, 18 + } from "./lib/runtime-utils.mjs"; 11 19 12 20 export const runDualFromConfig = async (config) => { 13 21 await fs.mkdir(config.artifactsDir, { recursive: true }); 14 - const appBaseUrl = config.appUrl.replace(/\/$/, ''); 22 + const appBaseUrl = config.appUrl.replace(/\/$/, ""); 15 23 16 24 const summary = createBaseSummary({ 17 25 appUrl: config.appUrl, ··· 29 37 summary.notes.push(`account source: ${config.accountSource}`); 30 38 } 31 39 32 - const { browser, primaryPage, secondaryPage } = await setupDualBrowser({ config, summary }); 40 + const { browser, primaryPage, secondaryPage } = await setupDualBrowser({ 41 + config, 42 + summary, 43 + }); 33 44 const { 34 45 screenshot, 35 46 normalizeText, ··· 63 74 addUserToCurrentList, 64 75 removeUserFromCurrentList, 65 76 } = createListHelpers({ appBaseUrl, wait }); 66 - const { 67 - setCheckboxSetting, 68 - setRadioSetting, 69 - } = createSettingsHelpers({ appBaseUrl, wait }); 77 + const { setCheckboxSetting, setRadioSetting } = createSettingsHelpers({ 78 + appBaseUrl, 79 + wait, 80 + }); 70 81 71 82 const { primary, secondary } = prepareAccounts({ 72 83 primaryConfig: config.primary, ··· 129 140 isIgnoredHttpFailure, 130 141 }); 131 142 await fs.writeFile( 132 - path.join(config.artifactsDir, 'summary.json'), 143 + path.join(config.artifactsDir, "summary.json"), 133 144 `${JSON.stringify(summary, null, 2)}\n`, 134 - 'utf8', 145 + "utf8", 135 146 ); 136 147 console.log(JSON.stringify(summary, null, 2)); 137 148 return summary; 138 149 }; 139 150 140 151 export const runDualFromConfigPath = async (configPath) => { 141 - const config = JSON.parse(await fs.readFile(configPath, 'utf8')); 152 + const config = JSON.parse(await fs.readFile(configPath, "utf8")); 142 153 return runDualFromConfig(config); 143 154 }; 144 155 145 156 export const runDualFromArgv = async (argv = process.argv) => { 146 157 const configPath = argv[2]; 147 158 if (!configPath) { 148 - console.error('usage: node run-dual.mjs <config.json>'); 159 + console.error("usage: node run-dual.mjs <config.json>"); 149 160 return 2; 150 161 } 151 162 const summary = await runDualFromConfigPath(configPath); ··· 153 164 }; 154 165 155 166 const isDirectExecution = 156 - !!process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); 167 + Boolean(process.argv[1]) && 168 + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); 157 169 158 170 if (isDirectExecution) { 159 171 const exitCode = await runDualFromArgv(process.argv);
+31 -22
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'; 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 5 import { 6 6 AVATAR_PNG_BASE64, 7 7 attachPageLogging, ··· 17 17 normalizeText, 18 18 pollJsonUntil, 19 19 sleep, 20 - } from './lib/runtime-utils.mjs'; 20 + } from "./lib/runtime-utils.mjs"; 21 21 import { 22 22 isIgnoredConsoleEntry, 23 23 isIgnoredHttpFailureEntry, 24 24 isIgnoredRequestFailureEntry, 25 - } from './lib/failure-rules.mjs'; 26 - import { runSingleScenario } from './lib/single-scenario.mjs'; 27 - import { createSingleActions } from './lib/single-actions.mjs'; 25 + } from "./lib/failure-rules.mjs"; 26 + import { runSingleScenario } from "./lib/single-scenario.mjs"; 27 + import { createSingleActions } from "./lib/single-actions.mjs"; 28 28 29 29 export const runSingleFromConfig = async (config) => { 30 30 await fs.mkdir(config.artifactsDir, { recursive: true }); 31 - const appBaseUrl = config.appUrl.replace(/\/$/, ''); 31 + const appBaseUrl = config.appUrl.replace(/\/$/, ""); 32 32 33 33 const summary = createBaseSummary({ 34 34 appUrl: config.appUrl, ··· 42 42 const progressEnabled = config.progress !== false; 43 43 const emitProgress = createProgressEmitter({ enabled: progressEnabled }); 44 44 45 - const browser = await launchBrowserWithFallback({ chromium, config, summary }); 45 + const browser = await launchBrowserWithFallback({ 46 + chromium, 47 + config, 48 + summary, 49 + }); 46 50 const context = await browser.newContext({ 47 51 viewport: { width: 1440, height: 1000 }, 48 52 }); 49 53 const page = await context.newPage(); 50 54 51 55 if (config.browserExecutablePath) { 52 - summary.notes.push(`requested browser executable: ${config.browserExecutablePath}`); 56 + summary.notes.push( 57 + `requested browser executable: ${config.browserExecutablePath}`, 58 + ); 53 59 } 54 60 55 61 attachPageLogging({ summary, page, xrpcLimit: 200 }); ··· 64 70 summary, 65 71 emitProgress, 66 72 captureArtifacts: async ({ name, failed }) => ({ 67 - screenshot: await screenshot(failed ? `${name}-error` : name).catch(() => undefined), 73 + screenshot: await screenshot(failed ? `${name}-error` : name).catch( 74 + () => undefined, 75 + ), 68 76 }), 69 77 }); 70 78 71 79 const wait = (ms) => page.waitForTimeout(ms); 72 - const fetchJson = async (url, options = {}) => 80 + const fetchJson = (url, options = {}) => 73 81 fetchJsonWithTimeout(url, { 74 - headers: { accept: 'application/json' }, 82 + headers: { accept: "application/json" }, 75 83 timeoutMs: options.timeoutMs ?? 30000, 76 84 }); 77 85 78 - const fetchStatus = async (url, options = {}) => 86 + const fetchStatus = (url, options = {}) => 79 87 fetchStatusWithTimeout(url, { 80 88 timeoutMs: options.timeoutMs ?? 30000, 81 89 }); 82 90 83 - const pollJson = async (name, buildUrl, predicate, timeoutMs) => 91 + const pollJson = (name, buildUrl, predicate, timeoutMs) => 84 92 pollJsonUntil({ 85 93 name, 86 94 buildUrl, ··· 121 129 isIgnoredRequestFailure: isIgnoredRequestFailureEntry, 122 130 isIgnoredHttpFailure: isIgnoredHttpFailureEntry, 123 131 }); 124 - await screenshot('final').catch(() => undefined); 132 + await screenshot("final").catch(() => undefined); 125 133 await fs.writeFile( 126 - path.join(config.artifactsDir, 'summary.json'), 134 + path.join(config.artifactsDir, "summary.json"), 127 135 `${JSON.stringify(summary, null, 2)}\n`, 128 - 'utf8', 136 + "utf8", 129 137 ); 130 138 console.log(JSON.stringify(summary, null, 2)); 131 139 await closeBrowserSafely({ browser, summary }); ··· 133 141 }; 134 142 135 143 export const runSingleFromConfigPath = async (configPath) => { 136 - const config = JSON.parse(await fs.readFile(configPath, 'utf8')); 144 + const config = JSON.parse(await fs.readFile(configPath, "utf8")); 137 145 return runSingleFromConfig(config); 138 146 }; 139 147 140 148 export const runSingleFromArgv = async (argv = process.argv) => { 141 149 const configPath = argv[2]; 142 150 if (!configPath) { 143 - console.error('usage: node run-single.mjs <config.json>'); 151 + console.error("usage: node run-single.mjs <config.json>"); 144 152 return 2; 145 153 } 146 154 const summary = await runSingleFromConfigPath(configPath); ··· 148 156 }; 149 157 150 158 const isDirectExecution = 151 - !!process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); 159 + Boolean(process.argv[1]) && 160 + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); 152 161 153 162 if (isDirectExecution) { 154 163 const exitCode = await runSingleFromArgv(process.argv);
+46 -36
src/cli.mjs
··· 1 - import fs from 'node:fs/promises'; 2 - import { ADAPTER_NAMES, getAdapter, listAdapters } from './adapters/registry.mjs'; 1 + import fs from "node:fs/promises"; 2 + import { 3 + ADAPTER_NAMES, 4 + getAdapter, 5 + listAdapters, 6 + } from "./adapters/registry.mjs"; 3 7 4 - const adapterUsage = ADAPTER_NAMES.join('|'); 8 + const adapterUsage = ADAPTER_NAMES.join("|"); 5 9 6 10 const usage = `Usage: 7 11 atproto-smoke run-single [--adapter ${adapterUsage}] --config config.json ··· 22 26 const parseArgs = (argv) => { 23 27 const result = { 24 28 command: argv[2], 25 - adapter: 'bring-your-own', 29 + adapter: "bring-your-own", 26 30 }; 27 31 28 32 for (let i = 3; i < argv.length; i += 1) { 29 33 const arg = argv[i]; 30 - if (arg === '--config') { 34 + if (arg === "--config") { 31 35 result.configPath = argv[++i]; 32 36 continue; 33 37 } 34 - if (arg === '--mode') { 38 + if (arg === "--mode") { 35 39 result.mode = argv[++i]; 36 40 continue; 37 41 } 38 - if (arg === '--adapter') { 42 + if (arg === "--adapter") { 39 43 result.adapter = argv[++i]; 40 44 continue; 41 45 } 42 - if (arg === '--output') { 46 + if (arg === "--output") { 43 47 result.outputPath = argv[++i]; 44 48 continue; 45 49 } 46 - if (arg === '--help' || arg === '-h' || arg === 'help') { 50 + if (arg === "--help" || arg === "-h" || arg === "help") { 47 51 result.help = true; 48 52 continue; 49 53 } 50 - if (arg === '--json-only') { 54 + if (arg === "--json-only") { 51 55 result.jsonOnly = true; 52 56 continue; 53 57 } ··· 58 62 }; 59 63 60 64 const normalizeMode = (command, mode) => { 61 - if (command === 'run-single') { 62 - return 'single'; 65 + if (command === "run-single") { 66 + return "single"; 63 67 } 64 - if (command === 'run-dual') { 65 - return 'dual'; 68 + if (command === "run-dual") { 69 + return "dual"; 66 70 } 67 71 return mode; 68 72 }; 69 73 70 74 const normalizeConfig = ({ mode, adapter, raw }) => { 71 75 const selectedAdapter = getAdapter(adapter); 72 - if (mode === 'single') { 76 + if (mode === "single") { 73 77 return selectedAdapter.createSingleConfig(raw); 74 78 } 75 - if (mode === 'dual') { 79 + if (mode === "dual") { 76 80 return selectedAdapter.createDualConfig(raw); 77 81 } 78 82 throw new Error(`unsupported mode: ${mode}`); 79 83 }; 80 84 81 85 const loadJsonConfig = async (configPath) => { 82 - const text = await fs.readFile(configPath, 'utf8'); 86 + const text = await fs.readFile(configPath, "utf8"); 83 87 return JSON.parse(text); 84 88 }; 85 89 86 90 const writeJsonConfig = async (outputPath, payload) => { 87 - await fs.writeFile(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); 91 + await fs.writeFile( 92 + outputPath, 93 + `${JSON.stringify(payload, null, 2)}\n`, 94 + "utf8", 95 + ); 88 96 }; 89 97 90 98 const adapterHelp = () => { ··· 97 105 for (const note of adapter.notes || []) { 98 106 lines.push(` note: ${note}`); 99 107 } 100 - return lines.join('\n'); 108 + return lines.join("\n"); 101 109 }) 102 - .join('\n'); 110 + .join("\n"); 103 111 }; 104 112 105 113 export const runCliFromArgv = async (argv = process.argv) => { ··· 108 116 if ( 109 117 args.help || 110 118 !args.command || 111 - args.command === 'help' || 112 - args.command === '--help' || 113 - args.command === '-h' 119 + args.command === "help" || 120 + args.command === "--help" || 121 + args.command === "-h" 114 122 ) { 115 123 console.log(`${usage}\nBuilt-in adapters:\n${adapterHelp()}`); 116 124 return 0; 117 125 } 118 126 119 - if (args.command === 'list-adapters') { 127 + if (args.command === "list-adapters") { 120 128 console.log(adapterHelp()); 121 129 return 0; 122 130 } ··· 124 132 const mode = normalizeMode(args.command, args.mode); 125 133 const adapter = getAdapter(args.adapter); 126 134 127 - if (args.command === 'print-example' || args.command === 'write-example') { 135 + if (args.command === "print-example" || args.command === "write-example") { 128 136 if (!mode) { 129 137 throw new Error(`${args.command} requires --mode single|dual`); 130 138 } 131 139 const example = adapter.createExampleConfig({ mode }); 132 - if (args.command === 'write-example') { 140 + if (args.command === "write-example") { 133 141 if (!args.outputPath) { 134 - throw new Error('write-example requires --output PATH'); 142 + throw new Error("write-example requires --output PATH"); 135 143 } 136 144 await writeJsonConfig(args.outputPath, example); 137 - console.log(`wrote ${args.outputPath} using adapter ${adapter.name} (${mode})`); 145 + console.log( 146 + `wrote ${args.outputPath} using adapter ${adapter.name} (${mode})`, 147 + ); 138 148 return 0; 139 149 } 140 150 console.log(JSON.stringify(example, null, 2)); ··· 142 152 } 143 153 144 154 if (!mode) { 145 - throw new Error('validate requires --mode single|dual'); 155 + throw new Error("validate requires --mode single|dual"); 146 156 } 147 157 if (!args.configPath) { 148 - throw new Error('--config is required'); 158 + throw new Error("--config is required"); 149 159 } 150 160 151 161 const raw = await loadJsonConfig(args.configPath); 152 162 const config = normalizeConfig({ mode, adapter: adapter.name, raw }); 153 - if (args.command === 'run-single' || args.command === 'run-dual') { 163 + if (args.command === "run-single" || args.command === "run-dual") { 154 164 config.progress = !args.jsonOnly; 155 165 } 156 166 157 - if (args.command === 'validate') { 167 + if (args.command === "validate") { 158 168 console.log(JSON.stringify(config, null, 2)); 159 169 return 0; 160 170 } 161 171 162 - if (args.command === 'run-single') { 163 - const { runSingleFromConfig } = await import('./browser/run-single.mjs'); 172 + if (args.command === "run-single") { 173 + const { runSingleFromConfig } = await import("./browser/run-single.mjs"); 164 174 const summary = await runSingleFromConfig(config); 165 175 return summary.ok ? 0 : 1; 166 176 } 167 177 168 - if (args.command === 'run-dual') { 169 - const { runDualFromConfig } = await import('./browser/run-dual.mjs'); 178 + if (args.command === "run-dual") { 179 + const { runDualFromConfig } = await import("./browser/run-dual.mjs"); 170 180 const summary = await runDualFromConfig(config); 171 181 return summary.ok ? 0 : 1; 172 182 }
+29 -23
src/config.mjs
··· 1 1 const DEFAULTS = { 2 - appUrl: 'https://bsky.app', 3 - publicApiUrl: 'https://public.api.bsky.app', 2 + appUrl: "https://bsky.app", 3 + publicApiUrl: "https://public.api.bsky.app", 4 4 publicCheckTimeoutMs: 180000, 5 5 stepTimeoutMs: 120000, 6 - birthdate: '1990-01-01', 6 + birthdate: "1990-01-01", 7 7 headless: true, 8 8 strictErrors: false, 9 9 publicChecks: true, 10 10 }; 11 11 12 12 const requireString = (value, label) => { 13 - if (typeof value !== 'string' || value.trim() === '') { 13 + if (typeof value !== "string" || value.trim() === "") { 14 14 throw new Error(`${label} is required`); 15 15 } 16 16 return value; ··· 20 20 if (value === undefined || value === null) { 21 21 return undefined; 22 22 } 23 - if (typeof value !== 'string') { 24 - throw new Error('optional string values must be strings when provided'); 23 + if (typeof value !== "string") { 24 + throw new Error("optional string values must be strings when provided"); 25 25 } 26 26 const trimmed = value.trim(); 27 - return trimmed === '' ? undefined : trimmed; 27 + return trimmed === "" ? undefined : trimmed; 28 28 }; 29 29 30 30 const optionalPostUrl = (value, label) => { ··· 52 52 return []; 53 53 } 54 54 if (!Array.isArray(prefixes)) { 55 - throw new Error('cleanupPostPrefixes must be an array when provided'); 55 + throw new Error("cleanupPostPrefixes must be an array when provided"); 56 56 } 57 57 return prefixes 58 58 .map((value) => { 59 - if (typeof value !== 'string') { 60 - throw new Error('cleanup post prefixes must be strings'); 59 + if (typeof value !== "string") { 60 + throw new Error("cleanup post prefixes must be strings"); 61 61 } 62 62 return value.length ? value : undefined; 63 63 }) ··· 87 87 ...rest 88 88 } = {}) => { 89 89 const normalized = { 90 - handle: requireString(handle, 'account.handle'), 91 - password: requireString(password, 'account.password'), 90 + handle: requireString(handle, "account.handle"), 91 + password: requireString(password, "account.password"), 92 92 birthdate: optionalString(birthdate) || DEFAULTS.birthdate, 93 93 cleanupPostPrefixes: normalizeCleanupPrefixes(cleanupPostPrefixes), 94 94 ...rest, ··· 142 142 ...rest 143 143 } = {}) => { 144 144 const normalized = { 145 - pdsUrl: requireString(pdsUrl, 'pdsUrl'), 146 - artifactsDir: requireString(artifactsDir, 'artifactsDir'), 145 + pdsUrl: requireString(pdsUrl, "pdsUrl"), 146 + artifactsDir: requireString(artifactsDir, "artifactsDir"), 147 147 appUrl: optionalString(appUrl) || DEFAULTS.appUrl, 148 148 publicApiUrl: optionalString(publicApiUrl) || DEFAULTS.publicApiUrl, 149 - publicCheckTimeoutMs: Number(publicCheckTimeoutMs || DEFAULTS.publicCheckTimeoutMs), 149 + publicCheckTimeoutMs: Number( 150 + publicCheckTimeoutMs || DEFAULTS.publicCheckTimeoutMs, 151 + ), 150 152 stepTimeoutMs: Number(stepTimeoutMs || DEFAULTS.stepTimeoutMs), 151 - headless: !!headless, 152 - strictErrors: !!strictErrors, 153 - publicChecks: !!publicChecks, 153 + headless: Boolean(headless), 154 + strictErrors: Boolean(strictErrors), 155 + publicChecks: Boolean(publicChecks), 154 156 ...rest, 155 157 }; 156 158 157 - normalized.pdsHost = optionalString(pdsHost) || derivePdsHost(normalized.pdsUrl); 159 + normalized.pdsHost = 160 + optionalString(pdsHost) || derivePdsHost(normalized.pdsUrl); 158 161 if (!normalized.pdsHost) { 159 - throw new Error('pdsHost could not be derived from pdsUrl'); 162 + throw new Error("pdsHost could not be derived from pdsUrl"); 160 163 } 161 164 162 165 const maybeTarget = optionalString(targetHandle); ··· 164 167 normalized.targetHandle = maybeTarget; 165 168 } 166 169 167 - const maybeRemoteReplyPostUrl = optionalPostUrl(remoteReplyPostUrl, 'remoteReplyPostUrl'); 170 + const maybeRemoteReplyPostUrl = optionalPostUrl( 171 + remoteReplyPostUrl, 172 + "remoteReplyPostUrl", 173 + ); 168 174 if (maybeRemoteReplyPostUrl) { 169 175 normalized.remoteReplyPostUrl = maybeRemoteReplyPostUrl; 170 176 } ··· 189 195 } = {}) => { 190 196 const suite = createSuiteConfig(rest); 191 197 if (!suite.targetHandle) { 192 - throw new Error('targetHandle is required for single-mode runs'); 198 + throw new Error("targetHandle is required for single-mode runs"); 193 199 } 194 200 return { 195 201 ...suite, 196 202 ...createAccountConfig(account), 197 - editProfile: !!editProfile, 203 + editProfile: Boolean(editProfile), 198 204 }; 199 205 }; 200 206
+9 -9
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 './adapters/tranquil-pds.mjs'; 5 - export * from './adapters/registry.mjs'; 6 - export * from './lab/pdslab-targets.mjs'; 7 - export * from './browser/run-single.mjs'; 8 - export * from './browser/run-dual.mjs'; 9 - export * from './cli.mjs'; 1 + export * from "./config.mjs"; 2 + export * from "./adapters/bring-your-own.mjs"; 3 + export * from "./adapters/perlsky.mjs"; 4 + export * from "./adapters/tranquil-pds.mjs"; 5 + export * from "./adapters/registry.mjs"; 6 + export * from "./lab/pdslab-targets.mjs"; 7 + export * from "./browser/run-single.mjs"; 8 + export * from "./browser/run-dual.mjs"; 9 + export * from "./cli.mjs";
+207 -233
src/lab/pdslab-targets.mjs
··· 2 2 3 3 export const PDSLAB_TARGETS = Object.freeze([ 4 4 createTarget({ 5 - id: 'perlsky', 6 - mode: 'dual', 7 - adapter: 'perlsky', 8 - ledgerTarget: 'perlsky', 9 - runnerStatus: 'ready', 10 - accountSource: 'pdslab-dual-smoke-pair', 11 - notes: [ 12 - 'Canonical same-PDS dual-account smoke target.', 13 - ], 5 + id: "perlsky", 6 + mode: "dual", 7 + adapter: "perlsky", 8 + ledgerTarget: "perlsky", 9 + runnerStatus: "ready", 10 + accountSource: "pdslab-dual-smoke-pair", 11 + notes: ["Canonical same-PDS dual-account smoke target."], 14 12 }), 15 13 createTarget({ 16 - id: 'tranquil', 17 - mode: 'dual', 18 - adapter: 'tranquil-pds', 19 - ledgerTarget: 'tranquil', 20 - runnerStatus: 'ready', 21 - accountSource: 'pdslab-dual-smoke-pair', 22 - notes: [ 23 - 'Canonical same-PDS dual-account smoke target.', 24 - ], 14 + id: "tranquil", 15 + mode: "dual", 16 + adapter: "tranquil-pds", 17 + ledgerTarget: "tranquil", 18 + runnerStatus: "ready", 19 + accountSource: "pdslab-dual-smoke-pair", 20 + notes: ["Canonical same-PDS dual-account smoke target."], 25 21 }), 26 22 createTarget({ 27 - id: 'cocoon', 28 - mode: 'dual', 29 - adapter: 'bring-your-own', 30 - ledgerTarget: 'cocoon', 31 - runnerStatus: 'ready', 32 - accountSource: 'pdslab-dual-smoke-pair', 33 - notes: [ 34 - 'Canonical same-PDS dual-account smoke target.', 35 - ], 23 + id: "cocoon", 24 + mode: "dual", 25 + adapter: "bring-your-own", 26 + ledgerTarget: "cocoon", 27 + runnerStatus: "ready", 28 + accountSource: "pdslab-dual-smoke-pair", 29 + notes: ["Canonical same-PDS dual-account smoke target."], 36 30 }), 37 31 createTarget({ 38 - id: 'bluesky-pds', 39 - mode: 'dual', 40 - adapter: 'bring-your-own', 41 - ledgerTarget: 'bluesky-pds', 42 - runnerStatus: 'ready', 43 - accountSource: 'pdslab-dual-smoke-pair', 44 - notes: [ 45 - 'Canonical same-PDS dual-account smoke target.', 46 - ], 32 + id: "bluesky-pds", 33 + mode: "dual", 34 + adapter: "bring-your-own", 35 + ledgerTarget: "bluesky-pds", 36 + runnerStatus: "ready", 37 + accountSource: "pdslab-dual-smoke-pair", 38 + notes: ["Canonical same-PDS dual-account smoke target."], 47 39 }), 48 40 createTarget({ 49 - id: 'millipds', 50 - mode: 'dual', 51 - adapter: 'bring-your-own', 52 - ledgerTarget: 'millipds', 53 - runnerStatus: 'ready', 54 - accountSource: 'pdslab-dual-smoke-pair', 55 - notes: [ 56 - 'Canonical same-PDS dual-account smoke target.', 57 - ], 41 + id: "millipds", 42 + mode: "dual", 43 + adapter: "bring-your-own", 44 + ledgerTarget: "millipds", 45 + runnerStatus: "ready", 46 + accountSource: "pdslab-dual-smoke-pair", 47 + notes: ["Canonical same-PDS dual-account smoke target."], 58 48 }), 59 49 createTarget({ 60 - id: 'pegasus', 61 - mode: 'dual', 62 - adapter: 'bring-your-own', 63 - ledgerTarget: 'pegasus', 64 - runnerStatus: 'ready', 65 - accountSource: 'pdslab-dual-smoke-pair', 66 - notes: [ 67 - 'Canonical same-PDS dual-account smoke target.', 68 - ], 50 + id: "pegasus", 51 + mode: "dual", 52 + adapter: "bring-your-own", 53 + ledgerTarget: "pegasus", 54 + runnerStatus: "ready", 55 + accountSource: "pdslab-dual-smoke-pair", 56 + notes: ["Canonical same-PDS dual-account smoke target."], 69 57 }), 70 58 createTarget({ 71 - id: 'rsky', 72 - mode: 'dual', 73 - adapter: 'bring-your-own', 74 - ledgerTarget: 'rsky', 75 - runnerStatus: 'ready', 76 - accountSource: 'pdslab-dual-smoke-pair', 77 - notes: [ 78 - 'Canonical same-PDS dual-account smoke target.', 79 - ], 59 + id: "rsky", 60 + mode: "dual", 61 + adapter: "bring-your-own", 62 + ledgerTarget: "rsky", 63 + runnerStatus: "ready", 64 + accountSource: "pdslab-dual-smoke-pair", 65 + notes: ["Canonical same-PDS dual-account smoke target."], 80 66 }), 81 67 createTarget({ 82 - id: 'pdsjs', 83 - mode: 'single', 84 - adapter: 'bring-your-own', 85 - ledgerTarget: 'pdsjs', 86 - ledgerAccount: 'smoke-a', 87 - pairGroup: 'pdsjs', 88 - runnerStatus: 'ready', 89 - accountSource: 'pdslab-paired-single-a', 68 + id: "pdsjs", 69 + mode: "single", 70 + adapter: "bring-your-own", 71 + ledgerTarget: "pdsjs", 72 + ledgerAccount: "smoke-a", 73 + pairGroup: "pdsjs", 74 + runnerStatus: "ready", 75 + accountSource: "pdslab-paired-single-a", 90 76 notes: [ 91 - 'Base endpoint for the pdsjs single-user pair.', 92 - 'setup.js still ends with a /register-handle 404, but login works.', 77 + "Base endpoint for the pdsjs single-user pair.", 78 + "setup.js still ends with a /register-handle 404, but login works.", 93 79 ], 94 80 }), 95 81 createTarget({ 96 - id: 'pdsjs2', 97 - mode: 'single', 98 - adapter: 'bring-your-own', 99 - ledgerTarget: 'pdsjs2', 100 - ledgerAccount: 'smoke-b', 101 - pairGroup: 'pdsjs', 102 - runnerStatus: 'ready', 103 - accountSource: 'pdslab-paired-single-b', 82 + id: "pdsjs2", 83 + mode: "single", 84 + adapter: "bring-your-own", 85 + ledgerTarget: "pdsjs2", 86 + ledgerAccount: "smoke-b", 87 + pairGroup: "pdsjs", 88 + runnerStatus: "ready", 89 + accountSource: "pdslab-paired-single-b", 104 90 notes: [ 105 - 'Companion endpoint for the pdsjs single-user pair.', 106 - 'setup.js still ends with a /register-handle 404, but login works.', 91 + "Companion endpoint for the pdsjs single-user pair.", 92 + "setup.js still ends with a /register-handle 404, but login works.", 107 93 ], 108 94 }), 109 95 createTarget({ 110 - id: 'pdsjs-pair', 111 - mode: 'dual', 112 - adapter: 'bring-your-own', 113 - primaryLedgerTarget: 'pdsjs', 114 - secondaryLedgerTarget: 'pdsjs2', 115 - primaryLedgerAccount: 'smoke-a', 116 - secondaryLedgerAccount: 'smoke-b', 117 - runnerStatus: 'ready', 118 - accountSource: 'pdslab-cross-pds-single-user-pair', 119 - notes: [ 120 - 'Cross-PDS dual run using the pdsjs single-user pair.', 121 - ], 96 + id: "pdsjs-pair", 97 + mode: "dual", 98 + adapter: "bring-your-own", 99 + primaryLedgerTarget: "pdsjs", 100 + secondaryLedgerTarget: "pdsjs2", 101 + primaryLedgerAccount: "smoke-a", 102 + secondaryLedgerAccount: "smoke-b", 103 + runnerStatus: "ready", 104 + accountSource: "pdslab-cross-pds-single-user-pair", 105 + notes: ["Cross-PDS dual run using the pdsjs single-user pair."], 122 106 }), 123 107 createTarget({ 124 - id: 'micropod', 125 - mode: 'single', 126 - adapter: 'bring-your-own', 127 - ledgerTarget: 'micropod', 128 - ledgerAccount: 'smoke-a', 129 - pairGroup: 'micropod', 130 - runnerStatus: 'ready', 131 - accountSource: 'pdslab-paired-single-a', 132 - notes: [ 133 - 'Base endpoint for the Micropod single-user pair.', 134 - ], 108 + id: "micropod", 109 + mode: "single", 110 + adapter: "bring-your-own", 111 + ledgerTarget: "micropod", 112 + ledgerAccount: "smoke-a", 113 + pairGroup: "micropod", 114 + runnerStatus: "ready", 115 + accountSource: "pdslab-paired-single-a", 116 + notes: ["Base endpoint for the Micropod single-user pair."], 135 117 }), 136 118 createTarget({ 137 - id: 'micropod2', 138 - mode: 'single', 139 - adapter: 'bring-your-own', 140 - ledgerTarget: 'micropod2', 141 - ledgerAccount: 'smoke-b', 142 - pairGroup: 'micropod', 143 - runnerStatus: 'ready', 144 - accountSource: 'pdslab-paired-single-b', 145 - notes: [ 146 - 'Companion endpoint for the Micropod single-user pair.', 147 - ], 119 + id: "micropod2", 120 + mode: "single", 121 + adapter: "bring-your-own", 122 + ledgerTarget: "micropod2", 123 + ledgerAccount: "smoke-b", 124 + pairGroup: "micropod", 125 + runnerStatus: "ready", 126 + accountSource: "pdslab-paired-single-b", 127 + notes: ["Companion endpoint for the Micropod single-user pair."], 148 128 }), 149 129 createTarget({ 150 - id: 'micropod-pair', 151 - mode: 'dual', 152 - adapter: 'bring-your-own', 153 - primaryLedgerTarget: 'micropod', 154 - secondaryLedgerTarget: 'micropod2', 155 - primaryLedgerAccount: 'smoke-a', 156 - secondaryLedgerAccount: 'smoke-b', 157 - runnerStatus: 'ready', 158 - accountSource: 'pdslab-cross-pds-single-user-pair', 159 - notes: [ 160 - 'Cross-PDS dual run using the Micropod single-user pair.', 161 - ], 130 + id: "micropod-pair", 131 + mode: "dual", 132 + adapter: "bring-your-own", 133 + primaryLedgerTarget: "micropod", 134 + secondaryLedgerTarget: "micropod2", 135 + primaryLedgerAccount: "smoke-a", 136 + secondaryLedgerAccount: "smoke-b", 137 + runnerStatus: "ready", 138 + accountSource: "pdslab-cross-pds-single-user-pair", 139 + notes: ["Cross-PDS dual run using the Micropod single-user pair."], 162 140 }), 163 141 createTarget({ 164 - id: 'rustproto', 165 - mode: 'single', 166 - adapter: 'bring-your-own', 167 - ledgerTarget: 'rustproto', 168 - ledgerAccount: 'smoke-a', 169 - pairGroup: 'rustproto', 170 - runnerStatus: 'ready', 171 - accountSource: 'pdslab-paired-single-a', 142 + id: "rustproto", 143 + mode: "single", 144 + adapter: "bring-your-own", 145 + ledgerTarget: "rustproto", 146 + ledgerAccount: "smoke-a", 147 + pairGroup: "rustproto", 148 + runnerStatus: "ready", 149 + accountSource: "pdslab-paired-single-a", 172 150 notes: [ 173 - 'Base endpoint for the Rustproto single-user pair.', 174 - 'Initial repo/profile state has already been installed.', 151 + "Base endpoint for the Rustproto single-user pair.", 152 + "Initial repo/profile state has already been installed.", 175 153 ], 176 154 }), 177 155 createTarget({ 178 - id: 'rustproto2', 179 - mode: 'single', 180 - adapter: 'bring-your-own', 181 - ledgerTarget: 'rustproto2', 182 - ledgerAccount: 'smoke-b', 183 - pairGroup: 'rustproto', 184 - runnerStatus: 'ready', 185 - accountSource: 'pdslab-paired-single-b', 156 + id: "rustproto2", 157 + mode: "single", 158 + adapter: "bring-your-own", 159 + ledgerTarget: "rustproto2", 160 + ledgerAccount: "smoke-b", 161 + pairGroup: "rustproto", 162 + runnerStatus: "ready", 163 + accountSource: "pdslab-paired-single-b", 186 164 notes: [ 187 - 'Companion endpoint for the Rustproto single-user pair.', 188 - 'Initial repo/profile state has already been installed.', 165 + "Companion endpoint for the Rustproto single-user pair.", 166 + "Initial repo/profile state has already been installed.", 189 167 ], 190 168 }), 191 169 createTarget({ 192 - id: 'rustproto-pair', 193 - mode: 'dual', 194 - adapter: 'bring-your-own', 195 - primaryLedgerTarget: 'rustproto', 196 - secondaryLedgerTarget: 'rustproto2', 197 - primaryLedgerAccount: 'smoke-a', 198 - secondaryLedgerAccount: 'smoke-b', 199 - runnerStatus: 'ready', 200 - accountSource: 'pdslab-cross-pds-single-user-pair', 201 - notes: [ 202 - 'Cross-PDS dual run using the Rustproto single-user pair.', 203 - ], 170 + id: "rustproto-pair", 171 + mode: "dual", 172 + adapter: "bring-your-own", 173 + primaryLedgerTarget: "rustproto", 174 + secondaryLedgerTarget: "rustproto2", 175 + primaryLedgerAccount: "smoke-a", 176 + secondaryLedgerAccount: "smoke-b", 177 + runnerStatus: "ready", 178 + accountSource: "pdslab-cross-pds-single-user-pair", 179 + notes: ["Cross-PDS dual run using the Rustproto single-user pair."], 204 180 }), 205 181 createTarget({ 206 - id: 'dnproto', 207 - mode: 'single', 208 - adapter: 'bring-your-own', 209 - ledgerTarget: 'dnproto', 210 - ledgerAccount: 'smoke-a', 211 - pairGroup: 'dnproto', 212 - runnerStatus: 'needs-login-identifier-support', 213 - loginIdentifierKey: 'did', 214 - accountSource: 'pdslab-paired-single-a', 182 + id: "dnproto", 183 + mode: "single", 184 + adapter: "bring-your-own", 185 + ledgerTarget: "dnproto", 186 + ledgerAccount: "smoke-a", 187 + pairGroup: "dnproto", 188 + runnerStatus: "needs-login-identifier-support", 189 + loginIdentifierKey: "did", 190 + accountSource: "pdslab-paired-single-a", 215 191 notes: [ 216 - 'Base endpoint for the Dnproto single-user pair.', 217 - 'Handle-based createSession returns null JWTs on this build; use the DID as the login identifier.', 192 + "Base endpoint for the Dnproto single-user pair.", 193 + "Handle-based createSession returns null JWTs on this build; use the DID as the login identifier.", 218 194 ], 219 195 }), 220 196 createTarget({ 221 - id: 'dnproto2', 222 - mode: 'single', 223 - adapter: 'bring-your-own', 224 - ledgerTarget: 'dnproto2', 225 - ledgerAccount: 'smoke-b', 226 - pairGroup: 'dnproto', 227 - runnerStatus: 'needs-login-identifier-support', 228 - loginIdentifierKey: 'did', 229 - accountSource: 'pdslab-paired-single-b', 197 + id: "dnproto2", 198 + mode: "single", 199 + adapter: "bring-your-own", 200 + ledgerTarget: "dnproto2", 201 + ledgerAccount: "smoke-b", 202 + pairGroup: "dnproto", 203 + runnerStatus: "needs-login-identifier-support", 204 + loginIdentifierKey: "did", 205 + accountSource: "pdslab-paired-single-b", 230 206 notes: [ 231 - 'Companion endpoint for the Dnproto single-user pair.', 232 - 'Handle-based createSession returns null JWTs on this build; use the DID as the login identifier.', 207 + "Companion endpoint for the Dnproto single-user pair.", 208 + "Handle-based createSession returns null JWTs on this build; use the DID as the login identifier.", 233 209 ], 234 210 }), 235 211 createTarget({ 236 - id: 'dnproto-pair', 237 - mode: 'dual', 238 - adapter: 'bring-your-own', 239 - primaryLedgerTarget: 'dnproto', 240 - secondaryLedgerTarget: 'dnproto2', 241 - primaryLedgerAccount: 'smoke-a', 242 - secondaryLedgerAccount: 'smoke-b', 243 - primaryLoginIdentifierKey: 'did', 244 - secondaryLoginIdentifierKey: 'did', 245 - runnerStatus: 'ready', 246 - accountSource: 'pdslab-cross-pds-single-user-pair', 212 + id: "dnproto-pair", 213 + mode: "dual", 214 + adapter: "bring-your-own", 215 + primaryLedgerTarget: "dnproto", 216 + secondaryLedgerTarget: "dnproto2", 217 + primaryLedgerAccount: "smoke-a", 218 + secondaryLedgerAccount: "smoke-b", 219 + primaryLoginIdentifierKey: "did", 220 + secondaryLoginIdentifierKey: "did", 221 + runnerStatus: "ready", 222 + accountSource: "pdslab-cross-pds-single-user-pair", 247 223 notes: [ 248 - 'Cross-PDS dual run using the Dnproto single-user pair.', 249 - 'Both sides should log in by DID instead of handle.', 224 + "Cross-PDS dual run using the Dnproto single-user pair.", 225 + "Both sides should log in by DID instead of handle.", 250 226 ], 251 227 }), 252 228 createTarget({ 253 - id: 'cirrus-a', 254 - mode: 'single', 255 - adapter: 'bring-your-own', 256 - ledgerTarget: 'cirrus-a', 257 - currentDeploymentKey: 'currentDeployment', 258 - runnerStatus: 'ready', 259 - accountSource: 'pdslab-single-cirrus-side-a', 229 + id: "cirrus-a", 230 + mode: "single", 231 + adapter: "bring-your-own", 232 + ledgerTarget: "cirrus-a", 233 + currentDeploymentKey: "currentDeployment", 234 + runnerStatus: "ready", 235 + accountSource: "pdslab-single-cirrus-side-a", 260 236 notes: [ 261 - 'Single-user Cirrus deployment.', 262 - 'This is the A-side endpoint; a future dual-PDS flow can pair it with cirrus-b.', 237 + "Single-user Cirrus deployment.", 238 + "This is the A-side endpoint; a future dual-PDS flow can pair it with cirrus-b.", 263 239 ], 264 240 }), 265 241 createTarget({ 266 - id: 'cirrus-b', 267 - mode: 'single', 268 - adapter: 'bring-your-own', 269 - ledgerTarget: 'cirrus-b', 270 - currentDeploymentKey: 'currentDeployment', 271 - runnerStatus: 'ready', 272 - accountSource: 'pdslab-single-cirrus-side-b', 242 + id: "cirrus-b", 243 + mode: "single", 244 + adapter: "bring-your-own", 245 + ledgerTarget: "cirrus-b", 246 + currentDeploymentKey: "currentDeployment", 247 + runnerStatus: "ready", 248 + accountSource: "pdslab-single-cirrus-side-b", 273 249 notes: [ 274 - 'Single-user Cirrus deployment.', 275 - 'This is the B-side endpoint; a future dual-PDS flow can pair it with cirrus-a.', 250 + "Single-user Cirrus deployment.", 251 + "This is the B-side endpoint; a future dual-PDS flow can pair it with cirrus-a.", 276 252 ], 277 253 }), 278 254 createTarget({ 279 - id: 'cirrus-pair', 280 - mode: 'dual', 281 - adapter: 'bring-your-own', 282 - primaryLedgerTarget: 'cirrus-a', 283 - secondaryLedgerTarget: 'cirrus-b', 284 - primaryCurrentDeploymentKey: 'currentDeployment', 285 - secondaryCurrentDeploymentKey: 'currentDeployment', 286 - runnerStatus: 'ready', 287 - accountSource: 'pdslab-cross-pds-single-user-pair', 288 - notes: [ 289 - 'Cross-PDS dual run using the two single-user Cirrus deployments.', 290 - ], 255 + id: "cirrus-pair", 256 + mode: "dual", 257 + adapter: "bring-your-own", 258 + primaryLedgerTarget: "cirrus-a", 259 + secondaryLedgerTarget: "cirrus-b", 260 + primaryCurrentDeploymentKey: "currentDeployment", 261 + secondaryCurrentDeploymentKey: "currentDeployment", 262 + runnerStatus: "ready", 263 + accountSource: "pdslab-cross-pds-single-user-pair", 264 + notes: ["Cross-PDS dual run using the two single-user Cirrus deployments."], 291 265 }), 292 266 createTarget({ 293 - id: 'vow', 294 - mode: 'dual', 295 - adapter: 'bring-your-own', 296 - ledgerTarget: 'vow', 297 - runnerStatus: 'blocked', 267 + id: "vow", 268 + mode: "dual", 269 + adapter: "bring-your-own", 270 + ledgerTarget: "vow", 271 + runnerStatus: "blocked", 298 272 notes: [ 299 - 'Blocked in the deployed build.', 300 - 'Account creation currently fails in the IPFS-backed block write path.', 273 + "Blocked in the deployed build.", 274 + "Account creation currently fails in the IPFS-backed block write path.", 301 275 ], 302 276 }), 303 277 ]);
+21
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "NodeNext", 5 + "moduleResolution": "NodeNext", 6 + "allowJs": true, 7 + "checkJs": false, 8 + "skipLibCheck": true, 9 + "noEmit": true, 10 + "resolveJsonModule": true, 11 + "types": ["node"] 12 + }, 13 + "include": [ 14 + "bin/**/*.mjs", 15 + "scripts/**/*.mjs", 16 + "src/**/*.mjs", 17 + "eslint.config.js", 18 + "prettier.config.mjs" 19 + ], 20 + "exclude": ["node_modules", "data", ".tmp"] 21 + }