this repo has no description
32
fork

Configure Feed

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

Enforce strict browser typing without lint overrides

alice 6669808c 58b0ae09

+1980 -756
-42
eslint.config.js
··· 67 67 }, 68 68 }, 69 69 { 70 - files: ["**/__tests__/**/*.ts", "**/*.test.ts", "**/*.spec.ts"], 71 - rules: { 72 - "@typescript-eslint/no-floating-promises": "off", 73 - "@typescript-eslint/no-non-null-assertion": "off", 74 - "@typescript-eslint/no-unnecessary-condition": "off", 75 - "@typescript-eslint/no-deprecated": "off", 76 - "@typescript-eslint/restrict-template-expressions": "off", 77 - "@typescript-eslint/naming-convention": "off", 78 - "no-case-declarations": "off", 79 - }, 80 - }, 81 - { 82 - files: [ 83 - "scripts/write-pdslab-configs.ts", 84 - "src/browser/lib/**/*.ts", 85 - "src/browser/run-single.ts", 86 - "src/browser/run-dual.ts", 87 - ], 88 - rules: { 89 - "@typescript-eslint/no-unsafe-assignment": "off", 90 - "@typescript-eslint/no-unsafe-call": "off", 91 - "@typescript-eslint/no-unsafe-member-access": "off", 92 - "@typescript-eslint/no-unsafe-return": "off", 93 - "@typescript-eslint/no-unsafe-argument": "off", 94 - "@typescript-eslint/strict-boolean-expressions": "off", 95 - "@typescript-eslint/explicit-function-return-type": "off", 96 - "@typescript-eslint/explicit-module-boundary-types": "off", 97 - "@typescript-eslint/restrict-template-expressions": "off", 98 - "@typescript-eslint/no-unnecessary-condition": "off", 99 - "@typescript-eslint/use-unknown-in-catch-callback-variable": "off", 100 - "@typescript-eslint/prefer-nullish-coalescing": "off", 101 - "@typescript-eslint/prefer-regexp-exec": "off", 102 - "@typescript-eslint/prefer-optional-chain": "off", 103 - "@typescript-eslint/no-base-to-string": "off", 104 - "@typescript-eslint/no-unnecessary-type-conversion": "off", 105 - "@typescript-eslint/no-confusing-void-expression": "off", 106 - "@typescript-eslint/restrict-plus-operands": "off", 107 - "@typescript-eslint/no-unnecessary-boolean-literal-compare": "off", 108 - "@typescript-eslint/dot-notation": "off", 109 - }, 110 - }, 111 - { 112 70 ignores: [ 113 71 "**/dist/**", 114 72 "**/data/**",
+159 -112
scripts/write-pdslab-configs.ts
··· 3 3 import fs from "node:fs/promises"; 4 4 import path from "node:path"; 5 5 import { fileURLToPath } from "node:url"; 6 + import { errorMessage } from "../src/browser/lib/runtime-utils.js"; 7 + import { 8 + getRecord, 9 + getString, 10 + getUnknown, 11 + parseJsonRecord, 12 + } from "../src/guards.js"; 6 13 import { PDSLAB_TARGETS } from "../src/lab/pdslab-targets.js"; 7 14 import type { FlexibleRecord } from "../src/types.js"; 8 - import { errorMessage } from "../src/browser/lib/runtime-utils.js"; 15 + 16 + type PdslabTargetSpec = (typeof PDSLAB_TARGETS)[number]; 9 17 10 18 interface PlanTarget { 11 19 id: string; ··· 27 35 runnableTargets: PlanTarget[]; 28 36 skippedTargets: SkippedTarget[]; 29 37 } 30 - 31 - const asRecord = (value: unknown): FlexibleRecord | undefined => { 32 - if (typeof value !== "object" || value === null || Array.isArray(value)) { 33 - return undefined; 34 - } 35 - return value as FlexibleRecord; 36 - }; 37 38 38 39 const repoRoot = path.resolve( 39 40 path.dirname(fileURLToPath(import.meta.url)), ··· 72 73 bun run write:pdslab-configs [--ledger .tmp/smoke-accounts.local.json] [--output-dir .tmp/generated/pdslab-configs] 73 74 `; 74 75 75 - const readJson = async (filePath: string): Promise<FlexibleRecord> => { 76 - return JSON.parse(await fs.readFile(filePath, "utf8")); 77 - }; 76 + const readJson = async (filePath: string): Promise<FlexibleRecord> => 77 + parseJsonRecord(await fs.readFile(filePath, "utf8"), filePath); 78 78 79 79 const ensureTarget = ( 80 80 ledger: FlexibleRecord, 81 81 targetId: string, 82 82 ): FlexibleRecord => { 83 - const targets = asRecord(ledger.targets); 84 - const target = asRecord(targets?.[targetId]); 83 + const targets = getRecord(ledger.targets); 84 + const target = getRecord(targets?.[targetId]); 85 85 if (target === undefined) { 86 86 throw new Error(`ledger target missing: ${targetId}`); 87 87 } 88 88 return target; 89 89 }; 90 90 91 - const createAccountDefaults = ({ 92 - spec, 93 - role, 94 - }: { 95 - spec: FlexibleRecord; 96 - role: string; 97 - }): FlexibleRecord => { 98 - const prefix = `pdslab ${spec.id} ${role}`; 91 + const requiredAccountString = ( 92 + account: FlexibleRecord, 93 + key: "handle" | "password" | "did", 94 + ): string => { 95 + const value = getString(account, key); 96 + if (value === undefined) { 97 + throw new Error(`account is missing required ${key}`); 98 + } 99 + return value; 100 + }; 101 + 102 + const createAccountDefaults = ( 103 + specId: string, 104 + role: string, 105 + ): FlexibleRecord => { 106 + const prefix = `pdslab ${specId} ${role}`; 99 107 return { 100 108 postText: `${prefix} root post`, 101 109 mediaPostText: `${prefix} image post`, ··· 109 117 const createAccount = ({ 110 118 account, 111 119 loginIdentifierKey, 112 - spec, 120 + specId, 113 121 role, 114 122 }: { 115 123 account: FlexibleRecord; 116 124 loginIdentifierKey?: string; 117 - spec: FlexibleRecord; 125 + specId: string; 118 126 role: string; 119 127 }): FlexibleRecord => { 120 - if (!account) { 121 - throw new Error("account details are required"); 122 - } 123 - 124 128 const normalized: FlexibleRecord = { 125 - ...createAccountDefaults({ spec, role }), 126 - handle: account.handle, 127 - password: account.password, 129 + ...createAccountDefaults(specId, role), 130 + handle: requiredAccountString(account, "handle"), 131 + password: requiredAccountString(account, "password"), 128 132 }; 129 133 130 - if (loginIdentifierKey) { 131 - const loginIdentifier = account[loginIdentifierKey]; 132 - if (typeof loginIdentifier !== "string" || loginIdentifier.length === 0) { 134 + if (loginIdentifierKey !== undefined) { 135 + const loginIdentifier = getString(account, loginIdentifierKey); 136 + if (loginIdentifier === undefined || loginIdentifier.length === 0) { 133 137 throw new Error( 134 138 `missing loginIdentifierKey "${loginIdentifierKey}" on account`, 135 139 ); ··· 137 141 normalized.loginIdentifier = loginIdentifier; 138 142 } 139 143 140 - if (account.did) { 141 - normalized.did = account.did; 144 + const did = getString(account, "did"); 145 + if (did !== undefined) { 146 + normalized.did = did; 142 147 } 143 - if (account.email) { 144 - normalized.email = account.email; 148 + const email = getString(account, "email"); 149 + if (email !== undefined) { 150 + normalized.email = email; 145 151 } 146 - if (account.pdsUrl) { 147 - normalized.pdsUrl = account.pdsUrl; 152 + const pdsUrl = getString(account, "pdsUrl"); 153 + if (pdsUrl !== undefined) { 154 + normalized.pdsUrl = pdsUrl; 148 155 } 149 - if (account.pdsHost) { 150 - normalized.pdsHost = account.pdsHost; 156 + const pdsHost = getString(account, "pdsHost"); 157 + if (pdsHost !== undefined) { 158 + normalized.pdsHost = pdsHost; 151 159 } 152 160 153 161 return normalized; ··· 157 165 spec, 158 166 ledgerTarget, 159 167 }: { 160 - spec: FlexibleRecord; 168 + spec: PdslabTargetSpec; 161 169 ledgerTarget: FlexibleRecord; 162 170 }): FlexibleRecord => { 163 - const accountSource = spec.currentDeploymentKey 164 - ? ledgerTarget[String(spec.currentDeploymentKey)] 165 - : asRecord(ledgerTarget.accounts)?.[String(spec.ledgerAccount)]; 166 - const normalizedAccountSource = asRecord(accountSource); 171 + const currentDeploymentKey = 172 + "currentDeploymentKey" in spec && 173 + typeof spec.currentDeploymentKey === "string" 174 + ? spec.currentDeploymentKey 175 + : undefined; 176 + const ledgerAccount = 177 + "ledgerAccount" in spec && typeof spec.ledgerAccount === "string" 178 + ? spec.ledgerAccount 179 + : undefined; 180 + const accountSource = 181 + currentDeploymentKey !== undefined 182 + ? getUnknown(ledgerTarget, currentDeploymentKey) 183 + : getRecord(ledgerTarget.accounts)?.[ledgerAccount ?? ""]; 184 + const normalizedAccountSource = getRecord(accountSource); 185 + const specAccountSource = 186 + "accountSource" in spec ? spec.accountSource : undefined; 187 + const specPairGroup = "pairGroup" in spec ? spec.pairGroup : undefined; 167 188 168 189 if (normalizedAccountSource === undefined) { 169 190 throw new Error( ··· 173 194 174 195 return { 175 196 adapter: spec.adapter, 176 - pdsUrl: ledgerTarget.pdsUrl, 197 + pdsUrl: getUnknown(ledgerTarget, "pdsUrl"), 177 198 artifactsDir: `data/browser-smoke/pdslab/${spec.id}`, 178 199 targetHandle: "smoke-a.perlsky.pdslab.net", 179 - accountSource: spec.accountSource, 200 + accountSource: specAccountSource, 180 201 pdslabTargetId: spec.id, 181 202 pdslabRunnerStatus: spec.runnerStatus, 182 - pdslabPairGroup: spec.pairGroup, 203 + pdslabPairGroup: specPairGroup, 183 204 pdslabNotes: spec.notes, 184 205 account: createAccount({ 185 206 account: normalizedAccountSource, 186 207 loginIdentifierKey: 208 + "loginIdentifierKey" in spec && 187 209 typeof spec.loginIdentifierKey === "string" 188 210 ? spec.loginIdentifierKey 189 211 : undefined, 190 - spec, 191 - role: 192 - typeof spec.ledgerAccount === "string" ? spec.ledgerAccount : "single", 212 + specId: spec.id, 213 + role: ledgerAccount ?? "single", 193 214 }), 194 215 }; 195 216 }; ··· 198 219 spec, 199 220 ledgerTarget, 200 221 }: { 201 - spec: FlexibleRecord; 222 + spec: PdslabTargetSpec; 202 223 ledgerTarget: FlexibleRecord; 203 224 }): FlexibleRecord => { 204 - const accounts = asRecord(ledgerTarget.accounts); 205 - const primary = asRecord(accounts?.["smoke-a"]); 206 - const secondary = asRecord(accounts?.["smoke-b"]); 225 + const accounts = getRecord(ledgerTarget.accounts); 226 + const primary = getRecord(accounts?.["smoke-a"]); 227 + const secondary = getRecord(accounts?.["smoke-b"]); 228 + const specAccountSource = 229 + "accountSource" in spec ? spec.accountSource : undefined; 207 230 if (primary === undefined || secondary === undefined) { 208 231 throw new Error( 209 232 `dual target ${spec.id} is missing smoke-a or smoke-b in the ledger`, ··· 212 235 213 236 return { 214 237 adapter: spec.adapter, 215 - pdsUrl: ledgerTarget.pdsUrl, 238 + pdsUrl: getUnknown(ledgerTarget, "pdsUrl"), 216 239 artifactsDir: `data/browser-smoke/pdslab/${spec.id}`, 217 - accountSource: spec.accountSource, 240 + accountSource: specAccountSource, 218 241 pdslabTargetId: spec.id, 219 242 pdslabRunnerStatus: spec.runnerStatus, 220 243 pdslabNotes: spec.notes, 221 - primary: createAccount({ account: primary, spec, role: "primary" }), 222 - secondary: createAccount({ account: secondary, spec, role: "secondary" }), 244 + primary: createAccount({ 245 + account: primary, 246 + specId: spec.id, 247 + role: "primary", 248 + }), 249 + secondary: createAccount({ 250 + account: secondary, 251 + specId: spec.id, 252 + role: "secondary", 253 + }), 223 254 }; 224 255 }; 225 256 ··· 228 259 primaryLedgerTarget, 229 260 secondaryLedgerTarget, 230 261 }: { 231 - spec: FlexibleRecord; 262 + spec: PdslabTargetSpec; 232 263 primaryLedgerTarget: FlexibleRecord; 233 264 secondaryLedgerTarget: FlexibleRecord; 234 265 }): FlexibleRecord => { 235 - const primarySource = spec.primaryCurrentDeploymentKey 236 - ? primaryLedgerTarget[String(spec.primaryCurrentDeploymentKey)] 237 - : asRecord(primaryLedgerTarget.accounts)?.[ 238 - String(spec.primaryLedgerAccount) 239 - ]; 240 - const secondarySource = spec.secondaryCurrentDeploymentKey 241 - ? secondaryLedgerTarget[String(spec.secondaryCurrentDeploymentKey)] 242 - : asRecord(secondaryLedgerTarget.accounts)?.[ 243 - String(spec.secondaryLedgerAccount) 244 - ]; 266 + const primarySource = 267 + "primaryCurrentDeploymentKey" in spec && 268 + typeof spec.primaryCurrentDeploymentKey === "string" 269 + ? getUnknown(primaryLedgerTarget, spec.primaryCurrentDeploymentKey) 270 + : getRecord(primaryLedgerTarget.accounts)?.[ 271 + "primaryLedgerAccount" in spec && 272 + typeof spec.primaryLedgerAccount === "string" 273 + ? spec.primaryLedgerAccount 274 + : "" 275 + ]; 276 + const secondarySource = 277 + "secondaryCurrentDeploymentKey" in spec && 278 + typeof spec.secondaryCurrentDeploymentKey === "string" 279 + ? getUnknown(secondaryLedgerTarget, spec.secondaryCurrentDeploymentKey) 280 + : getRecord(secondaryLedgerTarget.accounts)?.[ 281 + "secondaryLedgerAccount" in spec && 282 + typeof spec.secondaryLedgerAccount === "string" 283 + ? spec.secondaryLedgerAccount 284 + : "" 285 + ]; 245 286 246 - if (!asRecord(primarySource) || !asRecord(secondarySource)) { 287 + const normalizedPrimarySource = getRecord(primarySource); 288 + const normalizedSecondarySource = getRecord(secondarySource); 289 + const specAccountSource = 290 + "accountSource" in spec ? spec.accountSource : undefined; 291 + if ( 292 + normalizedPrimarySource === undefined || 293 + normalizedSecondarySource === undefined 294 + ) { 247 295 throw new Error( 248 296 `cross-PDS target ${spec.id} is missing one of its accounts in the ledger`, 249 297 ); ··· 251 299 252 300 return { 253 301 adapter: spec.adapter, 254 - pdsUrl: primaryLedgerTarget.pdsUrl, 302 + pdsUrl: getUnknown(primaryLedgerTarget, "pdsUrl"), 255 303 artifactsDir: `data/browser-smoke/pdslab/${spec.id}`, 256 - accountSource: spec.accountSource, 304 + accountSource: specAccountSource, 257 305 pdslabTargetId: spec.id, 258 306 pdslabRunnerStatus: spec.runnerStatus, 259 307 pdslabNotes: spec.notes, 260 308 primary: createAccount({ 261 309 account: { 262 - ...asRecord(primarySource), 263 - pdsUrl: primaryLedgerTarget.pdsUrl, 310 + ...normalizedPrimarySource, 311 + pdsUrl: getUnknown(primaryLedgerTarget, "pdsUrl"), 264 312 }, 265 313 loginIdentifierKey: 314 + "primaryLoginIdentifierKey" in spec && 266 315 typeof spec.primaryLoginIdentifierKey === "string" 267 316 ? spec.primaryLoginIdentifierKey 268 317 : undefined, 269 - spec, 318 + specId: spec.id, 270 319 role: "primary", 271 320 }), 272 321 secondary: createAccount({ 273 322 account: { 274 - ...asRecord(secondarySource), 275 - pdsUrl: secondaryLedgerTarget.pdsUrl, 323 + ...normalizedSecondarySource, 324 + pdsUrl: getUnknown(secondaryLedgerTarget, "pdsUrl"), 276 325 }, 277 326 loginIdentifierKey: 327 + "secondaryLoginIdentifierKey" in spec && 278 328 typeof spec.secondaryLoginIdentifierKey === "string" 279 329 ? spec.secondaryLoginIdentifierKey 280 330 : undefined, 281 - spec, 331 + specId: spec.id, 282 332 role: "secondary", 283 333 }), 284 334 }; ··· 288 338 const targets: PlanTarget[] = []; 289 339 const skipped: SkippedTarget[] = []; 290 340 291 - for (const spec of PDSLAB_TARGETS as readonly FlexibleRecord[]) { 292 - const specId = String(spec.id); 293 - const specMode = String(spec.mode); 294 - const specRunnerStatus = String(spec.runnerStatus); 295 - const needsLedgerTarget = typeof spec.ledgerTarget === "string"; 296 - const ledgerTarget = needsLedgerTarget 297 - ? ensureTarget(ledger, String(spec.ledgerTarget)) 298 - : null; 341 + for (const spec of PDSLAB_TARGETS) { 342 + const specId = spec.id; 343 + const specMode = spec.mode; 344 + const specRunnerStatus = spec.runnerStatus; 345 + const ledgerTarget = 346 + "ledgerTarget" in spec && typeof spec.ledgerTarget === "string" 347 + ? ensureTarget(ledger, spec.ledgerTarget) 348 + : undefined; 299 349 const primaryLedgerTarget = 350 + "primaryLedgerTarget" in spec && 300 351 typeof spec.primaryLedgerTarget === "string" 301 - ? ensureTarget(ledger, String(spec.primaryLedgerTarget)) 302 - : null; 352 + ? ensureTarget(ledger, spec.primaryLedgerTarget) 353 + : undefined; 303 354 const secondaryLedgerTarget = 355 + "secondaryLedgerTarget" in spec && 304 356 typeof spec.secondaryLedgerTarget === "string" 305 - ? ensureTarget(ledger, String(spec.secondaryLedgerTarget)) 306 - : null; 357 + ? ensureTarget(ledger, spec.secondaryLedgerTarget) 358 + : undefined; 307 359 308 360 if ( 309 361 specRunnerStatus !== "ready" && ··· 321 373 let config: FlexibleRecord; 322 374 if ( 323 375 spec.mode === "dual" && 324 - spec.primaryLedgerTarget && 325 - spec.secondaryLedgerTarget 376 + primaryLedgerTarget !== undefined && 377 + secondaryLedgerTarget !== undefined 326 378 ) { 327 - if (!primaryLedgerTarget || !secondaryLedgerTarget) { 328 - throw new Error( 329 - `cross-PDS target ${specId} is missing its ledger targets`, 330 - ); 331 - } 332 379 config = createCrossPdsDualConfig({ 333 380 spec, 334 381 primaryLedgerTarget, 335 382 secondaryLedgerTarget, 336 383 }); 337 384 } else if (spec.mode === "dual") { 338 - if (!ledgerTarget) { 385 + if (ledgerTarget === undefined) { 339 386 throw new Error(`dual target ${specId} is missing its ledger target`); 340 387 } 341 388 config = createDualConfig({ spec, ledgerTarget }); 342 389 } else { 343 - if (!ledgerTarget) { 390 + if (ledgerTarget === undefined) { 344 391 throw new Error(`single target ${specId} is missing its ledger target`); 345 392 } 346 393 config = createSingleConfig({ spec, ledgerTarget }); ··· 356 403 357 404 return { 358 405 generatedAt: new Date().toISOString(), 359 - domain: ledger.domain, 406 + domain: getUnknown(ledger, "domain"), 360 407 runnableTargets: targets, 361 408 skippedTargets: skipped, 362 409 }; ··· 379 426 id, 380 427 mode, 381 428 runnerStatus, 382 - pdsUrl: config.pdsUrl, 383 - primaryPdsUrl: asRecord(config.primary)?.pdsUrl, 384 - secondaryPdsUrl: asRecord(config.secondary)?.pdsUrl, 385 - artifactsDir: config.artifactsDir, 386 - accountSource: config.accountSource, 387 - pairGroup: config.pdslabPairGroup, 388 - notes: config.pdslabNotes, 389 - loginIdentifier: asRecord(config.account)?.loginIdentifier, 429 + pdsUrl: getUnknown(config, "pdsUrl"), 430 + primaryPdsUrl: getRecord(config.primary)?.pdsUrl, 431 + secondaryPdsUrl: getRecord(config.secondary)?.pdsUrl, 432 + artifactsDir: getUnknown(config, "artifactsDir"), 433 + accountSource: getUnknown(config, "accountSource"), 434 + pairGroup: getUnknown(config, "pdslabPairGroup"), 435 + notes: getUnknown(config, "pdslabNotes"), 436 + loginIdentifier: getRecord(config.account)?.loginIdentifier, 390 437 }), 391 438 ), 392 439 skippedTargets: plan.skippedTargets, ··· 410 457 411 458 const main = async (argv = process.argv): Promise<number> => { 412 459 const args = parseArgs(argv); 413 - if (args.help) { 460 + if (args.help === true) { 414 461 process.stdout.write(usage); 415 462 return 0; 416 463 } ··· 440 487 return 0; 441 488 }; 442 489 443 - const exitCode = await main(process.argv).catch((error) => { 490 + const exitCode = await main(process.argv).catch((error: unknown) => { 444 491 process.stderr.write(`${errorMessage(error)}\n`); 445 492 return 1; 446 493 });
+404
src/browser/lib/browser-types.ts
··· 1 + import type { Browser, Locator, Page } from "playwright"; 2 + import type { 3 + AccountConfig, 4 + ConsoleEntry, 5 + DualRunConfig, 6 + FetchJsonResult, 7 + FetchStatusResult, 8 + FlexibleRecord, 9 + HttpFailureEntry, 10 + SessionInfo, 11 + RequestFailureEntry, 12 + ProfileCountsSnapshot, 13 + RepoRecord, 14 + RuntimeDualAccount, 15 + SingleRunConfig, 16 + Summary, 17 + XrpcJsonOptions, 18 + } from "../../types.js"; 19 + 20 + export type PageName = "primary" | "secondary"; 21 + 22 + export interface StepOptions { 23 + optional?: boolean; 24 + timeoutMs?: number; 25 + pageNames?: PageName[]; 26 + } 27 + 28 + export type StepRunner = <T>( 29 + name: string, 30 + fn: () => Promise<T>, 31 + options?: StepOptions, 32 + ) => Promise<T | null>; 33 + 34 + export type SingleWait = (ms: number) => Promise<void>; 35 + export type PageWait = (page: Page, ms: number) => Promise<void>; 36 + 37 + export interface LoginTarget { 38 + pdsHost: string; 39 + loginIdentifier: string; 40 + password: string; 41 + notes?: string[]; 42 + noteTarget?: string; 43 + } 44 + 45 + export interface AgeAssuranceTarget { 46 + birthdate: string; 47 + notes?: string[]; 48 + noteText?: string; 49 + } 50 + 51 + export interface PageAuthActionsOptions { 52 + appUrl: string; 53 + appBaseUrl: string; 54 + wait: PageWait; 55 + loginToBlueskyApp: (args: { 56 + page: Page; 57 + appUrl: string; 58 + pdsHost: string; 59 + loginIdentifier: string; 60 + password: string; 61 + notes?: string[]; 62 + noteTarget?: string; 63 + }) => Promise<{ loginPath: string }>; 64 + } 65 + 66 + export interface PageAuthActions { 67 + login: (page: Page, target: LoginTarget) => Promise<{ loginPath: string }>; 68 + completeAgeAssuranceIfNeeded: ( 69 + page: Page, 70 + target: AgeAssuranceTarget, 71 + ) => Promise<void>; 72 + gotoProfile: (page: Page, handle: string) => Promise<void>; 73 + waitForProfileHandle: ( 74 + page: Page, 75 + handle: string, 76 + timeout?: number, 77 + ) => Promise<void>; 78 + maybeFollow: (page: Page) => Promise<FlexibleRecord>; 79 + maybeUnfollow: (page: Page) => Promise<FlexibleRecord>; 80 + openNotifications: (page: Page) => Promise<void>; 81 + openSavedPosts: (page: Page) => Promise<void>; 82 + openProfileTab: (page: Page, name: string) => Promise<void>; 83 + } 84 + 85 + export interface PageFeedActionsOptions { 86 + wait: PageWait; 87 + normalizeText: (text: string | null | undefined) => string; 88 + buttonText: (locator: Locator) => Promise<string>; 89 + dismissBlockingOverlays: (page: Page) => Promise<void>; 90 + } 91 + 92 + export interface PublishComposerOptions { 93 + applyWritesLabel: string; 94 + publishLabel: string | RegExp; 95 + } 96 + 97 + export interface PageFeedActions { 98 + composePost: (page: Page, text: string) => Promise<void>; 99 + findRowByPrimaryText: ( 100 + page: Page, 101 + needle: string, 102 + timeout?: number, 103 + ) => Promise<Locator>; 104 + maybeFindRowByPrimaryText: ( 105 + page: Page, 106 + needle: string, 107 + timeout?: number, 108 + ) => Promise<Locator | null>; 109 + findFirstFeedItem: (page: Page, timeout?: number) => Promise<Locator>; 110 + clickQuote: (page: Page, row: Locator, text: string) => Promise<void>; 111 + clickReply: (page: Page, row: Page | Locator, text: string) => Promise<void>; 112 + ensureBookmarked: (page: Page, row: Locator) => Promise<FlexibleRecord>; 113 + ensureNotBookmarked: (page: Page, row: Locator) => Promise<FlexibleRecord>; 114 + ensureLiked: (page: Page, row: Locator) => Promise<FlexibleRecord>; 115 + ensureNotLiked: (page: Page, row: Locator) => Promise<FlexibleRecord>; 116 + ensureReposted: (page: Page, row: Locator) => Promise<FlexibleRecord>; 117 + ensureNotReposted: (page: Page, row: Locator) => Promise<FlexibleRecord>; 118 + openPostOptions: (page: Page, row: Locator) => Promise<Locator>; 119 + maybeDeleteOwnPostByText: ( 120 + page: Page, 121 + text: string, 122 + successNote: string, 123 + ) => Promise<FlexibleRecord>; 124 + } 125 + 126 + export interface ProfileEditTarget { 127 + profileNote: string; 128 + handle?: string; 129 + } 130 + 131 + export interface PageProfileEditActionsOptions { 132 + artifactsDir: string; 133 + wait: PageWait; 134 + dismissBlockingOverlays: (page: Page) => Promise<void>; 135 + avatarPngBase64: string; 136 + notes?: string[]; 137 + } 138 + 139 + export interface PageProfileEditActions { 140 + ensureAvatarFixture: () => Promise<string>; 141 + uploadProfileAvatar: (page: Page) => Promise<string>; 142 + editProfile: ( 143 + page: Page, 144 + target: ProfileEditTarget, 145 + ) => Promise<{ avatarFile: string; profileNote: string }>; 146 + } 147 + 148 + export type FetchJson = ( 149 + url: string, 150 + options?: FlexibleRecord, 151 + ) => Promise<FetchJsonResult>; 152 + 153 + export type FetchStatus = ( 154 + url: string, 155 + options?: FlexibleRecord, 156 + ) => Promise<FetchStatusResult>; 157 + 158 + export type PollJson = ( 159 + name: string, 160 + buildUrl: () => string, 161 + predicate: (result: FetchJsonResult) => boolean, 162 + timeoutMs: number, 163 + ) => Promise<FetchJsonResult>; 164 + 165 + export type XrpcJson = ( 166 + nsid: string, 167 + options?: XrpcJsonOptions, 168 + ) => Promise<FetchJsonResult>; 169 + 170 + export interface SingleActionsOptions { 171 + config: SingleRunConfig; 172 + summary: Summary; 173 + page: Page; 174 + appBaseUrl: string; 175 + wait: SingleWait; 176 + sleep: (ms: number) => Promise<void>; 177 + normalizeText: (text: string | null | undefined) => string; 178 + buttonText: (locator: Locator) => Promise<string>; 179 + fetchStatus: FetchStatus; 180 + pollJson: PollJson; 181 + avatarPngBase64: string; 182 + } 183 + 184 + export interface SingleActions { 185 + login: () => Promise<void>; 186 + completeAgeAssuranceIfNeeded: () => Promise<void>; 187 + gotoProfile: (handle: string) => Promise<void>; 188 + waitForProfileHandle: (handle: string, timeout?: number) => Promise<void>; 189 + maybeFollowTarget: () => Promise<FlexibleRecord>; 190 + maybeUnfollowTarget: () => Promise<FlexibleRecord>; 191 + openNotifications: () => Promise<void>; 192 + openSavedPosts: () => Promise<void>; 193 + openProfileTab: (name: string) => Promise<void>; 194 + composePost: (text: string) => Promise<void>; 195 + findRowByPrimaryText: (needle: string, timeout?: number) => Promise<Locator>; 196 + findFirstFeedItem: (timeout?: number) => Promise<Locator>; 197 + clickQuote: (row: Locator, text: string) => Promise<void>; 198 + clickReply: (row: Page | Locator, text: string) => Promise<void>; 199 + ensureBookmarked: (row: Locator) => Promise<FlexibleRecord>; 200 + ensureNotBookmarked: (row: Locator) => Promise<FlexibleRecord>; 201 + ensureLiked: (row: Locator) => Promise<FlexibleRecord>; 202 + ensureNotLiked: (row: Locator) => Promise<FlexibleRecord>; 203 + ensureReposted: (row: Locator) => Promise<FlexibleRecord>; 204 + ensureNotReposted: (row: Locator) => Promise<FlexibleRecord>; 205 + maybeDeleteOwnPostByText: ( 206 + text: string, 207 + successNote: string, 208 + ) => Promise<FlexibleRecord>; 209 + verifyPublicHandleResolution: () => Promise<FlexibleRecord>; 210 + verifyPublicAuthorFeed: () => Promise<FlexibleRecord>; 211 + verifyPublicProfile: () => Promise<FlexibleRecord>; 212 + verifyPublicProfileAfterEdit: () => Promise<FlexibleRecord>; 213 + verifyLocalProfileAfterEdit: () => Promise<FlexibleRecord>; 214 + editProfile: () => Promise<{ avatarFile: string; profileNote: string }>; 215 + } 216 + 217 + export interface DualActionsOptions { 218 + config: DualRunConfig; 219 + summary: Summary; 220 + appBaseUrl: string; 221 + wait: PageWait; 222 + sleep: (ms: number) => Promise<void>; 223 + normalizeText: (text: string | null | undefined) => string; 224 + buttonText: (locator: Locator) => Promise<string>; 225 + fetchJson: FetchJson; 226 + fetchStatus: FetchStatus; 227 + xrpcJson: XrpcJson; 228 + avatarPngBase64: string; 229 + } 230 + 231 + export interface DualActions { 232 + login: (page: Page, account: AccountConfig) => Promise<void>; 233 + completeAgeAssuranceIfNeeded: ( 234 + page: Page, 235 + account: AccountConfig, 236 + ) => Promise<void>; 237 + gotoProfile: (page: Page, handle: string) => Promise<void>; 238 + waitForProfileHandle: ( 239 + page: Page, 240 + handle: string, 241 + timeout?: number, 242 + ) => Promise<void>; 243 + verifyProfileCountsAfterReload: ( 244 + page: Page, 245 + viewerAccount: AccountConfig, 246 + profileHandle: string, 247 + expected: { followersCount?: number; followsCount?: number }, 248 + timeoutMs?: number, 249 + ) => Promise<ProfileCountsSnapshot>; 250 + readProfileCountsAfterReload: ( 251 + page: Page, 252 + viewerAccount: AccountConfig, 253 + profileHandle: string, 254 + timeoutMs?: number, 255 + ) => Promise<ProfileCountsSnapshot>; 256 + composePost: (page: Page, text: string) => Promise<void>; 257 + composePostWithImage: ( 258 + page: Page, 259 + text: string, 260 + ) => Promise<{ mediaFile: string }>; 261 + editProfile: ( 262 + page: Page, 263 + account: AccountConfig, 264 + ) => Promise<{ avatarFile: string; profileNote: string }>; 265 + verifyLocalProfileAfterEdit: ( 266 + account: AccountConfig, 267 + ) => Promise<FlexibleRecord>; 268 + verifyPublicProfileAfterEdit: ( 269 + account: AccountConfig, 270 + ) => Promise<FlexibleRecord>; 271 + findRowByPrimaryText: ( 272 + page: Page, 273 + needle: string, 274 + timeout?: number, 275 + ) => Promise<Locator>; 276 + ensureLiked: (page: Page, row: Locator) => Promise<FlexibleRecord>; 277 + ensureNotLiked: (page: Page, row: Locator) => Promise<FlexibleRecord>; 278 + ensureReposted: (page: Page, row: Locator) => Promise<FlexibleRecord>; 279 + ensureNotReposted: (page: Page, row: Locator) => Promise<FlexibleRecord>; 280 + ensureBookmarked: (page: Page, row: Locator) => Promise<FlexibleRecord>; 281 + ensureNotBookmarked: (page: Page, row: Locator) => Promise<FlexibleRecord>; 282 + clickQuote: (page: Page, row: Locator, text: string) => Promise<void>; 283 + clickReply: (page: Page, row: Page | Locator, text: string) => Promise<void>; 284 + maybeFollow: (page: Page) => Promise<FlexibleRecord>; 285 + maybeUnfollow: (page: Page) => Promise<FlexibleRecord>; 286 + openNotifications: (page: Page) => Promise<void>; 287 + openSavedPosts: (page: Page) => Promise<void>; 288 + waitForNotificationsFeed: (page: Page) => Promise<Locator | null>; 289 + openProfileTab: (page: Page, name: string) => Promise<void>; 290 + maybeDeleteOwnPostByText: ( 291 + page: Page, 292 + text: string, 293 + successNote: string, 294 + ) => Promise<FlexibleRecord>; 295 + openReportPostDraft: (page: Page, row: Locator) => Promise<FlexibleRecord>; 296 + ensureProfileMuted: (page: Page) => Promise<FlexibleRecord>; 297 + ensureProfileUnmuted: (page: Page) => Promise<FlexibleRecord>; 298 + blockProfile: (page: Page) => Promise<FlexibleRecord>; 299 + unblockProfile: (page: Page) => Promise<FlexibleRecord>; 300 + } 301 + 302 + export interface SingleScenarioContext extends SingleActions { 303 + step: StepRunner; 304 + config: SingleRunConfig; 305 + page: Page; 306 + } 307 + 308 + export interface DualScenarioContext extends DualActions { 309 + config: DualRunConfig; 310 + step: StepRunner; 311 + primaryPage: Page; 312 + secondaryPage: Page; 313 + primary: RuntimeDualAccount; 314 + secondary: RuntimeDualAccount; 315 + createSession: (account: AccountConfig) => Promise<SessionInfo>; 316 + cleanupStaleSmokeArtifacts: (account: AccountConfig) => Promise<{ 317 + deletedPosts: number; 318 + deletedListItems: number; 319 + deletedLists: number; 320 + }>; 321 + waitForOwnPostRecord: ( 322 + account: AccountConfig, 323 + text: string, 324 + timeoutMs?: number, 325 + ) => Promise<RepoRecord>; 326 + waitForFollowRecord: ( 327 + account: AccountConfig, 328 + subjectDid: string, 329 + timeoutMs?: number, 330 + ) => Promise<RepoRecord>; 331 + waitForNoOwnRecord: ( 332 + account: AccountConfig, 333 + collection: string, 334 + predicate: (record: RepoRecord) => boolean, 335 + timeoutMs?: number, 336 + ) => Promise<true>; 337 + waitForOwnListRecord: ( 338 + account: AccountConfig, 339 + name: string, 340 + timeoutMs?: number, 341 + ) => Promise<RepoRecord>; 342 + waitForOwnListItemRecord: ( 343 + account: AccountConfig, 344 + listUri: string, 345 + subjectDid: string, 346 + timeoutMs?: number, 347 + ) => Promise<RepoRecord>; 348 + recordRkey: (recordOrUri: RepoRecord | string) => string | undefined; 349 + pollNotifications: (args: { 350 + account: AccountConfig; 351 + authorHandle: string; 352 + reasons: string[]; 353 + minIndexedAt: number; 354 + timeoutMs?: number; 355 + }) => Promise<{ 356 + notifications: FlexibleRecord[]; 357 + allNotifications: FlexibleRecord[]; 358 + }>; 359 + openListPage: (page: Page, handle: string, listRkey: string) => Promise<void>; 360 + createList: ( 361 + page: Page, 362 + name: string, 363 + description: string, 364 + ) => Promise<FlexibleRecord>; 365 + editCurrentList: ( 366 + page: Page, 367 + name: string, 368 + description: string, 369 + ) => Promise<FlexibleRecord>; 370 + addUserToCurrentList: (page: Page, handle: string) => Promise<FlexibleRecord>; 371 + removeUserFromCurrentList: ( 372 + page: Page, 373 + handle: string, 374 + ) => Promise<FlexibleRecord>; 375 + deleteCurrentList: (page: Page) => Promise<FlexibleRecord>; 376 + setRadioSetting: ( 377 + page: Page, 378 + route: string, 379 + name: string, 380 + ) => Promise<FlexibleRecord>; 381 + setCheckboxSetting: ( 382 + page: Page, 383 + route: string, 384 + name: string, 385 + desired: boolean, 386 + ) => Promise<FlexibleRecord>; 387 + } 388 + 389 + export interface DualStepHelpers { 390 + screenshot: (pageName: PageName, name: string) => Promise<string>; 391 + normalizeText: (text: string | null | undefined) => string; 392 + isIgnoredConsole: (entry: ConsoleEntry) => boolean; 393 + isIgnoredRequestFailure: (entry: RequestFailureEntry) => boolean; 394 + isIgnoredHttpFailure: (entry: HttpFailureEntry) => boolean; 395 + step: StepRunner; 396 + wait: PageWait; 397 + buttonText: (locator: Locator) => Promise<string>; 398 + } 399 + 400 + export interface SetupDualBrowserResult { 401 + browser: Browser; 402 + primaryPage: Page; 403 + secondaryPage: Page; 404 + }
+2 -1
src/browser/lib/dual-actions.ts
··· 1 + import type { DualActions, DualActionsOptions } from "./browser-types.js"; 1 2 import { createDualFeedActions } from "./dual-actions/feed.js"; 2 3 import { createDualModerationActions } from "./dual-actions/moderation.js"; 3 4 import { createDualProfileActions } from "./dual-actions/profile.js"; 4 5 5 - export const createDualActions = (options) => { 6 + export const createDualActions = (options: DualActionsOptions): DualActions => { 6 7 return { 7 8 ...createDualProfileActions(options), 8 9 ...createDualFeedActions(options),
+37 -6
src/browser/lib/dual-actions/feed.ts
··· 1 + import type { 2 + DualActions, 3 + DualActionsOptions, 4 + PageAuthActions, 5 + PageFeedActions, 6 + } from "../browser-types.js"; 7 + import type { Locator, Page } from "playwright"; 1 8 import { createPageAuthActions } from "../page-auth-actions.js"; 2 9 import { createPageFeedActions } from "../page-feed-actions.js"; 3 10 import { dismissBlockingOverlays } from "../runtime-utils.js"; ··· 8 15 wait, 9 16 normalizeText, 10 17 buttonText, 11 - }) => { 12 - const authActions = createPageAuthActions({ 18 + }: DualActionsOptions): Pick< 19 + DualActions, 20 + | "findRowByPrimaryText" 21 + | "ensureLiked" 22 + | "ensureNotLiked" 23 + | "ensureReposted" 24 + | "ensureNotReposted" 25 + | "ensureBookmarked" 26 + | "ensureNotBookmarked" 27 + | "clickQuote" 28 + | "clickReply" 29 + | "maybeFollow" 30 + | "maybeUnfollow" 31 + | "openNotifications" 32 + | "openSavedPosts" 33 + | "waitForNotificationsFeed" 34 + | "openProfileTab" 35 + | "maybeDeleteOwnPostByText" 36 + | "openReportPostDraft" 37 + > => { 38 + const authActions: PageAuthActions = createPageAuthActions({ 13 39 appUrl: config.appUrl, 14 40 appBaseUrl, 15 41 wait, 16 - loginToBlueskyApp: () => undefined, 42 + loginToBlueskyApp: () => Promise.resolve({ loginPath: "unused" }), 17 43 }); 18 - const feedActions = createPageFeedActions({ 44 + const feedActions: PageFeedActions = createPageFeedActions({ 19 45 wait, 20 46 normalizeText, 21 47 buttonText, 22 48 dismissBlockingOverlays, 23 49 }); 24 50 25 - const waitForNotificationsFeed = async (page) => { 51 + const waitForNotificationsFeed = async ( 52 + page: Page, 53 + ): Promise<Locator | null> => { 26 54 const feed = page.getByTestId("notifsFeed").first(); 27 55 if (await feed.count()) { 28 56 await feed.waitFor({ state: "visible", timeout: 15000 }); ··· 31 59 return null; 32 60 }; 33 61 34 - const openReportPostDraft = async (page, row) => { 62 + const openReportPostDraft = async ( 63 + page: Page, 64 + row: Locator, 65 + ): Promise<Record<string, string | boolean>> => { 35 66 await feedActions.openPostOptions(page, row); 36 67 await page 37 68 .getByRole("menuitem", { name: /report post/i })
+25 -8
src/browser/lib/dual-actions/moderation.ts
··· 1 - export const createDualModerationActions = ({ wait }) => { 2 - const openProfileMenu = async (page) => { 1 + import type { Locator, Page } from "playwright"; 2 + import type { DualActions, DualActionsOptions } from "../browser-types.js"; 3 + 4 + export const createDualModerationActions = ({ 5 + wait, 6 + }: DualActionsOptions): Pick< 7 + DualActions, 8 + | "ensureProfileMuted" 9 + | "ensureProfileUnmuted" 10 + | "blockProfile" 11 + | "unblockProfile" 12 + > => { 13 + const openProfileMenu = async (page: Page): Promise<Locator> => { 3 14 const btn = page.getByTestId("profileHeaderDropdownBtn").first(); 4 15 await btn.waitFor({ state: "visible", timeout: 15000 }); 5 16 await btn.click({ noWaitAfter: true }); ··· 8 19 return menu; 9 20 }; 10 21 11 - const menuItems = (page) => 22 + const menuItems = (page: Page): Promise<string[]> => 12 23 page 13 24 .locator('[role="menuitem"]') 14 25 .evaluateAll((els) => ··· 17 28 .filter(Boolean), 18 29 ); 19 30 20 - const closeActiveMenu = async (page) => { 31 + const closeActiveMenu = async (page: Page): Promise<void> => { 21 32 const backdrop = page.locator('[aria-label*="backdrop"]').last(); 22 33 if (await backdrop.count()) { 23 34 await backdrop ··· 30 41 await wait(page, 400); 31 42 }; 32 43 33 - const ensureProfileMuted = async (page) => { 44 + const ensureProfileMuted = async ( 45 + page: Page, 46 + ): Promise<Record<string, string>> => { 34 47 await openProfileMenu(page); 35 48 const items = await menuItems(page); 36 49 if (items.some((item) => /unmute account/i.test(item))) { ··· 50 63 return { note: "muted account" }; 51 64 }; 52 65 53 - const ensureProfileUnmuted = async (page) => { 66 + const ensureProfileUnmuted = async ( 67 + page: Page, 68 + ): Promise<Record<string, string>> => { 54 69 await openProfileMenu(page); 55 70 const items = await menuItems(page); 56 71 if (!items.some((item) => /unmute account/i.test(item))) { ··· 70 85 return { note: "unmuted account" }; 71 86 }; 72 87 73 - const blockProfile = async (page) => { 88 + const blockProfile = async (page: Page): Promise<Record<string, string>> => { 74 89 await openProfileMenu(page); 75 90 const items = await menuItems(page); 76 91 if (items.some((item) => /unblock account/i.test(item))) { ··· 93 108 return { note: "blocked account" }; 94 109 }; 95 110 96 - const unblockProfile = async (page) => { 111 + const unblockProfile = async ( 112 + page: Page, 113 + ): Promise<Record<string, string>> => { 97 114 const unblock = page.getByRole("button", { name: /unblock/i }).first(); 98 115 if (!(await unblock.count())) { 99 116 return { note: "already unblocked" };
+164 -89
src/browser/lib/dual-actions/profile.ts
··· 1 + import type { Page } from "playwright"; 1 2 import { 2 3 buttonText, 3 4 dismissBlockingOverlays, ··· 5 6 normalizeText, 6 7 pollJsonUntil, 7 8 } from "../runtime-utils.js"; 8 - import type { FlexibleRecord } from "../../../types.js"; 9 + import { isNumber, isRecord, isString } from "../../../guards.js"; 10 + import type { 11 + AccountConfig, 12 + FlexibleRecord, 13 + ProfileCountsSnapshot, 14 + RenderedProfileCountsRaw, 15 + } from "../../../types.js"; 16 + import type { 17 + DualActions, 18 + DualActionsOptions, 19 + PageAuthActions, 20 + PageFeedActions, 21 + PageProfileEditActions, 22 + } from "../browser-types.js"; 9 23 import { createPageAuthActions } from "../page-auth-actions.js"; 10 24 import { createPageFeedActions } from "../page-feed-actions.js"; 11 25 import { createPageProfileEditActions } from "../page-profile-edit-actions.js"; ··· 19 33 fetchJson, 20 34 fetchStatus, 21 35 avatarPngBase64, 22 - }) => { 23 - const publicCheckTimeoutMs = config.publicCheckTimeoutMs ?? 180000; 24 - const authActions = createPageAuthActions({ 36 + }: DualActionsOptions): Pick< 37 + DualActions, 38 + | "login" 39 + | "completeAgeAssuranceIfNeeded" 40 + | "gotoProfile" 41 + | "waitForProfileHandle" 42 + | "verifyProfileCountsAfterReload" 43 + | "readProfileCountsAfterReload" 44 + | "composePost" 45 + | "composePostWithImage" 46 + | "editProfile" 47 + | "verifyLocalProfileAfterEdit" 48 + | "verifyPublicProfileAfterEdit" 49 + > => { 50 + const publicCheckTimeoutMs = config.publicCheckTimeoutMs; 51 + const authActions: PageAuthActions = createPageAuthActions({ 25 52 appUrl: config.appUrl, 26 53 appBaseUrl, 27 54 wait, 28 55 loginToBlueskyApp, 29 56 }); 30 - const feedActions = createPageFeedActions({ 57 + const feedActions: PageFeedActions = createPageFeedActions({ 31 58 wait, 32 59 normalizeText, 33 60 buttonText, 34 61 dismissBlockingOverlays, 35 62 }); 36 - const profileEditActions = createPageProfileEditActions({ 37 - artifactsDir: config.artifactsDir, 38 - wait, 39 - dismissBlockingOverlays, 40 - avatarPngBase64, 41 - notes: summary.notes, 42 - }); 63 + const profileEditActions: PageProfileEditActions = 64 + createPageProfileEditActions({ 65 + artifactsDir: config.artifactsDir, 66 + wait, 67 + dismissBlockingOverlays, 68 + avatarPngBase64, 69 + notes: summary.notes, 70 + }); 43 71 44 - const parseCompactCount = (raw) => { 45 - if (typeof raw !== "string") { 72 + const parseCompactCount = (raw: unknown): number | undefined => { 73 + if (!isString(raw)) { 46 74 return undefined; 47 75 } 48 76 const normalized = raw.replace(/,/g, "").trim(); 49 - const match = normalized.match(/^([0-9]+(?:\.[0-9]+)?)([KMB])?$/i); 77 + const match = /^([0-9]+(?:\.[0-9]+)?)([KMB])?$/i.exec(normalized); 50 78 if (!match) { 51 79 return undefined; 52 80 } 53 81 const base = Number(match[1]); 54 - const suffix = (match[2] || "").toUpperCase(); 82 + const suffixMatch = /[KMB]$/i.exec(normalized); 83 + const suffix = suffixMatch?.[0]?.toUpperCase(); 55 84 const multiplier = 56 85 suffix === "K" 57 86 ? 1_000 ··· 63 92 return Math.round(base * multiplier); 64 93 }; 65 94 66 - const login = async (page, account) => { 67 - const loginIdentifier = account.loginIdentifier || account.handle; 95 + const login = async (page: Page, account: AccountConfig): Promise<void> => { 96 + const loginIdentifier = account.loginIdentifier ?? account.handle; 68 97 await authActions.login(page, { 69 - pdsHost: account.pdsHost || config.pdsHost, 98 + pdsHost: account.pdsHost ?? config.pdsHost, 70 99 loginIdentifier, 71 100 password: account.password, 72 101 notes: summary.notes, ··· 74 103 }); 75 104 }; 76 105 77 - const completeAgeAssuranceIfNeeded = (page, account) => 106 + const completeAgeAssuranceIfNeeded = ( 107 + page: Page, 108 + account: AccountConfig, 109 + ): Promise<void> => 78 110 authActions.completeAgeAssuranceIfNeeded(page, { 79 111 birthdate: account.birthdate, 80 112 notes: summary.notes, ··· 85 117 86 118 const waitForProfileHandle = authActions.waitForProfileHandle; 87 119 88 - const readRenderedProfileCounts = async (page) => { 89 - const raw = await page.evaluate(() => { 90 - const normalize = (text) => (text || "").replace(/\s+/g, " ").trim(); 91 - const entries = Array.from(document.querySelectorAll("a[href]")).map( 92 - (node) => ({ 93 - href: node.getAttribute("href") || "", 94 - text: normalize(node.textContent || ""), 95 - }), 120 + const readRenderedProfileCounts = async ( 121 + page: Page, 122 + ): Promise<{ 123 + followersCount: number; 124 + followsCount: number; 125 + raw: RenderedProfileCountsRaw; 126 + }> => { 127 + const raw = await page.evaluate<RenderedProfileCountsRaw>(() => { 128 + const entries = Array.from( 129 + document.querySelectorAll<HTMLAnchorElement>("a[href]"), 130 + ).map((node) => ({ 131 + href: node.getAttribute("href") ?? "", 132 + text: node.innerText.replace(/\s+/g, " ").trim(), 133 + })); 134 + const followersEntry = entries.find((entry) => 135 + /\/followers(?:[/?#]|$)/i.test(entry.href), 136 + ); 137 + const followsEntry = entries.find((entry) => 138 + /\/follows(?:[/?#]|$)/i.test(entry.href), 96 139 ); 97 - const pick = (pattern) => 98 - entries.find((entry) => pattern.test(entry.href))?.text; 99 - const bodyText = normalize(document.body?.innerText || ""); 100 - const followersFallback = bodyText.match( 101 - /([0-9][0-9.,]*\s*[KMB]?)\s+followers?/i, 102 - )?.[0]; 103 - const followsFallback = bodyText.match( 104 - /([0-9][0-9.,]*\s*[KMB]?)\s+(?:following|follows?)/i, 105 - )?.[0]; 140 + const bodyText = document.body.innerText.replace(/\s+/g, " ").trim(); 141 + const followersFallbackMatch = 142 + /([0-9][0-9.,]*\s*[KMB]?)\s+followers?/i.exec(bodyText); 143 + const followsFallbackMatch = 144 + /([0-9][0-9.,]*\s*[KMB]?)\s+(?:following|follows?)/i.exec(bodyText); 145 + const followersFallback = followersFallbackMatch?.[0]; 146 + const followsFallback = followsFallbackMatch?.[0]; 106 147 return { 107 - followersText: pick(/\/followers(?:[/?#]|$)/i) || followersFallback, 108 - followsText: pick(/\/follows(?:[/?#]|$)/i) || followsFallback, 148 + followersText: followersEntry?.text ?? followersFallback, 149 + followsText: followsEntry?.text ?? followsFallback, 109 150 }; 110 151 }); 111 152 112 - const parseLinkedCount = (text, label) => { 113 - if (typeof text !== "string" || !text.length) { 153 + const parseLinkedCount = (text: unknown, label: string): number => { 154 + if (!isString(text) || text.length === 0) { 114 155 throw new Error(`rendered ${label} link text not found`); 115 156 } 116 157 const normalized = text.replace(/\s+/g, " ").trim(); 117 - const match = normalized.match(/([0-9][0-9.,]*\s*[KMB]?)/i); 158 + const match = /([0-9][0-9.,]*\s*[KMB]?)/i.exec(normalized); 118 159 if (!match) { 119 160 throw new Error( 120 161 `unable to parse rendered ${label} count from "${normalized}"`, ··· 137 178 }; 138 179 139 180 const readProfileCountsSnapshot = async ( 140 - page, 141 - viewerAccount, 142 - profileHandle, 143 - ) => { 181 + page: Page, 182 + viewerAccount: AccountConfig, 183 + profileHandle: string, 184 + ): Promise<ProfileCountsSnapshot> => { 144 185 await gotoProfile(page, profileHandle); 145 186 await waitForProfileHandle(page, profileHandle); 146 187 const rendered = await readRenderedProfileCounts(page); 147 188 const apiResult = await xrpcJson("app.bsky.actor.getProfile", { 148 - token: viewerAccount?.accessJwt, 149 - pdsUrl: viewerAccount?.pdsUrl, 189 + token: viewerAccount.accessJwt, 190 + pdsUrl: viewerAccount.pdsUrl, 150 191 params: { actor: profileHandle }, 151 192 timeoutMs: 15000, 152 193 }); 153 194 if (!apiResult.ok) { 154 195 throw new Error(`failed to read profile counts for ${profileHandle}`); 155 196 } 197 + const apiJson = isRecord(apiResult.json) ? apiResult.json : {}; 156 198 return { 157 199 rendered, 158 200 api: { 159 - followersCount: apiResult.json?.followersCount, 160 - followsCount: apiResult.json?.followsCount, 201 + followersCount: isNumber(apiJson.followersCount) 202 + ? apiJson.followersCount 203 + : undefined, 204 + followsCount: isNumber(apiJson.followsCount) 205 + ? apiJson.followsCount 206 + : undefined, 161 207 }, 162 208 }; 163 209 }; 164 210 165 211 const verifyProfileCountsAfterReload = async ( 166 - page, 167 - viewerAccount, 168 - profileHandle, 169 - expected, 212 + page: Page, 213 + viewerAccount: AccountConfig, 214 + profileHandle: string, 215 + expected: { followersCount?: number; followsCount?: number }, 170 216 timeoutMs = 30000, 171 - ) => { 217 + ): Promise<ProfileCountsSnapshot> => { 172 218 const started = Date.now(); 173 - let snapshot; 219 + let lastSnapshot: ProfileCountsSnapshot | undefined; 174 220 while (Date.now() - started < timeoutMs) { 175 221 try { 176 - snapshot = await readProfileCountsSnapshot( 222 + const snapshot = await readProfileCountsSnapshot( 177 223 page, 178 224 viewerAccount, 179 225 profileHandle, 180 226 ); 181 - const matches = Object.entries(expected).every( 182 - ([key, value]) => 183 - snapshot?.rendered?.[key] === value && 184 - snapshot?.api?.[key] === value, 185 - ); 227 + lastSnapshot = snapshot; 228 + const matches = Object.entries(expected).every(([key, value]) => { 229 + if (key === "followersCount") { 230 + return ( 231 + snapshot.rendered.followersCount === value && 232 + snapshot.api.followersCount === value 233 + ); 234 + } 235 + if (key === "followsCount") { 236 + return ( 237 + snapshot.rendered.followsCount === value && 238 + snapshot.api.followsCount === value 239 + ); 240 + } 241 + return true; 242 + }); 186 243 if (matches) { 187 244 return snapshot; 188 245 } ··· 193 250 } 194 251 195 252 throw new Error( 196 - `profile counts for ${profileHandle} did not converge; expected=${JSON.stringify(expected)} rendered=${JSON.stringify(snapshot?.rendered)} api=${JSON.stringify(snapshot?.api)}`, 253 + `profile counts for ${profileHandle} did not converge; expected=${JSON.stringify(expected)} rendered=${JSON.stringify(lastSnapshot?.rendered)} api=${JSON.stringify(lastSnapshot?.api)}`, 197 254 ); 198 255 }; 199 256 200 257 const readProfileCountsAfterReload = async ( 201 - page, 202 - viewerAccount, 203 - profileHandle, 258 + page: Page, 259 + viewerAccount: AccountConfig, 260 + profileHandle: string, 204 261 timeoutMs = 30000, 205 - ) => { 262 + ): Promise<ProfileCountsSnapshot> => { 206 263 const started = Date.now(); 207 - let lastError; 264 + let lastError: unknown; 208 265 while (Date.now() - started < timeoutMs) { 209 266 try { 210 267 return await readProfileCountsSnapshot( ··· 217 274 await wait(page, 2000); 218 275 } 219 276 } 220 - throw ( 221 - lastError || 222 - new Error(`failed to read profile counts for ${profileHandle}`) 223 - ); 277 + throw lastError instanceof Error 278 + ? lastError 279 + : new Error(`failed to read profile counts for ${profileHandle}`); 224 280 }; 225 281 226 282 const composePost = feedActions.composePost; 227 283 228 - const uploadComposerMedia = async (page) => { 284 + const uploadComposerMedia = async (page: Page): Promise<string> => { 229 285 const mediaFile = await profileEditActions.ensureAvatarFixture(); 230 286 const openMedia = page.getByTestId("openMediaBtn").last(); 231 287 if (!(await openMedia.count())) { ··· 239 295 return mediaFile; 240 296 }; 241 297 242 - const composePostWithImage = async (page, text) => { 298 + const composePostWithImage = async ( 299 + page: Page, 300 + text: string, 301 + ): Promise<{ mediaFile: string }> => { 243 302 await page 244 303 .locator('[aria-label="Compose new post"]') 245 304 .last() ··· 257 316 return { mediaFile }; 258 317 }; 259 318 260 - const editProfile = (page, account) => 319 + const editProfile = ( 320 + page: Page, 321 + account: AccountConfig, 322 + ): Promise<{ avatarFile: string; profileNote: string }> => 261 323 profileEditActions.editProfile(page, { 262 324 profileNote: account.profileNote, 263 325 handle: account.handle, 264 326 }); 265 327 266 - const verifyLocalProfileAfterEdit = async (account) => { 328 + const verifyLocalProfileAfterEdit = async ( 329 + account: AccountConfig, 330 + ): Promise<FlexibleRecord> => { 267 331 const didResult = await xrpcJson("com.atproto.identity.resolveHandle", { 268 332 pdsUrl: account.pdsUrl, 269 333 params: { handle: account.handle }, 270 334 }); 271 - if (!didResult.ok || didResult.json?.did !== account.did) { 335 + const didJson = isRecord(didResult.json) ? didResult.json : undefined; 336 + if (!didResult.ok || didJson?.did !== account.did) { 272 337 throw new Error(`handle did mismatch for ${account.handle}`); 273 338 } 274 339 const result = await xrpcJson("com.atproto.repo.getRecord", { 275 340 pdsUrl: account.pdsUrl, 276 341 params: { 277 - repo: account.did, 342 + repo: account.did ?? "", 278 343 collection: "app.bsky.actor.profile", 279 344 rkey: "self", 280 345 }, 281 346 }); 282 347 if (!result.ok) { 283 348 throw new Error( 284 - `profile record lookup failed for ${account.handle}: ${result.status} ${result.text}`, 349 + `profile record lookup failed for ${account.handle}: ${String(result.status)} ${result.text}`, 285 350 ); 286 351 } 287 - const avatarCid = result.json?.value?.avatar?.ref?.$link; 288 - const description = result.json?.value?.description; 352 + const resultJson = isRecord(result.json) ? result.json : {}; 353 + const value = isRecord(resultJson.value) ? resultJson.value : {}; 354 + const avatar = isRecord(value.avatar) ? value.avatar : {}; 355 + const ref = isRecord(avatar.ref) ? avatar.ref : {}; 356 + const avatarCid = ref.$link; 357 + const description = value.description; 289 358 if ( 290 359 description !== account.profileNote || 291 360 typeof avatarCid !== "string" || ··· 298 367 return { avatarCid, description }; 299 368 }; 300 369 301 - const verifyPublicProfileAfterEdit = async (account) => { 370 + const verifyPublicProfileAfterEdit = async ( 371 + account: AccountConfig, 372 + ): Promise<FlexibleRecord> => { 302 373 const result = await pollJsonUntil({ 303 374 name: `public profile edit indexing for ${account.handle}`, 304 375 buildUrl: () => 305 376 `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(account.handle)}`, 306 - predicate: ({ ok, json }) => { 307 - const publicProfile = json as FlexibleRecord; 377 + predicate: ({ ok, json }): boolean => { 378 + const publicProfile = isRecord(json) ? json : undefined; 379 + const avatar = publicProfile?.avatar; 308 380 return ( 309 381 ok && 310 382 publicProfile?.description === account.profileNote && 311 - typeof publicProfile?.avatar === "string" && 312 - publicProfile.avatar.length > 0 383 + isString(avatar) && 384 + avatar.length > 0 313 385 ); 314 386 }, 315 387 timeoutMs: publicCheckTimeoutMs, 316 388 fetchJson, 317 389 }); 318 - const publicProfile = result.json as FlexibleRecord; 319 - const avatarResult = await fetchStatus(String(publicProfile.avatar)); 390 + const publicProfile = isRecord(result.json) ? result.json : {}; 391 + if (!isString(publicProfile.avatar)) { 392 + throw new Error(`public avatar URL missing for ${account.handle}`); 393 + } 394 + const avatarResult = await fetchStatus(publicProfile.avatar); 320 395 if (!avatarResult.ok) { 321 396 throw new Error( 322 - `public avatar URL returned ${avatarResult.status} for ${account.handle}`, 397 + `public avatar URL returned ${String(avatarResult.status)} for ${account.handle}`, 323 398 ); 324 399 } 325 400 return {
+177 -61
src/browser/lib/dual-api.ts
··· 4 4 sleep, 5 5 } from "./runtime-utils.js"; 6 6 import { derivePdsHost } from "../../config.js"; 7 + import { 8 + getRecordArray, 9 + getRecordValue, 10 + getString, 11 + isRecord, 12 + } from "../../guards.js"; 7 13 import type { 8 14 AccountConfig, 9 15 FetchJsonResult, 10 - FetchStatusResult, 11 16 FlexibleRecord, 12 17 RepoRecord, 18 + RuntimeDualAccount, 19 + SessionInfo, 13 20 XrpcJsonOptions, 14 21 } from "../../types.js"; 15 22 16 - const asRecord = (value: unknown): FlexibleRecord | undefined => { 17 - if (typeof value !== "object" || value === null || Array.isArray(value)) { 18 - return undefined; 19 - } 20 - return value as FlexibleRecord; 21 - }; 22 - 23 23 export const createDualApiHelpers = ({ 24 24 config, 25 25 }: { 26 26 config: { pdsUrl: string }; 27 - }) => { 28 - const fetchJson = ( 29 - url: string, 30 - options: FlexibleRecord = {}, 31 - ): Promise<FetchJsonResult> => fetchJsonWithTimeout(url, options); 32 - 33 - const fetchStatus = ( 34 - url: string, 35 - options: FlexibleRecord = {}, 36 - ): Promise<FetchStatusResult> => fetchStatusWithTimeout(url, options); 27 + }): { 28 + fetchJson: typeof fetchJsonWithTimeout; 29 + fetchStatus: typeof fetchStatusWithTimeout; 30 + xrpcJson: ( 31 + nsid: string, 32 + options?: XrpcJsonOptions, 33 + ) => Promise<FetchJsonResult>; 34 + listOwnRecords: ( 35 + account: AccountConfig, 36 + collection: string, 37 + limit?: number, 38 + ) => Promise<RepoRecord[]>; 39 + deleteOwnRecord: ( 40 + account: AccountConfig, 41 + collection: string, 42 + record: RepoRecord | string, 43 + ) => Promise<{ rkey: string }>; 44 + purgeOwnRecords: ( 45 + account: AccountConfig, 46 + collection: string, 47 + predicate: (record: RepoRecord) => boolean, 48 + limit?: number, 49 + ) => Promise<number>; 50 + waitForOwnRecord: ( 51 + account: AccountConfig, 52 + collection: string, 53 + predicate: (record: RepoRecord) => boolean, 54 + timeoutMs?: number, 55 + ) => Promise<RepoRecord>; 56 + waitForOwnPostRecord: ( 57 + account: AccountConfig, 58 + text: string, 59 + timeoutMs?: number, 60 + ) => Promise<RepoRecord>; 61 + waitForFollowRecord: ( 62 + account: AccountConfig, 63 + subjectDid: string, 64 + timeoutMs?: number, 65 + ) => Promise<RepoRecord>; 66 + waitForNoOwnRecord: ( 67 + account: AccountConfig, 68 + collection: string, 69 + predicate: (record: RepoRecord) => boolean, 70 + timeoutMs?: number, 71 + ) => Promise<true>; 72 + waitForOwnListRecord: ( 73 + account: AccountConfig, 74 + name: string, 75 + timeoutMs?: number, 76 + ) => Promise<RepoRecord>; 77 + waitForOwnListItemRecord: ( 78 + account: AccountConfig, 79 + listUri: string, 80 + subjectDid: string, 81 + timeoutMs?: number, 82 + ) => Promise<RepoRecord>; 83 + recordRkey: (recordOrUri: RepoRecord | string) => string | undefined; 84 + createSession: (account: AccountConfig) => Promise<SessionInfo>; 85 + pollNotifications: (args: { 86 + account: AccountConfig; 87 + authorHandle: string; 88 + reasons: string[]; 89 + minIndexedAt: number; 90 + timeoutMs?: number; 91 + }) => Promise<{ 92 + notifications: FlexibleRecord[]; 93 + allNotifications: FlexibleRecord[]; 94 + }>; 95 + prepareAccounts: (args: { 96 + primaryConfig: AccountConfig; 97 + secondaryConfig: AccountConfig; 98 + startedAt: string; 99 + }) => { primary: RuntimeDualAccount; secondary: RuntimeDualAccount }; 100 + cleanupStaleSmokeArtifacts: (account: AccountConfig) => Promise<{ 101 + deletedPosts: number; 102 + deletedListItems: number; 103 + deletedLists: number; 104 + }>; 105 + } => { 106 + const fetchJson = fetchJsonWithTimeout; 107 + const fetchStatus = fetchStatusWithTimeout; 37 108 38 109 const collectionFromUri = (uri: string | undefined): string | undefined => { 39 110 // Example: at://did:plc:123/app.bsky.feed.post/3kabc -> app.bsky.feed.post ··· 45 116 }; 46 117 47 118 const normalizeRepoRecord = (record: RepoRecord): RepoRecord => { 48 - const recordValue = asRecord(record.value); 49 - const innerValue = asRecord(recordValue?.value); 119 + const recordValue = isRecord(record.value) ? record.value : undefined; 120 + const innerValue = isRecord(recordValue?.value) 121 + ? recordValue.value 122 + : undefined; 50 123 const innerType = 51 124 typeof innerValue?.$type === "string" ? innerValue.$type : undefined; 52 - const expectedCollection = collectionFromUri(record?.uri); 125 + const expectedCollection = collectionFromUri(record.uri); 53 126 if ( 54 127 recordValue !== undefined && 55 128 innerValue !== undefined && 56 129 recordValue.$type === undefined && 57 130 typeof innerType === "string" && 58 - (!expectedCollection || innerType === expectedCollection) 131 + (expectedCollection === undefined || innerType === expectedCollection) 59 132 ) { 60 133 return { 61 134 ...record, ··· 70 143 options: XrpcJsonOptions = {}, 71 144 ): Promise<FetchJsonResult> => { 72 145 const { method = "GET", token, params, body, timeoutMs, pdsUrl } = options; 73 - const basePdsUrl = pdsUrl || config.pdsUrl; 146 + const basePdsUrl = pdsUrl ?? config.pdsUrl; 74 147 const url = new URL(`${basePdsUrl}/xrpc/${nsid}`); 75 - if (params) { 148 + if (params !== undefined) { 76 149 for (const [key, value] of Object.entries(params)) { 77 150 url.searchParams.set(key, value); 78 151 } 79 152 } 80 153 const headers: Record<string, string> = { accept: "application/json" }; 81 - if (token) { 154 + if (token !== undefined && token.length > 0) { 82 155 headers.authorization = `Bearer ${token}`; 83 156 } 84 157 if (body !== undefined) { 85 158 headers["content-type"] = "application/json"; 86 159 } 87 - const run = (extraHeaders: Record<string, string> = {}) => 160 + const run = ( 161 + extraHeaders: Record<string, string> = {}, 162 + ): Promise<FetchJsonResult> => 88 163 fetchJson(url.toString(), { 89 164 method, 90 165 headers: { ··· 122 197 }); 123 198 if (!result.ok) { 124 199 throw new Error( 125 - `listRecords failed for ${account.handle} collection ${collection}: ${result.status} ${result.text}`, 200 + `listRecords failed for ${account.handle} collection ${collection}: ${String(result.status)} ${result.text}`, 126 201 ); 127 202 } 128 - const records = Array.isArray(asRecord(result.json)?.records) 129 - ? (asRecord(result.json)?.records as RepoRecord[]) 203 + const json = isRecord(result.json) ? result.json : undefined; 204 + const records = Array.isArray(json?.records) 205 + ? json.records.filter(isRecord).map((record): RepoRecord => record) 130 206 : []; 131 207 return records.map(normalizeRepoRecord); 132 208 }; 133 209 134 210 const recordRkey = (recordOrUri: RepoRecord | string): string | undefined => { 135 - const uri = 136 - typeof recordOrUri === "string" ? recordOrUri : recordOrUri?.uri; 211 + const uri = typeof recordOrUri === "string" ? recordOrUri : recordOrUri.uri; 137 212 return uri?.split("/").pop(); 138 213 }; 139 214 ··· 143 218 record: RepoRecord | string, 144 219 ): Promise<{ rkey: string }> => { 145 220 const rkey = recordRkey(record); 146 - if (!rkey) { 221 + if (rkey === undefined || rkey.length === 0) { 147 222 throw new Error( 148 223 `unable to determine rkey for ${collection} on ${account.handle}`, 149 224 ); ··· 160 235 }); 161 236 if (!result.ok) { 162 237 throw new Error( 163 - `deleteRecord failed for ${account.handle} ${collection} ${rkey}: ${result.status} ${result.text}`, 238 + `deleteRecord failed for ${account.handle} ${collection} ${rkey}: ${String(result.status)} ${result.text}`, 164 239 ); 165 240 } 166 241 return { rkey }; ··· 209 284 return waitForOwnRecord( 210 285 account, 211 286 "app.bsky.feed.post", 212 - (record) => record?.value?.text === text, 287 + (record) => record.value?.text === text, 213 288 timeoutMs, 214 289 ); 215 290 }; ··· 222 297 waitForOwnRecord( 223 298 account, 224 299 "app.bsky.graph.follow", 225 - (record) => record?.value?.subject === subjectDid, 300 + (record) => record.value?.subject === subjectDid, 226 301 timeoutMs, 227 302 ); 228 303 ··· 253 328 waitForOwnRecord( 254 329 account, 255 330 "app.bsky.graph.list", 256 - (record) => record?.value?.name === name, 331 + (record) => record.value?.name === name, 257 332 timeoutMs, 258 333 ); 259 334 ··· 267 342 account, 268 343 "app.bsky.graph.listitem", 269 344 (record) => 270 - record?.value?.list === listUri && 271 - record?.value?.subject === subjectDid, 345 + getString(record.value, "list") === listUri && 346 + getString(record.value, "subject") === subjectDid, 272 347 timeoutMs, 273 348 ); 274 349 275 350 const createSession = async ( 276 351 account: AccountConfig, 277 - ): Promise<FlexibleRecord> => { 278 - const identifier = account.loginIdentifier || account.handle; 352 + ): Promise<SessionInfo> => { 353 + const identifier = account.loginIdentifier ?? account.handle; 279 354 const result = await xrpcJson("com.atproto.server.createSession", { 280 355 method: "POST", 281 356 pdsUrl: account.pdsUrl, ··· 286 361 }); 287 362 if (!result.ok) { 288 363 throw new Error( 289 - `createSession failed for ${identifier}: ${result.status} ${result.text}`, 364 + `createSession failed for ${identifier}: ${String(result.status)} ${result.text}`, 290 365 ); 291 366 } 292 - return (result.json ?? {}) as FlexibleRecord; 367 + const session = isRecord(result.json) ? result.json : undefined; 368 + const accessJwt = getString(session, "accessJwt"); 369 + const did = getString(session, "did"); 370 + if (accessJwt === undefined || did === undefined) { 371 + throw new Error( 372 + `createSession returned an incomplete session for ${identifier}`, 373 + ); 374 + } 375 + return { 376 + ...session, 377 + accessJwt, 378 + did, 379 + }; 293 380 }; 294 381 295 382 const pollNotifications = async ({ ··· 309 396 allNotifications: FlexibleRecord[]; 310 397 }> => { 311 398 const started = Date.now(); 312 - let last; 399 + let last: FetchJsonResult | undefined; 313 400 while (Date.now() - started < timeoutMs) { 314 401 last = await xrpcJson("app.bsky.notification.listNotifications", { 315 402 token: account.accessJwt, ··· 317 404 params: { limit: "100" }, 318 405 timeoutMs: 15000, 319 406 }); 320 - if (last.ok && Array.isArray(last.json?.notifications)) { 321 - const matching = last.json.notifications.filter((item) => { 322 - if (item?.author?.handle !== authorHandle) { 407 + const lastJson = isRecord(last.json) ? last.json : undefined; 408 + const notifications = getRecordArray(lastJson, "notifications"); 409 + if (last.ok && notifications.length > 0) { 410 + const matching = notifications.filter((item) => { 411 + const author = getRecordValue(item, "author"); 412 + if (getString(author, "handle") !== authorHandle) { 323 413 return false; 324 414 } 325 - const indexedAt = Date.parse( 326 - item?.indexedAt || item?.record?.createdAt || 0, 327 - ); 415 + const record = getRecordValue(item, "record"); 416 + const indexedAtText = 417 + getString(item, "indexedAt") ?? 418 + getString(record, "createdAt") ?? 419 + "0"; 420 + const indexedAt = Date.parse(indexedAtText); 328 421 if (Number.isFinite(minIndexedAt) && indexedAt < minIndexedAt) { 329 422 return false; 330 423 } 331 - return reasons.includes(item?.reason); 424 + const reason = getString(item, "reason"); 425 + return reason !== undefined && reasons.includes(reason); 332 426 }); 333 - const seenReasons = new Set(matching.map((item) => item.reason)); 427 + const seenReasons = new Set( 428 + matching 429 + .map((item) => getString(item, "reason")) 430 + .filter((reason): reason is string => reason !== undefined), 431 + ); 334 432 if (reasons.every((reason) => seenReasons.has(reason))) { 335 433 return { 336 434 notifications: matching, 337 - allNotifications: last.json.notifications.slice(0, 12), 435 + allNotifications: notifications.slice(0, 12), 338 436 }; 339 437 } 340 438 } 341 439 await sleep(5000); 342 440 } 343 441 throw new Error( 344 - `notifications not observed for ${account.handle} within ${timeoutMs}ms; last status=${last?.status ?? "none"} body=${last?.text ?? ""}`, 442 + `notifications not observed for ${account.handle} within ${String(timeoutMs)}ms; last status=${String(last?.status ?? "none")} body=${last?.text ?? ""}`, 345 443 ); 346 444 }; 347 445 348 - const accountFromConfig = (entry: AccountConfig): AccountConfig => ({ 446 + const normalizeSession = (entry: AccountConfig): SessionInfo => { 447 + const accessJwt = 448 + getString(entry.session, "accessJwt") ?? entry.accessJwt ?? ""; 449 + const did = getString(entry.session, "did") ?? entry.did ?? ""; 450 + return { 451 + ...(isRecord(entry.session) ? entry.session : {}), 452 + accessJwt, 453 + did, 454 + }; 455 + }; 456 + 457 + const accountFromConfig = (entry: AccountConfig): RuntimeDualAccount => ({ 349 458 ...entry, 350 459 pdsUrl: entry.pdsUrl ?? config.pdsUrl, 351 460 pdsHost: entry.pdsHost ?? derivePdsHost(entry.pdsUrl ?? config.pdsUrl), 352 461 loginIdentifier: entry.loginIdentifier ?? entry.handle, 353 - mediaPostText: entry.mediaPostText ?? `${entry.postText} image`, 462 + mediaPostText: entry.mediaPostText, 354 463 shortHandle: entry.handle.replace(/^@/, ""), 464 + did: entry.did ?? "", 465 + accessJwt: entry.accessJwt ?? "", 466 + listName: entry.listName ?? "", 467 + listDescription: entry.listDescription ?? "", 468 + listUpdatedName: entry.listUpdatedName ?? "", 469 + listUpdatedDescription: entry.listUpdatedDescription ?? "", 470 + session: normalizeSession(entry), 355 471 }); 356 472 357 473 const prepareAccounts = ({ ··· 362 478 primaryConfig: AccountConfig; 363 479 secondaryConfig: AccountConfig; 364 480 startedAt: string; 365 - }): { primary: AccountConfig; secondary: AccountConfig } => { 481 + }): { primary: RuntimeDualAccount; secondary: RuntimeDualAccount } => { 366 482 const runToken = startedAt.replace(/\D/g, "").slice(0, 14); 367 483 const primary = accountFromConfig({ 368 484 ...primaryConfig, 369 - listName: primaryConfig.listName || `Smoke List ${runToken}`, 485 + listName: primaryConfig.listName ?? `Smoke List ${runToken}`, 370 486 listDescription: 371 - primaryConfig.listDescription || `smoke list description ${runToken}`, 487 + primaryConfig.listDescription ?? `smoke list description ${runToken}`, 372 488 listUpdatedName: 373 - primaryConfig.listUpdatedName || `Updated Smoke List ${runToken}`, 489 + primaryConfig.listUpdatedName ?? `Updated Smoke List ${runToken}`, 374 490 listUpdatedDescription: 375 - primaryConfig.listUpdatedDescription || 491 + primaryConfig.listUpdatedDescription ?? 376 492 `updated smoke list description ${runToken}`, 377 493 }); 378 494 const secondary = accountFromConfig(secondaryConfig); ··· 382 498 const stalePostPrefixesFor = (account: AccountConfig): string[] => { 383 499 if ( 384 500 Array.isArray(account.cleanupPostPrefixes) && 385 - account.cleanupPostPrefixes.length 501 + account.cleanupPostPrefixes.length > 0 386 502 ) { 387 503 return account.cleanupPostPrefixes; 388 504 }
+22 -6
src/browser/lib/dual-browser.ts
··· 17 17 isIgnoredRequestFailureEntry, 18 18 } from "./failure-rules.js"; 19 19 import type { Summary } from "../../types.js"; 20 + import type { 21 + DualStepHelpers, 22 + SetupDualBrowserResult, 23 + } from "./browser-types.js"; 20 24 21 - export const setupDualBrowser = async ({ config, summary }) => { 25 + export const setupDualBrowser = async ({ 26 + config, 27 + summary, 28 + }: { 29 + config: { browserExecutablePath?: string; headless?: boolean }; 30 + summary: Summary; 31 + }): Promise<SetupDualBrowserResult> => { 22 32 const browser = await launchBrowserWithFallback({ 23 33 chromium, 24 34 config, ··· 48 58 49 59 return { 50 60 browser, 51 - primaryContext, 52 - secondaryContext, 53 61 primaryPage, 54 62 secondaryPage, 55 63 }; ··· 65 73 summary: Summary; 66 74 primaryPage: Page; 67 75 secondaryPage: Page; 68 - }) => { 69 - const stepTimeoutMs = Number(config.stepTimeoutMs || 120000); 76 + }): DualStepHelpers => { 77 + const stepTimeoutMs = config.stepTimeoutMs ?? 120000; 70 78 const progressEnabled = config.progress !== false; 71 79 const pageFor = (name: string): Page => 72 80 name === "primary" ? primaryPage : secondaryPage; ··· 123 131 isIgnoredConsole, 124 132 isIgnoredRequestFailure, 125 133 isIgnoredHttpFailure, 126 - }) => { 134 + }: { 135 + summary: Summary; 136 + config: { strictErrors: boolean }; 137 + screenshot: DualStepHelpers["screenshot"]; 138 + browser: SetupDualBrowserResult["browser"]; 139 + isIgnoredConsole: DualStepHelpers["isIgnoredConsole"]; 140 + isIgnoredRequestFailure: DualStepHelpers["isIgnoredRequestFailure"]; 141 + isIgnoredHttpFailure: DualStepHelpers["isIgnoredHttpFailure"]; 142 + }): Promise<Summary> => { 127 143 finalizeSummary({ 128 144 summary, 129 145 strictErrors: config.strictErrors,
+4 -1
src/browser/lib/dual-scenario.ts
··· 1 + import type { DualScenarioContext } from "./browser-types.js"; 1 2 import { 2 3 runDualCleanupPhase, 3 4 runDualPrimaryWavePhase, ··· 5 6 runDualSetupPhase, 6 7 } from "./dual-scenario/phases.js"; 7 8 8 - export const runDualScenario = async (ctx) => { 9 + export const runDualScenario = async ( 10 + ctx: DualScenarioContext, 11 + ): Promise<void> => { 9 12 await runDualSetupPhase(ctx); 10 13 await runDualPrimaryWavePhase(ctx); 11 14 await runDualSecondaryWaveAndSettingsPhase(ctx);
+111 -71
src/browser/lib/dual-scenario/phases.ts
··· 1 - const remoteReplyHandleFromUrl = (postUrl) => { 1 + import type { DualScenarioContext } from "../browser-types.js"; 2 + import { getRecordValue, getString, isRecord } from "../../../guards.js"; 3 + 4 + const remoteReplyHandleFromUrl = ( 5 + postUrl: string | undefined, 6 + ): string | undefined => { 2 7 const match = postUrl?.match(/\/profile\/([^/]+)\/post\//); 3 8 return match ? decodeURIComponent(match[1]) : undefined; 4 9 }; 5 10 6 - export const runDualSetupPhase = async (ctx) => { 11 + export const runDualSetupPhase = async ( 12 + ctx: DualScenarioContext, 13 + ): Promise<void> => { 7 14 const { 8 15 config, 9 16 step, ··· 80 87 async () => { 81 88 await gotoProfile(primaryPage, secondary.handle); 82 89 await waitForProfileHandle(primaryPage, secondary.handle); 83 - return maybeUnfollow(primaryPage); 90 + return await maybeUnfollow(primaryPage); 84 91 }, 85 92 { optional: true, pageNames: ["primary"] }, 86 93 ); ··· 90 97 async () => { 91 98 await gotoProfile(secondaryPage, primary.handle); 92 99 await waitForProfileHandle(secondaryPage, primary.handle); 93 - return maybeUnfollow(secondaryPage); 100 + return await maybeUnfollow(secondaryPage); 94 101 }, 95 102 { optional: true, pageNames: ["secondary"] }, 96 103 ); ··· 154 161 primary, 155 162 primary.mediaPostText, 156 163 ); 157 - const embed = primary.imagePost.value?.embed; 164 + const embed = getRecordValue(primary.imagePost.value, "embed"); 165 + const images = Array.isArray(embed?.images) 166 + ? embed.images.filter(isRecord) 167 + : []; 158 168 if ( 159 - embed?.$type !== "app.bsky.embed.images" || 160 - !Array.isArray(embed.images) || 161 - embed.images.length < 1 169 + getString(embed, "$type") !== "app.bsky.embed.images" || 170 + images.length < 1 162 171 ) { 163 172 throw new Error( 164 173 "image post did not persist an app.bsky.embed.images record", 165 174 ); 166 175 } 176 + const firstImage = getRecordValue(images[0], "image"); 167 177 return { 168 178 uri: primary.imagePost.uri, 169 - imageCount: embed.images.length, 170 - mimeType: embed.images[0]?.image?.mimeType, 179 + imageCount: images.length, 180 + mimeType: getString(firstImage, "mimeType"), 171 181 }; 172 182 }); 173 183 ··· 231 241 "primary-public-profile-after-edit", 232 242 () => verifyPublicProfileAfterEdit(primary), 233 243 { 234 - timeoutMs: Math.max( 235 - Number(config.publicCheckTimeoutMs || 180000) + 15000, 236 - 195000, 237 - ), 244 + timeoutMs: Math.max(config.publicCheckTimeoutMs + 15000, 195000), 238 245 }, 239 246 ); 240 247 ··· 253 260 "secondary-public-profile-after-edit", 254 261 () => verifyPublicProfileAfterEdit(secondary), 255 262 { 256 - timeoutMs: Math.max( 257 - Number(config.publicCheckTimeoutMs || 180000) + 15000, 258 - 195000, 259 - ), 263 + timeoutMs: Math.max(config.publicCheckTimeoutMs + 15000, 195000), 260 264 }, 261 265 ); 262 266 ··· 297 301 await step("primary-list-record", async () => { 298 302 primary.listRecord = await waitForOwnListRecord(primary, primary.listName); 299 303 primary.listRkey = recordRkey(primary.listRecord); 304 + if (primary.listRkey === undefined) { 305 + throw new Error("list record rkey missing after create"); 306 + } 300 307 if (primary.listRecord.value?.description !== primary.listDescription) { 301 308 throw new Error("list record description did not match after create"); 302 309 } 303 310 return { 304 311 uri: primary.listRecord.uri, 305 312 rkey: primary.listRkey, 306 - description: primary.listRecord.value?.description, 313 + description: getString(primary.listRecord.value, "description"), 307 314 }; 308 315 }); 309 316 310 317 await step( 311 318 "primary-edit-list", 312 319 async () => { 320 + if (primary.listRkey === undefined) { 321 + throw new Error("list rkey missing before list edit"); 322 + } 313 323 await openListPage(primaryPage, primary.handle, primary.listRkey); 314 - return editCurrentList( 324 + return await editCurrentList( 315 325 primaryPage, 316 326 primary.listUpdatedName, 317 327 primary.listUpdatedDescription, ··· 326 336 primary.listUpdatedName, 327 337 ); 328 338 primary.listRkey = recordRkey(primary.listRecord); 339 + if (primary.listRkey === undefined) { 340 + throw new Error("list record rkey missing after edit"); 341 + } 329 342 if ( 330 343 primary.listRecord.value?.description !== primary.listUpdatedDescription 331 344 ) { ··· 334 347 return { 335 348 uri: primary.listRecord.uri, 336 349 rkey: primary.listRkey, 337 - description: primary.listRecord.value?.description, 350 + description: getString(primary.listRecord.value, "description"), 338 351 }; 339 352 }); 340 353 341 354 await step( 342 355 "primary-list-add-secondary-member", 343 356 async () => { 357 + if (primary.listRkey === undefined) { 358 + throw new Error("list rkey missing before list member add"); 359 + } 344 360 await openListPage(primaryPage, primary.handle, primary.listRkey); 345 - return addUserToCurrentList(primaryPage, secondary.handle); 361 + return await addUserToCurrentList(primaryPage, secondary.handle); 346 362 }, 347 363 { pageNames: ["primary"] }, 348 364 ); 349 365 350 366 await step("primary-list-member-record", async () => { 367 + if (primary.listRecord === undefined) { 368 + throw new Error("list record missing before list member add"); 369 + } 370 + const listUri = primary.listRecord.uri; 371 + if (listUri === undefined) { 372 + throw new Error("list record uri missing before list member add"); 373 + } 351 374 primary.listItemRecord = await waitForOwnListItemRecord( 352 375 primary, 353 - primary.listRecord.uri, 376 + listUri, 354 377 secondary.did, 355 378 ); 356 379 return { ··· 362 385 await step( 363 386 "primary-list-remove-secondary-member", 364 387 async () => { 388 + if (primary.listRkey === undefined) { 389 + throw new Error("list rkey missing before list member remove"); 390 + } 365 391 await openListPage(primaryPage, primary.handle, primary.listRkey); 366 - return removeUserFromCurrentList(primaryPage, secondary.handle); 392 + return await removeUserFromCurrentList(primaryPage, secondary.handle); 367 393 }, 368 394 { pageNames: ["primary"] }, 369 395 ); 370 396 371 397 await step("primary-list-member-record-removed", async () => { 398 + if (primary.listRecord === undefined) { 399 + throw new Error("list record missing before list member cleanup"); 400 + } 401 + const listUri = primary.listRecord.uri; 402 + if (listUri === undefined) { 403 + throw new Error("list record uri missing before list member cleanup"); 404 + } 372 405 await waitForNoOwnRecord( 373 406 primary, 374 407 "app.bsky.graph.listitem", 375 408 (record) => 376 - record?.value?.list === primary.listRecord.uri && 377 - record?.value?.subject === secondary.did, 409 + record.value?.list === listUri && 410 + getString(record.value, "subject") === secondary.did, 378 411 ); 379 - return { listUri: primary.listRecord.uri, subject: secondary.did }; 412 + return { listUri, subject: secondary.did }; 380 413 }); 381 414 382 415 await step( 383 416 "primary-delete-list", 384 417 async () => { 418 + if (primary.listRkey === undefined) { 419 + throw new Error("list rkey missing before delete"); 420 + } 385 421 await openListPage(primaryPage, primary.handle, primary.listRkey); 386 - return deleteCurrentList(primaryPage); 422 + return await deleteCurrentList(primaryPage); 387 423 }, 388 424 { pageNames: ["primary"] }, 389 425 ); ··· 398 434 }); 399 435 }; 400 436 401 - export const runDualPrimaryWavePhase = async (ctx) => { 437 + export const runDualPrimaryWavePhase = async ( 438 + ctx: DualScenarioContext, 439 + ): Promise<void> => { 402 440 const { 403 441 config, 404 442 step, ··· 461 499 primary, 462 500 primary.handle, 463 501 { 464 - followsCount: (primary.baselineCounts?.api?.followsCount ?? 0) + 1, 502 + followsCount: (primary.baselineCounts?.api.followsCount ?? 0) + 1, 465 503 }, 466 504 ); 467 505 }, ··· 477 515 secondary.handle, 478 516 { 479 517 followersCount: 480 - (secondary.baselineCounts?.api?.followersCount ?? 0) + 1, 518 + (secondary.baselineCounts?.api.followersCount ?? 0) + 1, 481 519 }, 482 520 ); 483 521 }, ··· 493 531 secondary.postText, 494 532 60000, 495 533 ); 496 - return ensureLiked(primaryPage, row); 534 + return await ensureLiked(primaryPage, row); 497 535 }, 498 536 { pageNames: ["primary"] }, 499 537 ); ··· 507 545 secondary.postText, 508 546 60000, 509 547 ); 510 - return ensureBookmarked(primaryPage, row); 548 + return await ensureBookmarked(primaryPage, row); 511 549 }, 512 550 { pageNames: ["primary"] }, 513 551 ); ··· 537 575 secondary.postText, 538 576 60000, 539 577 ); 540 - return ensureReposted(primaryPage, row); 578 + return await ensureReposted(primaryPage, row); 541 579 }, 542 580 { pageNames: ["primary"] }, 543 581 ); ··· 580 618 { pageNames: ["primary"] }, 581 619 ); 582 620 583 - if (config.remoteReplyPostUrl) { 621 + if (config.remoteReplyPostUrl !== undefined) { 584 622 const remoteReplyText = `${primary.replyText} remote`; 585 - const remoteReplyHandle = remoteReplyHandleFromUrl( 586 - config.remoteReplyPostUrl, 587 - ); 623 + const remoteReplyPostUrl = config.remoteReplyPostUrl; 624 + const remoteReplyHandle = remoteReplyHandleFromUrl(remoteReplyPostUrl); 588 625 589 - if (remoteReplyHandle) { 626 + if (remoteReplyHandle !== undefined) { 590 627 await step( 591 628 "primary-prepare-configured-remote-reply-target", 592 629 async () => { ··· 613 650 await step( 614 651 "primary-reply-configured-remote-post", 615 652 async () => { 616 - await primaryPage.goto(config.remoteReplyPostUrl, { 653 + await primaryPage.goto(remoteReplyPostUrl, { 617 654 waitUntil: "domcontentloaded", 618 655 timeout: 60000, 619 656 }); ··· 629 666 return { 630 667 replyText: remoteReplyText, 631 668 uri: primary.remoteReplyPost.uri, 632 - remoteReplyPostUrl: config.remoteReplyPostUrl, 669 + remoteReplyPostUrl, 633 670 remoteReplyHandle, 634 671 }; 635 672 }, ··· 665 702 ); 666 703 }; 667 704 668 - export const runDualSecondaryWaveAndSettingsPhase = async (ctx) => { 705 + export const runDualSecondaryWaveAndSettingsPhase = async ( 706 + ctx: DualScenarioContext, 707 + ): Promise<void> => { 669 708 const { 670 709 step, 671 710 primaryPage, ··· 728 767 secondary.handle, 729 768 { 730 769 followersCount: 731 - (secondary.baselineCounts?.api?.followersCount ?? 0) + 1, 732 - followsCount: (secondary.baselineCounts?.api?.followsCount ?? 0) + 1, 770 + (secondary.baselineCounts?.api.followersCount ?? 0) + 1, 771 + followsCount: (secondary.baselineCounts?.api.followsCount ?? 0) + 1, 733 772 }, 734 773 ); 735 774 }, ··· 744 783 primary, 745 784 primary.handle, 746 785 { 747 - followersCount: 748 - (primary.baselineCounts?.api?.followersCount ?? 0) + 1, 749 - followsCount: (primary.baselineCounts?.api?.followsCount ?? 0) + 1, 786 + followersCount: (primary.baselineCounts?.api.followersCount ?? 0) + 1, 787 + followsCount: (primary.baselineCounts?.api.followsCount ?? 0) + 1, 750 788 }, 751 789 ); 752 790 }, ··· 789 827 "primary-mute-secondary", 790 828 async () => { 791 829 await gotoProfile(primaryPage, secondary.handle); 792 - return ensureProfileMuted(primaryPage); 830 + return await ensureProfileMuted(primaryPage); 793 831 }, 794 832 { pageNames: ["primary"] }, 795 833 ); ··· 798 836 "primary-unmute-secondary", 799 837 async () => { 800 838 await gotoProfile(primaryPage, secondary.handle); 801 - return ensureProfileUnmuted(primaryPage); 839 + return await ensureProfileUnmuted(primaryPage); 802 840 }, 803 841 { pageNames: ["primary"] }, 804 842 ); ··· 812 850 primary.postText, 813 851 60000, 814 852 ); 815 - return openReportPostDraft(secondaryPage, row); 853 + return await openReportPostDraft(secondaryPage, row); 816 854 }, 817 855 { pageNames: ["secondary"] }, 818 856 ); ··· 821 859 "secondary-block-primary", 822 860 async () => { 823 861 await gotoProfile(secondaryPage, primary.handle); 824 - return blockProfile(secondaryPage); 862 + return await blockProfile(secondaryPage); 825 863 }, 826 864 { pageNames: ["secondary"] }, 827 865 ); ··· 1013 1051 ); 1014 1052 }; 1015 1053 1016 - export const runDualCleanupPhase = async (ctx) => { 1054 + export const runDualCleanupPhase = async ( 1055 + ctx: DualScenarioContext, 1056 + ): Promise<void> => { 1017 1057 const { 1018 1058 config, 1019 1059 step, ··· 1042 1082 secondary.postText, 1043 1083 60000, 1044 1084 ); 1045 - return ensureNotLiked(primaryPage, row); 1085 + return await ensureNotLiked(primaryPage, row); 1046 1086 }, 1047 1087 { optional: true, pageNames: ["primary"] }, 1048 1088 ); ··· 1056 1096 secondary.postText, 1057 1097 60000, 1058 1098 ); 1059 - return ensureNotBookmarked(primaryPage, row); 1099 + return await ensureNotBookmarked(primaryPage, row); 1060 1100 }, 1061 1101 { optional: true, pageNames: ["primary"] }, 1062 1102 ); ··· 1070 1110 secondary.postText, 1071 1111 60000, 1072 1112 ); 1073 - return ensureNotReposted(primaryPage, row); 1113 + return await ensureNotReposted(primaryPage, row); 1074 1114 }, 1075 1115 { optional: true, pageNames: ["primary"] }, 1076 1116 ); ··· 1079 1119 "primary-cleanup-unfollow-secondary", 1080 1120 async () => { 1081 1121 await gotoProfile(primaryPage, secondary.handle); 1082 - return maybeUnfollow(primaryPage); 1122 + return await maybeUnfollow(primaryPage); 1083 1123 }, 1084 1124 { optional: true, pageNames: ["primary"] }, 1085 1125 ); ··· 1088 1128 "secondary-cleanup-unfollow-primary", 1089 1129 async () => { 1090 1130 await gotoProfile(secondaryPage, primary.handle); 1091 - return maybeUnfollow(secondaryPage); 1131 + return await maybeUnfollow(secondaryPage); 1092 1132 }, 1093 1133 { optional: true, pageNames: ["secondary"] }, 1094 1134 ); 1095 1135 1096 1136 if ( 1097 - config.remoteReplyPostUrl && 1137 + config.remoteReplyPostUrl !== undefined && 1098 1138 primary.remoteReplyWasFollowingTarget === false 1099 1139 ) { 1100 1140 const remoteReplyHandle = remoteReplyHandleFromUrl( 1101 1141 config.remoteReplyPostUrl, 1102 1142 ); 1103 - if (remoteReplyHandle) { 1143 + if (remoteReplyHandle !== undefined) { 1104 1144 await step( 1105 1145 "primary-cleanup-remote-reply-target-follow", 1106 1146 async () => { 1107 1147 await gotoProfile(primaryPage, remoteReplyHandle); 1108 1148 await waitForProfileHandle(primaryPage, remoteReplyHandle); 1109 - return maybeUnfollow(primaryPage); 1149 + return await maybeUnfollow(primaryPage); 1110 1150 }, 1111 1151 { optional: true, pageNames: ["primary"] }, 1112 1152 ); ··· 1121 1161 primary, 1122 1162 primary.handle, 1123 1163 { 1124 - followersCount: primary.baselineCounts?.api?.followersCount ?? 0, 1125 - followsCount: primary.baselineCounts?.api?.followsCount ?? 0, 1164 + followersCount: primary.baselineCounts?.api.followersCount ?? 0, 1165 + followsCount: primary.baselineCounts?.api.followsCount ?? 0, 1126 1166 }, 1127 1167 ); 1128 1168 }, ··· 1137 1177 secondary, 1138 1178 secondary.handle, 1139 1179 { 1140 - followersCount: secondary.baselineCounts?.api?.followersCount ?? 0, 1141 - followsCount: secondary.baselineCounts?.api?.followsCount ?? 0, 1180 + followersCount: secondary.baselineCounts?.api.followersCount ?? 0, 1181 + followsCount: secondary.baselineCounts?.api.followsCount ?? 0, 1142 1182 }, 1143 1183 ); 1144 1184 }, ··· 1150 1190 async () => { 1151 1191 await gotoProfile(primaryPage, primary.handle); 1152 1192 await openProfileTab(primaryPage, "Posts"); 1153 - return maybeDeleteOwnPostByText( 1193 + return await maybeDeleteOwnPostByText( 1154 1194 primaryPage, 1155 1195 primary.quoteText, 1156 1196 "deleted quote post", ··· 1164 1204 async () => { 1165 1205 await gotoProfile(primaryPage, primary.handle); 1166 1206 await openProfileTab(primaryPage, "Posts"); 1167 - return maybeDeleteOwnPostByText( 1207 + return await maybeDeleteOwnPostByText( 1168 1208 primaryPage, 1169 1209 primary.mediaPostText, 1170 1210 "deleted image post", ··· 1178 1218 async () => { 1179 1219 await gotoProfile(primaryPage, primary.handle); 1180 1220 await openProfileTab(primaryPage, "Replies"); 1181 - return maybeDeleteOwnPostByText( 1221 + return await maybeDeleteOwnPostByText( 1182 1222 primaryPage, 1183 1223 primary.replyText, 1184 1224 "deleted reply post", ··· 1187 1227 { optional: true, pageNames: ["primary"] }, 1188 1228 ); 1189 1229 1190 - if (config.remoteReplyPostUrl) { 1230 + if (config.remoteReplyPostUrl !== undefined) { 1191 1231 await step( 1192 1232 "primary-cleanup-delete-remote-reply", 1193 1233 async () => { 1194 1234 await gotoProfile(primaryPage, primary.handle); 1195 1235 await openProfileTab(primaryPage, "Replies"); 1196 - return maybeDeleteOwnPostByText( 1236 + return await maybeDeleteOwnPostByText( 1197 1237 primaryPage, 1198 1238 `${primary.replyText} remote`, 1199 1239 "deleted remote reply post", ··· 1208 1248 async () => { 1209 1249 await gotoProfile(secondaryPage, secondary.handle); 1210 1250 await openProfileTab(secondaryPage, "Posts"); 1211 - return maybeDeleteOwnPostByText( 1251 + return await maybeDeleteOwnPostByText( 1212 1252 secondaryPage, 1213 1253 secondary.postText, 1214 1254 "deleted root post", ··· 1222 1262 async () => { 1223 1263 await gotoProfile(primaryPage, primary.handle); 1224 1264 await openProfileTab(primaryPage, "Posts"); 1225 - return maybeDeleteOwnPostByText( 1265 + return await maybeDeleteOwnPostByText( 1226 1266 primaryPage, 1227 1267 primary.postText, 1228 1268 "deleted root post",
+11 -3
src/browser/lib/failure-rules.ts
··· 1 + import type { 2 + ConsoleEntry, 3 + HttpFailureEntry, 4 + RequestFailureEntry, 5 + } from "../../types.js"; 6 + 1 7 export const IGNORED_CONSOLE = [ 2 8 /events\.bsky\.app\/.*ERR_BLOCKED_BY_CLIENT/i, 3 9 /slider-vertical/i, ··· 55 61 { url: /\/xrpc\/app\.bsky\.feed\.getAuthorFeed\?/, status: 400 }, 56 62 ]; 57 63 58 - export const isIgnoredConsoleEntry = (entry) => 64 + export const isIgnoredConsoleEntry = (entry: ConsoleEntry): boolean => 59 65 IGNORED_CONSOLE.some((pattern) => pattern.test(entry.text || "")); 60 66 61 - export const isIgnoredRequestFailureEntry = (entry) => 67 + export const isIgnoredRequestFailureEntry = ( 68 + entry: RequestFailureEntry, 69 + ): boolean => 62 70 IGNORED_REQUEST_FAILURE.some( 63 71 (rule) => 64 72 rule.url.test(entry.url || "") && rule.error.test(entry.errorText || ""), 65 73 ); 66 74 67 - export const isIgnoredHttpFailureEntry = (entry) => 75 + export const isIgnoredHttpFailureEntry = (entry: HttpFailureEntry): boolean => 68 76 IGNORED_HTTP_FAILURE.some( 69 77 (rule) => 70 78 rule.url.test(entry.url || "") &&
+74 -16
src/browser/lib/lists.ts
··· 1 - export const createListHelpers = ({ appBaseUrl, wait }) => { 2 - const waitForListPageReady = async (page, timeout = 30000) => { 1 + import type { Locator, Page } from "playwright"; 2 + import type { FlexibleRecord } from "../../types.js"; 3 + import type { PageWait } from "./browser-types.js"; 4 + 5 + interface ListHelpers { 6 + openListPage: (page: Page, handle: string, listRkey: string) => Promise<void>; 7 + createList: ( 8 + page: Page, 9 + name: string, 10 + description: string, 11 + ) => Promise<FlexibleRecord>; 12 + editCurrentList: ( 13 + page: Page, 14 + name: string, 15 + description: string, 16 + ) => Promise<FlexibleRecord>; 17 + deleteCurrentList: (page: Page) => Promise<FlexibleRecord>; 18 + addUserToCurrentList: (page: Page, handle: string) => Promise<FlexibleRecord>; 19 + removeUserFromCurrentList: ( 20 + page: Page, 21 + handle: string, 22 + ) => Promise<FlexibleRecord>; 23 + } 24 + 25 + export const createListHelpers = ({ 26 + appBaseUrl, 27 + wait, 28 + }: { 29 + appBaseUrl: string; 30 + wait: PageWait; 31 + }): ListHelpers => { 32 + const waitForListPageReady = async ( 33 + page: Page, 34 + timeout = 30000, 35 + ): Promise<void> => { 3 36 const started = Date.now(); 4 37 while (Date.now() - started < timeout) { 5 38 const moreOptions = page.getByTestId("moreOptionsBtn").first(); ··· 15 48 throw new Error("list page did not become interactive"); 16 49 }; 17 50 18 - const openLists = async (page) => { 51 + const openLists = async (page: Page): Promise<void> => { 19 52 await page.goto(`${appBaseUrl}/lists`, { 20 53 waitUntil: "domcontentloaded", 21 54 timeout: 60000, ··· 27 60 } 28 61 }; 29 62 30 - const openListPage = async (page, handle, listRkey) => { 63 + const openListPage = async ( 64 + page: Page, 65 + handle: string, 66 + listRkey: string, 67 + ): Promise<void> => { 31 68 await page.goto( 32 69 `${appBaseUrl}/profile/${encodeURIComponent(handle)}/lists/${encodeURIComponent(listRkey)}`, 33 70 { ··· 39 76 await waitForListPageReady(page); 40 77 }; 41 78 42 - const fillListEditor = async (page, name, description) => { 79 + const fillListEditor = async ( 80 + page: Page, 81 + name: string, 82 + description: string, 83 + ): Promise<Locator> => { 43 84 const dialog = page.locator('[role="dialog"]').last(); 44 85 await dialog.waitFor({ state: "visible", timeout: 15000 }); 45 86 await dialog.getByTestId("editListNameInput").fill(name); ··· 47 88 return dialog; 48 89 }; 49 90 50 - const saveListEditor = async (page) => { 91 + const saveListEditor = async (page: Page): Promise<void> => { 51 92 const dialog = page.locator('[role="dialog"]').last(); 52 93 const save = dialog.getByTestId("editProfileSaveBtn").last(); 53 94 await save.waitFor({ state: "visible", timeout: 15000 }); ··· 72 113 await wait(page, 3000); 73 114 }; 74 115 75 - const createList = async (page, name, description) => { 116 + const createList = async ( 117 + page: Page, 118 + name: string, 119 + description: string, 120 + ): Promise<FlexibleRecord> => { 76 121 await openLists(page); 77 122 await page 78 123 .getByTestId("newUserListBtn") ··· 85 130 return { url: page.url() }; 86 131 }; 87 132 88 - const openCurrentListOptions = async (page) => { 133 + const openCurrentListOptions = async (page: Page): Promise<Locator> => { 89 134 const btn = page.getByTestId("moreOptionsBtn").first(); 90 135 await btn.waitFor({ state: "visible", timeout: 15000 }); 91 136 await btn.click({ noWaitAfter: true }); ··· 94 139 return menu; 95 140 }; 96 141 97 - const editCurrentList = async (page, name, description) => { 142 + const editCurrentList = async ( 143 + page: Page, 144 + name: string, 145 + description: string, 146 + ): Promise<FlexibleRecord> => { 98 147 await openCurrentListOptions(page); 99 148 await page 100 149 .getByRole("menuitem", { name: /edit list details/i }) ··· 106 155 return { listName: name, listDescription: description }; 107 156 }; 108 157 109 - const deleteCurrentList = async (page) => { 158 + const deleteCurrentList = async (page: Page): Promise<FlexibleRecord> => { 110 159 const beforeUrl = page.url(); 111 160 await openCurrentListOptions(page); 112 161 await page ··· 128 177 return { url: page.url() }; 129 178 }; 130 179 131 - const openListPeopleTab = async (page) => { 180 + const openListPeopleTab = async (page: Page): Promise<void> => { 132 181 await page 133 182 .getByRole("tab", { name: /^People$/i }) 134 183 .click({ noWaitAfter: true }); 135 184 await wait(page, 1500); 136 185 }; 137 186 138 - const openAddPeopleToList = async (page) => { 187 + const openAddPeopleToList = async (page: Page): Promise<void> => { 139 188 await openListPeopleTab(page); 140 189 const add = page 141 190 .getByRole("button", { name: /start adding people|add people/i }) ··· 149 198 await wait(page, 1000); 150 199 }; 151 200 152 - const closeAddPeopleToList = async (page) => { 201 + const closeAddPeopleToList = async (page: Page): Promise<void> => { 153 202 const close = page.getByRole("button", { name: /^close$/i }).last(); 154 203 if (await close.count()) { 155 204 await close.click({ noWaitAfter: true }).catch(() => undefined); ··· 159 208 await wait(page, 1000); 160 209 }; 161 210 162 - const searchAddPeopleList = async (page, handle) => { 211 + const searchAddPeopleList = async ( 212 + page: Page, 213 + handle: string, 214 + ): Promise<void> => { 163 215 const search = page.getByPlaceholder("Search").last(); 164 216 await search.fill(handle.replace(/^@/, "")); 165 217 await wait(page, 2500); ··· 169 221 .waitFor({ state: "visible", timeout: 15000 }); 170 222 }; 171 223 172 - const addUserToCurrentList = async (page, handle) => { 224 + const addUserToCurrentList = async ( 225 + page: Page, 226 + handle: string, 227 + ): Promise<FlexibleRecord> => { 173 228 await openAddPeopleToList(page); 174 229 await searchAddPeopleList(page, handle); 175 230 const add = page.getByRole("button", { name: /add user to list/i }).last(); ··· 208 263 return { handle }; 209 264 }; 210 265 211 - const removeUserFromCurrentList = async (page, handle) => { 266 + const removeUserFromCurrentList = async ( 267 + page: Page, 268 + handle: string, 269 + ): Promise<FlexibleRecord> => { 212 270 await openListPeopleTab(page); 213 271 const edit = page.getByTestId(`user-${handle}-editBtn`).first(); 214 272 await edit.waitFor({ state: "visible", timeout: 15000 });
+28 -16
src/browser/lib/page-auth-actions.ts
··· 1 + import type { Page } from "playwright"; 2 + import type { 3 + AgeAssuranceTarget, 4 + LoginTarget, 5 + PageAuthActions, 6 + PageAuthActionsOptions, 7 + } from "./browser-types.js"; 8 + 1 9 export const createPageAuthActions = ({ 2 10 appUrl, 3 11 appBaseUrl, 4 12 wait, 5 13 loginToBlueskyApp, 6 - }) => { 14 + }: PageAuthActionsOptions): PageAuthActions => { 7 15 const login = ( 8 - page, 9 - { pdsHost, loginIdentifier, password, notes, noteTarget }, 10 - ) => 16 + page: Page, 17 + { pdsHost, loginIdentifier, password, notes, noteTarget }: LoginTarget, 18 + ): ReturnType<PageAuthActions["login"]> => 11 19 loginToBlueskyApp({ 12 20 page, 13 21 appUrl, ··· 19 27 }); 20 28 21 29 const completeAgeAssuranceIfNeeded = async ( 22 - page, 23 - { birthdate, notes, noteText }, 24 - ) => { 30 + page: Page, 31 + { birthdate, notes, noteText }: AgeAssuranceTarget, 32 + ): Promise<void> => { 25 33 const addBirthdate = page.getByRole("button", { 26 34 name: /(?:update|add) your birthdate/i, 27 35 }); 28 - if (await addBirthdate.count()) { 36 + if ((await addBirthdate.count()) > 0) { 29 37 await addBirthdate.click({ noWaitAfter: true }); 30 38 await wait(page, 800); 31 39 await page.getByTestId("birthdayInput").fill(birthdate); ··· 33 41 .getByRole("button", { name: /save birthdate/i }) 34 42 .click({ noWaitAfter: true }); 35 43 await wait(page, 3000); 36 - if (Array.isArray(notes) && noteText) { 44 + if (Array.isArray(notes) && noteText !== undefined) { 37 45 notes.push(noteText); 38 46 } 39 47 } 40 48 }; 41 49 42 - const gotoProfile = async (page, handle) => { 50 + const gotoProfile = async (page: Page, handle: string): Promise<void> => { 43 51 await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 44 52 waitUntil: "domcontentloaded", 45 53 timeout: 60000, ··· 47 55 await wait(page, 3000); 48 56 }; 49 57 50 - const waitForProfileHandle = async (page, handle, timeout = 20000) => { 58 + const waitForProfileHandle = async ( 59 + page: Page, 60 + handle: string, 61 + timeout = 20000, 62 + ): Promise<void> => { 51 63 const shortHandle = handle.replace(/^@/, ""); 52 64 await page 53 65 .getByText(`@${shortHandle}`) ··· 55 67 .waitFor({ state: "visible", timeout }); 56 68 }; 57 69 58 - const maybeFollow = async (page) => { 70 + const maybeFollow = async (page: Page): Promise<Record<string, string>> => { 59 71 const follow = page.getByTestId("followBtn").first(); 60 72 if (await follow.count()) { 61 73 const label = (await follow.getAttribute("aria-label")) ?? ""; ··· 85 97 return { note: "follow attempted via role button" }; 86 98 }; 87 99 88 - const maybeUnfollow = async (page) => { 100 + const maybeUnfollow = async (page: Page): Promise<Record<string, string>> => { 89 101 const btn = page.getByTestId("unfollowBtn").first(); 90 102 if (!(await btn.count())) { 91 103 return { note: "already not following" }; ··· 95 107 return { note: "unfollow attempted" }; 96 108 }; 97 109 98 - const openNotifications = async (page) => { 110 + const openNotifications = async (page: Page): Promise<void> => { 99 111 await page.goto(`${appBaseUrl}/notifications`, { 100 112 waitUntil: "domcontentloaded", 101 113 timeout: 60000, ··· 107 119 } 108 120 }; 109 121 110 - const openSavedPosts = async (page) => { 122 + const openSavedPosts = async (page: Page): Promise<void> => { 111 123 await page.goto(`${appBaseUrl}/saved`, { 112 124 waitUntil: "domcontentloaded", 113 125 timeout: 60000, ··· 115 127 await wait(page, 3000); 116 128 }; 117 129 118 - const openProfileTab = async (page, name) => { 130 + const openProfileTab = async (page: Page, name: string): Promise<void> => { 119 131 const tab = page.getByRole("tab", { name }).first(); 120 132 await tab.waitFor({ state: "visible", timeout: 15000 }); 121 133 await tab.click({ noWaitAfter: true });
+93 -27
src/browser/lib/page-feed-actions.ts
··· 1 + import type { Locator, Page } from "playwright"; 2 + import type { 3 + PageFeedActions, 4 + PageFeedActionsOptions, 5 + PublishComposerOptions, 6 + } from "./browser-types.js"; 7 + 1 8 export const createPageFeedActions = ({ 2 9 wait, 3 10 normalizeText, 4 11 buttonText, 5 12 dismissBlockingOverlays, 6 - }) => { 7 - const composePost = async (page, text) => { 13 + }: PageFeedActionsOptions): PageFeedActions => { 14 + const composePost = async (page: Page, text: string): Promise<void> => { 8 15 await page 9 16 .locator('[aria-label="Compose new post"]') 10 17 .last() ··· 20 27 await wait(page, 4000); 21 28 }; 22 29 23 - const findRowByPrimaryText = async (page, needle, timeout = 60000) => { 30 + const findRowByPrimaryText = async ( 31 + page: Page, 32 + needle: string, 33 + timeout = 60000, 34 + ): Promise<Locator> => { 24 35 const started = Date.now(); 25 36 while (Date.now() - started < timeout) { 26 37 const rows = page.locator('[data-testid^="feedItem-by-"]'); ··· 42 53 throw new Error(`feed item with primary text not found: ${needle}`); 43 54 }; 44 55 45 - const maybeFindRowByPrimaryText = async (page, needle, timeout = 10000) => { 56 + const maybeFindRowByPrimaryText = async ( 57 + page: Page, 58 + needle: string, 59 + timeout = 10000, 60 + ): Promise<Locator | null> => { 46 61 try { 47 62 return await findRowByPrimaryText(page, needle, timeout); 48 63 } catch { ··· 50 65 } 51 66 }; 52 67 53 - const findFirstFeedItem = async (page, timeout = 20000) => { 68 + const findFirstFeedItem = async ( 69 + page: Page, 70 + timeout = 20000, 71 + ): Promise<Locator> => { 54 72 const started = Date.now(); 55 73 while (Date.now() - started < timeout) { 56 74 const rows = page.locator('[data-testid^="feedItem-by-"]'); ··· 65 83 throw new Error("feed item not found"); 66 84 }; 67 85 68 - const clickLike = async (page, row) => { 86 + const clickLike = async (page: Page, row: Locator): Promise<void> => { 69 87 const btn = row.getByTestId("likeBtn").first(); 70 88 await btn.click({ noWaitAfter: true }); 71 89 await wait(page, 1500); 72 90 }; 73 91 74 - const clickRepost = async (page, row, actionPattern = /^Repost$/i) => { 92 + const clickRepost = async ( 93 + page: Page, 94 + row: Locator, 95 + actionPattern = /^Repost$/i, 96 + ): Promise<void> => { 75 97 await dismissBlockingOverlays(page); 76 98 const btn = row.getByTestId("repostBtn").first(); 77 99 await btn.click({ noWaitAfter: true }); ··· 86 108 await wait(page, 1500); 87 109 }; 88 110 89 - const ensureLiked = async (page, row) => { 111 + const ensureLiked = async ( 112 + page: Page, 113 + row: Locator, 114 + ): Promise<Record<string, string>> => { 90 115 const btn = row.getByTestId("likeBtn").first(); 91 116 const before = await buttonText(btn); 92 117 if (/unlike/i.test(before)) { ··· 96 121 return { note: await buttonText(btn) }; 97 122 }; 98 123 99 - const ensureNotLiked = async (page, row) => { 124 + const ensureNotLiked = async ( 125 + page: Page, 126 + row: Locator, 127 + ): Promise<Record<string, string>> => { 100 128 const btn = row.getByTestId("likeBtn").first(); 101 129 const before = await buttonText(btn); 102 130 if (!/unlike/i.test(before)) { ··· 106 134 return { note: await buttonText(btn) }; 107 135 }; 108 136 109 - const ensureReposted = async (page, row) => { 137 + const ensureReposted = async ( 138 + page: Page, 139 + row: Locator, 140 + ): Promise<Record<string, string>> => { 110 141 const btn = row.getByTestId("repostBtn").first(); 111 142 const before = await buttonText(btn); 112 143 if (/undo repost|remove repost/i.test(before)) { ··· 116 147 return { note: await buttonText(btn) }; 117 148 }; 118 149 119 - const ensureNotReposted = async (page, row) => { 150 + const ensureNotReposted = async ( 151 + page: Page, 152 + row: Locator, 153 + ): Promise<Record<string, string>> => { 120 154 const btn = row.getByTestId("repostBtn").first(); 121 155 const before = await buttonText(btn); 122 156 if (!/undo repost|remove repost/i.test(before)) { ··· 126 160 return { note: await buttonText(btn) }; 127 161 }; 128 162 129 - const ensureBookmarked = async (page, row) => { 163 + const ensureBookmarked = async ( 164 + page: Page, 165 + row: Locator, 166 + ): Promise<Record<string, string>> => { 130 167 const btn = row.getByTestId("postBookmarkBtn").first(); 131 168 const before = await buttonText(btn); 132 169 if (/remove from saved posts/i.test(before)) { ··· 137 174 return { note: await buttonText(btn) }; 138 175 }; 139 176 140 - const ensureNotBookmarked = async (page, row) => { 177 + const ensureNotBookmarked = async ( 178 + page: Page, 179 + row: Locator, 180 + ): Promise<Record<string, string>> => { 141 181 const btn = row.getByTestId("postBookmarkBtn").first(); 142 182 const before = await buttonText(btn); 143 183 if (!/remove from saved posts/i.test(before)) { ··· 148 188 return { note: await buttonText(btn) }; 149 189 }; 150 190 151 - const visibleEditorLocator = (page) => 191 + const visibleEditorLocator = (page: Page): Locator => 152 192 page.locator( 153 193 [ 154 194 '[aria-label="Rich-Text Editor"]', ··· 157 197 ].join(", "), 158 198 ); 159 199 160 - const waitForVisibleEditor = async (page, timeout = 20000) => { 200 + const waitForVisibleEditor = async ( 201 + page: Page, 202 + timeout = 20000, 203 + ): Promise<Locator> => { 161 204 const editors = visibleEditorLocator(page); 162 205 const started = Date.now(); 163 206 while (Date.now() - started < timeout) { ··· 173 216 throw new Error("visible rich-text editor not found"); 174 217 }; 175 218 176 - const firstVisibleLocator = async (locator) => { 219 + const firstVisibleLocator = async ( 220 + locator: Locator, 221 + ): Promise<Locator | null> => { 177 222 const count = await locator.count(); 178 223 for (let i = count - 1; i >= 0; i -= 1) { 179 224 const candidate = locator.nth(i); ··· 185 230 }; 186 231 187 232 const publishComposer = async ( 188 - page, 189 - text, 190 - { applyWritesLabel, publishLabel }, 191 - ) => { 233 + page: Page, 234 + text: string, 235 + { applyWritesLabel, publishLabel }: PublishComposerOptions, 236 + ): Promise<void> => { 192 237 const editor = await waitForVisibleEditor(page); 193 238 await editor.click({ noWaitAfter: true }); 194 239 await editor.fill(text); ··· 205 250 const response = await responsePromise; 206 251 if (response.status() !== 200) { 207 252 throw new Error( 208 - `${applyWritesLabel} failed with status ${response.status()}`, 253 + `${applyWritesLabel} failed with status ${String(response.status())}`, 209 254 ); 210 255 } 211 256 await wait(page, 4000); ··· 222 267 .catch(() => undefined); 223 268 }; 224 269 225 - const clickQuote = async (page, row, text) => { 270 + const clickQuote = async ( 271 + page: Page, 272 + row: Locator, 273 + text: string, 274 + ): Promise<void> => { 226 275 await dismissBlockingOverlays(page); 227 276 const btn = row.getByTestId("repostBtn").first(); 228 277 await btn.click({ noWaitAfter: true }); ··· 239 288 await dismissBlockingOverlays(page); 240 289 }; 241 290 242 - const clickReply = async (page, row, text) => { 243 - const openReplyComposer = async (scope) => { 291 + const clickReply = async ( 292 + page: Page, 293 + row: Page | Locator, 294 + text: string, 295 + ): Promise<void> => { 296 + const openReplyComposer = async ( 297 + scope: Page | Locator, 298 + ): Promise<boolean> => { 244 299 const editor = await waitForVisibleEditor(page, 750).catch(() => null); 245 300 if (editor) { 246 301 return true; ··· 311 366 await dismissBlockingOverlays(page); 312 367 }; 313 368 314 - const openPostOptions = async (page, row) => { 369 + const openPostOptions = async ( 370 + page: Page, 371 + row: Locator, 372 + ): Promise<Locator> => { 315 373 const btn = row.getByTestId("postDropdownBtn").first(); 316 374 await btn.click({ noWaitAfter: true }); 317 375 const menu = page.locator('[role="menu"]').last(); ··· 319 377 return menu; 320 378 }; 321 379 322 - const deletePostRow = async (page, row) => { 380 + const deletePostRow = async ( 381 + page: Page, 382 + row: Locator, 383 + ): Promise<Record<string, string>> => { 323 384 await openPostOptions(page, row); 324 385 const deleteItem = page 325 386 .getByRole("menuitem", { name: /delete post/i }) ··· 332 393 await confirm.click({ noWaitAfter: true }); 333 394 await dialog.waitFor({ state: "hidden", timeout: 15000 }); 334 395 await wait(page, 3000); 396 + return { note: "delete attempted" }; 335 397 }; 336 398 337 - const maybeDeleteOwnPostByText = async (page, text, successNote) => { 399 + const maybeDeleteOwnPostByText = async ( 400 + page: Page, 401 + text: string, 402 + successNote: string, 403 + ): Promise<Record<string, string>> => { 338 404 const row = await maybeFindRowByPrimaryText(page, text, 10000); 339 405 if (!row) { 340 406 return { note: `not surfaced for cleanup: ${text}` };
+17 -7
src/browser/lib/page-profile-edit-actions.ts
··· 1 1 import fs from "node:fs/promises"; 2 2 import path from "node:path"; 3 + import type { 4 + PageProfileEditActions, 5 + PageProfileEditActionsOptions, 6 + } from "./browser-types.js"; 7 + import type { Page } from "playwright"; 3 8 4 9 export const createPageProfileEditActions = ({ 5 10 artifactsDir, ··· 7 12 dismissBlockingOverlays, 8 13 avatarPngBase64, 9 14 notes, 10 - }) => { 11 - const ensureAvatarFixture = async () => { 15 + }: PageProfileEditActionsOptions): PageProfileEditActions => { 16 + const ensureAvatarFixture = async (): Promise<string> => { 12 17 const file = path.join(artifactsDir, "avatar-fixture.png"); 13 18 await fs.writeFile(file, Buffer.from(avatarPngBase64, "base64")); 14 19 return file; 15 20 }; 16 21 17 - const uploadProfileAvatar = async (page) => { 22 + const uploadProfileAvatar = async (page: Page): Promise<string> => { 18 23 const avatarFile = await ensureAvatarFixture(); 19 24 const fileInputs = page.locator('input[type="file"]'); 20 25 const count = await fileInputs.count(); ··· 64 69 await fileInputs.first().setInputFiles(avatarFile); 65 70 await wait(page, 1500); 66 71 if (Array.isArray(notes)) { 67 - notes.push(`edit profile file inputs: ${count}`); 72 + notes.push(`edit profile file inputs: ${String(count)}`); 68 73 } 69 74 return avatarFile; 70 75 }; 71 76 72 - const editProfile = async (page, { profileNote, handle }) => { 77 + const editProfile = async ( 78 + page: Page, 79 + { profileNote, handle }: { profileNote: string; handle?: string }, 80 + ): Promise<{ avatarFile: string; profileNote: string }> => { 73 81 const edit = page.getByRole("button", { name: /edit profile/i }); 74 82 if (!(await edit.count())) { 75 - const detail = handle ? ` for ${handle}` : ""; 83 + const detail = 84 + handle !== undefined && handle.length > 0 ? ` for ${handle}` : ""; 76 85 throw new Error(`edit profile button unavailable${detail}`); 77 86 } 78 87 await edit.click({ noWaitAfter: true }); ··· 84 93 await bioField.fill(profileNote); 85 94 const actual = await bioField.inputValue(); 86 95 if (actual !== profileNote) { 87 - const detail = handle ? ` for ${handle}` : ""; 96 + const detail = 97 + handle !== undefined && handle.length > 0 ? ` for ${handle}` : ""; 88 98 throw new Error( 89 99 `profile description fill did not stick${detail}: ${actual}`, 90 100 );
+12 -2
src/browser/lib/playwright-runtime.ts
··· 5 5 const fallbackPlaywrightPath = 6 6 "../../../../tools/browser-automation/node_modules/playwright/index.js"; 7 7 8 + const importPlaywright = async ( 9 + modulePath: string, 10 + ): Promise<typeof Playwright> => { 11 + const mod: unknown = await import(modulePath); 12 + if (typeof mod === "object" && mod !== null && "chromium" in mod) { 13 + return mod as typeof Playwright; 14 + } 15 + throw new Error(`module at ${modulePath} is not a Playwright runtime`); 16 + }; 17 + 8 18 try { 9 - playwright = await import("playwright"); 19 + playwright = await importPlaywright("playwright"); 10 20 } catch (primaryError) { 11 21 try { 12 - playwright = await import(fallbackPlaywrightPath); 22 + playwright = await importPlaywright(fallbackPlaywrightPath); 13 23 } catch { 14 24 throw new Error( 15 25 [
+81 -67
src/browser/lib/runtime-utils.ts
··· 1 1 import fs from "node:fs/promises"; 2 2 import type { Browser, Locator, Page } from "playwright"; 3 + import { isRecord } from "../../guards.js"; 3 4 import type { 4 5 FetchJsonResult, 5 6 FetchStatusResult, 6 7 FlexibleRecord, 7 8 Summary, 8 9 } from "../../types.js"; 10 + import type { StepOptions, StepRunner } from "./browser-types.js"; 9 11 10 12 const SYSTEM_GOOGLE_CHROME = 11 13 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; ··· 20 22 new Promise((resolve) => setTimeout(resolve, ms)); 21 23 22 24 export const normalizeText = (text: string | null | undefined): string => 23 - (text || "").replace(/\s+/g, " ").trim(); 25 + (text ?? "").replace(/\s+/g, " ").trim(); 24 26 25 27 export const createBaseSummary = (fields: FlexibleRecord = {}): Summary => ({ 26 28 startedAt: new Date().toISOString(), ··· 62 64 failed: boolean; 63 65 }) => Promise<FlexibleRecord>; 64 66 defaultTimeoutMs?: number; 65 - }) => { 66 - return async ( 67 + }): StepRunner => { 68 + return async <T>( 67 69 name: string, 68 - fn: () => Promise<unknown>, 69 - { 70 - optional = false, 71 - timeoutMs, 72 - pageNames = [], 73 - }: { 74 - optional?: boolean; 75 - timeoutMs?: number; 76 - pageNames?: string[]; 77 - } = {}, 78 - ): Promise<unknown> => { 79 - const effectiveTimeoutMs = Number(timeoutMs || defaultTimeoutMs || 0); 70 + fn: () => Promise<T>, 71 + { optional = false, timeoutMs, pageNames = [] }: StepOptions = {}, 72 + ): Promise<T | null> => { 73 + const effectiveTimeoutMs = timeoutMs ?? defaultTimeoutMs ?? 0; 80 74 emitProgress("start", name); 81 75 let timeoutId: ReturnType<typeof setTimeout> | undefined; 82 76 try { 83 - const result = 77 + const result: T = 84 78 effectiveTimeoutMs > 0 85 - ? await Promise.race([ 79 + ? await Promise.race<T>([ 86 80 fn(), 87 - new Promise((_, reject) => { 81 + new Promise<T>((_, reject) => { 88 82 timeoutId = setTimeout(() => { 89 83 reject( 90 - new Error(`step timed out after ${effectiveTimeoutMs}ms`), 84 + new Error( 85 + `step timed out after ${String(effectiveTimeoutMs)}ms`, 86 + ), 91 87 ); 92 88 }, effectiveTimeoutMs); 93 89 }), ··· 98 94 pageNames, 99 95 failed: false, 100 96 }); 97 + const resultDetails = isRecord(result) ? result : {}; 101 98 recordStep(summary, name, "ok", { 102 99 ...artifacts, 103 - ...((result as FlexibleRecord | null) ?? {}), 100 + ...resultDetails, 104 101 }); 105 102 emitProgress("ok", name); 106 103 return result; ··· 127 124 }; 128 125 }; 129 126 130 - export const buildBrowserLaunchCandidates = async ( 131 - config: FlexibleRecord, 132 - ): Promise<{ label: string; options: FlexibleRecord }[]> => { 127 + export const buildBrowserLaunchCandidates = async (config: { 128 + browserExecutablePath?: string; 129 + headless?: boolean; 130 + }): Promise<{ label: string; options: FlexibleRecord }[]> => { 133 131 const base = { 134 132 headless: config.headless !== false, 135 133 chromiumSandbox: true, 136 134 }; 137 135 const candidates: { label: string; options: FlexibleRecord }[] = []; 138 - if (config.browserExecutablePath) { 136 + const browserExecutablePath = config.browserExecutablePath; 137 + if (browserExecutablePath !== undefined) { 139 138 candidates.push({ 140 - label: `executable:${config.browserExecutablePath}`, 141 - options: { ...base, executablePath: config.browserExecutablePath }, 139 + label: `executable:${browserExecutablePath}`, 140 + options: { ...base, executablePath: browserExecutablePath }, 142 141 }); 143 - } 144 - if (!config.browserExecutablePath) { 142 + } else { 145 143 try { 146 144 await fs.access(SYSTEM_GOOGLE_CHROME); 147 145 candidates.push({ ··· 166 164 const timeoutMs = 167 165 typeof options.timeoutMs === "number" ? options.timeoutMs : 30000; 168 166 const controller = new AbortController(); 169 - const timer = setTimeout(() => controller.abort(), timeoutMs); 167 + const timer = setTimeout(() => { 168 + controller.abort(); 169 + }, timeoutMs); 170 170 const fetchOptions: RequestInit & { timeoutMs?: number } = { 171 - ...(options as RequestInit), 171 + ...(isRecord(options) ? options : {}), 172 172 signal: controller.signal, 173 173 }; 174 174 delete fetchOptions.timeoutMs; ··· 181 181 const text = await res.text(); 182 182 let json: FetchJsonResult["json"]; 183 183 try { 184 - json = text ? (JSON.parse(text) as FetchJsonResult["json"]) : null; 184 + const parsed: unknown = text ? JSON.parse(text) : null; 185 + json = 186 + parsed === null || Array.isArray(parsed) || isRecord(parsed) 187 + ? parsed 188 + : null; 185 189 } catch { 186 190 json = null; 187 191 } ··· 195 199 const timeoutMs = 196 200 typeof options.timeoutMs === "number" ? options.timeoutMs : 30000; 197 201 const controller = new AbortController(); 198 - const timer = setTimeout(() => controller.abort(), timeoutMs); 202 + const timer = setTimeout(() => { 203 + controller.abort(); 204 + }, timeoutMs); 199 205 try { 200 - const redirect = 201 - typeof options.redirect === "string" ? options.redirect : "follow"; 206 + const redirect: RequestRedirect = 207 + options.redirect === "error" || 208 + options.redirect === "manual" || 209 + options.redirect === "follow" 210 + ? options.redirect 211 + : "follow"; 202 212 const res = await fetch(url, { 203 - ...(options as RequestInit), 204 - redirect: redirect as RequestRedirect, 213 + ...(isRecord(options) ? options : {}), 214 + redirect, 205 215 signal: controller.signal, 206 216 }); 207 217 return { ok: res.ok, status: res.status, url: res.url }; ··· 212 222 213 223 export const buttonText = async (locator: Locator): Promise<string> => { 214 224 const label = await locator.getAttribute("aria-label"); 215 - if (label && label.trim()) { 216 - return label.trim(); 225 + const trimmedLabel = label?.trim(); 226 + if (trimmedLabel !== undefined && trimmedLabel.length > 0) { 227 + return trimmedLabel; 217 228 } 218 229 const text = await locator.innerText().catch(() => ""); 219 230 return text.trim(); ··· 221 232 222 233 export const dismissBlockingOverlays = async (page: Page): Promise<void> => { 223 234 const backdrop = page.locator('[aria-label*="click to close"]').last(); 224 - if (await backdrop.count()) { 235 + if ((await backdrop.count()) > 0) { 225 236 await backdrop 226 237 .click({ force: true, noWaitAfter: true }) 227 238 .catch(() => undefined); ··· 229 240 } 230 241 231 242 const dialog = page.locator('[role="dialog"][aria-modal="true"]').last(); 232 - if (await dialog.count()) { 243 + if ((await dialog.count()) > 0) { 233 244 const close = dialog.getByRole("button", { name: /close/i }).last(); 234 - if (await close.count()) { 245 + if ((await close.count()) > 0) { 235 246 await close.click({ noWaitAfter: true }).catch(() => undefined); 236 247 await page.waitForTimeout(400); 237 248 } ··· 258 269 noteTarget?: string; 259 270 }): Promise<{ loginPath: string }> => { 260 271 let loginPath = "legacy-service-picker"; 261 - const activeScope = () => page.locator('[role="dialog"]').last(); 272 + const activeScope = (): Locator => page.locator('[role="dialog"]').last(); 262 273 263 274 const clickNamedControl = async (name: string): Promise<void> => { 264 275 const scope = activeScope(); 265 276 const asButton = scope.getByRole("button", { name }).first(); 266 - if (await asButton.count()) { 277 + if ((await asButton.count()) > 0) { 267 278 await asButton.click({ noWaitAfter: true, force: true }); 268 279 return; 269 280 } 270 281 const asLink = scope.getByRole("link", { name }).first(); 271 - if (await asLink.count()) { 282 + if ((await asLink.count()) > 0) { 272 283 await asLink.click({ noWaitAfter: true, force: true }); 273 284 return; 274 285 } ··· 287 298 const loginIdentifierField = page.getByPlaceholder( 288 299 "Username or email address", 289 300 ); 290 - if (await loginIdentifierField.count()) { 301 + if ((await loginIdentifierField.count()) > 0) { 291 302 const serviceButton = page.getByTestId("selectServiceButton").first(); 292 303 const currentService = await buttonText(serviceButton).catch(() => ""); 293 304 if ( ··· 322 333 } 323 334 324 335 const close = page.getByRole("button", { name: "Close welcome modal" }); 325 - if (await close.count()) { 336 + if ((await close.count()) > 0) { 326 337 await close.click({ noWaitAfter: true }).catch(() => undefined); 327 338 await page.waitForTimeout(300); 328 339 } ··· 333 344 await page.getByTestId("loginNextButton").click({ noWaitAfter: true }); 334 345 await page.waitForTimeout(3000); 335 346 if (Array.isArray(notes)) { 336 - notes.push(`login path for ${noteTarget || pdsHost}: ${loginPath}`); 347 + notes.push(`login path for ${noteTarget ?? pdsHost}: ${loginPath}`); 337 348 } 338 349 return { loginPath }; 339 350 }; ··· 368 379 await sleep(intervalMs); 369 380 } 370 381 throw new Error( 371 - `${name} did not succeed before timeout; last status=${last?.status ?? "none"}`, 382 + `${name} did not succeed before timeout; last status=${String(last?.status ?? "none")}`, 372 383 ); 373 384 }; 374 385 ··· 378 389 summary, 379 390 }: { 380 391 chromium: { launch: (options: FlexibleRecord) => Promise<Browser> }; 381 - config: FlexibleRecord; 392 + config: { browserExecutablePath?: string; headless?: boolean }; 382 393 summary: Summary; 383 394 }): Promise<Browser> => { 384 395 const errors: string[] = []; ··· 409 420 pageName?: string; 410 421 xrpcLimit?: number; 411 422 }): void => { 412 - const maybePage = pageName ? { page: pageName } : {}; 423 + const maybePage = pageName !== undefined ? { page: pageName } : {}; 413 424 414 425 page.on("console", (msg) => { 415 426 summary.console.push({ ··· 422 433 page.on("pageerror", (error) => { 423 434 summary.pageErrors.push({ 424 435 ...maybePage, 425 - message: String(error?.message ?? error), 426 - stack: error?.stack, 436 + message: error.message, 437 + stack: error.stack, 427 438 }); 428 439 }); 429 440 ··· 468 479 }: { 469 480 enabled: boolean; 470 481 write?: (message: string) => void; 471 - }) => { 482 + }): ((status: string, name: string, detail?: string) => void) => { 472 483 return (status: string, name: string, detail = ""): void => { 473 484 if (!enabled) { 474 485 return; ··· 488 499 }: { 489 500 summary: Summary; 490 501 strictErrors: boolean; 491 - isIgnoredConsole: (entry: FlexibleRecord) => boolean; 492 - isIgnoredRequestFailure: (entry: FlexibleRecord) => boolean; 493 - isIgnoredHttpFailure: (entry: FlexibleRecord) => boolean; 502 + isIgnoredConsole: (entry: Summary["console"][number]) => boolean; 503 + isIgnoredRequestFailure: ( 504 + entry: Summary["requestFailures"][number], 505 + ) => boolean; 506 + isIgnoredHttpFailure: (entry: Summary["httpFailures"][number]) => boolean; 494 507 }): Summary => { 495 508 summary.finishedAt = new Date().toISOString(); 496 509 summary.unexpected = { ··· 509 522 summary.unexpected.httpFailures.length + 510 523 summary.unexpected.pageErrors.length; 511 524 if ( 512 - !summary.fatal && 513 - strictErrors !== false && 525 + summary.fatal === undefined && 526 + strictErrors && 514 527 summary.unexpected.total > 0 515 528 ) { 516 - summary.fatal = `Unexpected browser/runtime errors: ${summary.unexpected.total}`; 529 + summary.fatal = `Unexpected browser/runtime errors: ${String(summary.unexpected.total)}`; 517 530 } 518 - summary.ok = !summary.fatal; 531 + summary.ok = summary.fatal === undefined; 519 532 return summary; 520 533 }; 521 534 ··· 530 543 }): Promise<void> => { 531 544 await Promise.race([ 532 545 browser.close(), 533 - new Promise((_, reject) => { 534 - setTimeout( 535 - () => reject(new Error(`browser close timed out after ${timeoutMs}ms`)), 536 - timeoutMs, 537 - ); 546 + new Promise<never>((_, reject) => { 547 + setTimeout(() => { 548 + reject( 549 + new Error(`browser close timed out after ${String(timeoutMs)}ms`), 550 + ); 551 + }, timeoutMs); 538 552 }), 539 - ]).catch((error) => { 540 - summary.notes.push(String(error?.message ?? error)); 553 + ]).catch((error: unknown) => { 554 + summary.notes.push(errorMessage(error)); 541 555 }); 542 556 };
+57 -9
src/browser/lib/settings.ts
··· 1 - export const createSettingsHelpers = ({ appBaseUrl, wait }) => { 2 - const openSettingRoute = async (page, route) => { 1 + import type { Page } from "playwright"; 2 + import type { FlexibleRecord } from "../../types.js"; 3 + import type { PageWait } from "./browser-types.js"; 4 + 5 + interface SettingsHelpers { 6 + setCheckboxSetting: ( 7 + page: Page, 8 + route: string, 9 + name: string, 10 + desired: boolean, 11 + ) => Promise<FlexibleRecord>; 12 + setRadioSetting: ( 13 + page: Page, 14 + route: string, 15 + name: string, 16 + ) => Promise<FlexibleRecord>; 17 + } 18 + 19 + export const createSettingsHelpers = ({ 20 + appBaseUrl, 21 + wait, 22 + }: { 23 + appBaseUrl: string; 24 + wait: PageWait; 25 + }): SettingsHelpers => { 26 + const openSettingRoute = async (page: Page, route: string): Promise<void> => { 3 27 await page.goto(`${appBaseUrl}${route}`, { 4 28 waitUntil: "domcontentloaded", 5 29 timeout: 60000, ··· 7 31 await wait(page, 3000); 8 32 }; 9 33 10 - const roleSetting = (page, role, name) => 11 - page.getByRole(role, { name }).first(); 34 + const roleSetting = ( 35 + page: Page, 36 + role: "checkbox" | "radio", 37 + name: string, 38 + ): ReturnType<Page["getByRole"]> => page.getByRole(role, { name }).first(); 12 39 13 - const settingState = async (page, role, name) => { 40 + const settingState = async ( 41 + page: Page, 42 + role: "checkbox" | "radio", 43 + name: string, 44 + ): Promise<boolean> => { 14 45 const locator = roleSetting(page, role, name); 15 46 await locator.waitFor({ state: "visible", timeout: 15000 }); 16 47 return (await locator.getAttribute("aria-checked")) === "true"; ··· 24 55 desired, 25 56 verifyError, 26 57 result, 27 - }) => { 58 + }: { 59 + page: Page; 60 + route: string; 61 + role: "checkbox" | "radio"; 62 + name: string; 63 + desired: boolean; 64 + verifyError: (verified: boolean) => string; 65 + result: (verified: boolean) => FlexibleRecord; 66 + }): Promise<FlexibleRecord> => { 28 67 await openSettingRoute(page, route); 29 68 const locator = roleSetting(page, role, name); 30 69 const current = await settingState(page, role, name); ··· 40 79 return result(verified); 41 80 }; 42 81 43 - const setCheckboxSetting = async (page, route, name, desired) => { 82 + const setCheckboxSetting = async ( 83 + page: Page, 84 + route: string, 85 + name: string, 86 + desired: boolean, 87 + ): Promise<FlexibleRecord> => { 44 88 return await setPersistedSetting({ 45 89 page, 46 90 route, ··· 48 92 name, 49 93 desired, 50 94 verifyError: (verified) => 51 - `checkbox setting ${name} on ${route} expected ${desired} but saw ${verified}`, 95 + `checkbox setting ${name} on ${route} expected ${String(desired)} but saw ${String(verified)}`, 52 96 result: (verified) => ({ desired, verified }), 53 97 }); 54 98 }; 55 99 56 - const setRadioSetting = async (page, route, name) => { 100 + const setRadioSetting = async ( 101 + page: Page, 102 + route: string, 103 + name: string, 104 + ): Promise<FlexibleRecord> => { 57 105 return await setPersistedSetting({ 58 106 page, 59 107 route,
+4 -1
src/browser/lib/single-actions.ts
··· 1 + import type { SingleActions, SingleActionsOptions } from "./browser-types.js"; 1 2 import { createSingleAuthActions } from "./single-actions/auth.js"; 2 3 import { createSingleFeedActions } from "./single-actions/feed.js"; 3 4 import { createSingleProfileActions } from "./single-actions/profile.js"; 4 5 5 - export const createSingleActions = (options) => { 6 + export const createSingleActions = ( 7 + options: SingleActionsOptions, 8 + ): SingleActions => { 6 9 return { 7 10 ...createSingleAuthActions(options), 8 11 ...createSingleFeedActions(options),
+37 -13
src/browser/lib/single-actions/auth.ts
··· 1 + import type { 2 + PageAuthActions, 3 + SingleActions, 4 + SingleActionsOptions, 5 + } from "../browser-types.js"; 1 6 import { loginToBlueskyApp } from "../runtime-utils.js"; 2 7 import { createPageAuthActions } from "../page-auth-actions.js"; 3 8 ··· 7 12 page, 8 13 appBaseUrl, 9 14 wait, 10 - }) => { 11 - const actions = createPageAuthActions({ 15 + }: SingleActionsOptions): Pick< 16 + SingleActions, 17 + | "login" 18 + | "completeAgeAssuranceIfNeeded" 19 + | "gotoProfile" 20 + | "waitForProfileHandle" 21 + | "maybeFollowTarget" 22 + | "maybeUnfollowTarget" 23 + | "openNotifications" 24 + | "openSavedPosts" 25 + | "openProfileTab" 26 + > => { 27 + const actions: PageAuthActions = createPageAuthActions({ 12 28 appUrl: config.appUrl, 13 29 appBaseUrl, 14 30 wait: (_page, ms) => wait(ms), 15 31 loginToBlueskyApp, 16 32 }); 17 33 18 - const login = async () => { 19 - const loginIdentifier = config.loginIdentifier || config.handle; 34 + const login = async (): Promise<void> => { 35 + const loginIdentifier = config.loginIdentifier ?? config.handle; 20 36 await actions.login(page, { 21 37 pdsHost: config.pdsHost, 22 38 loginIdentifier, ··· 26 42 }); 27 43 }; 28 44 29 - const completeAgeAssuranceIfNeeded = () => 45 + const completeAgeAssuranceIfNeeded = (): Promise<void> => 30 46 actions.completeAgeAssuranceIfNeeded(page, { 31 47 birthdate: config.birthdate, 32 48 notes: summary.notes, 33 49 noteText: "Completed age-assurance birthdate gate", 34 50 }); 35 51 36 - const gotoProfile = (handle) => actions.gotoProfile(page, handle); 52 + const gotoProfile = (handle: string): Promise<void> => 53 + actions.gotoProfile(page, handle); 37 54 38 - const waitForProfileHandle = (handle, timeout) => 39 - actions.waitForProfileHandle(page, handle, timeout); 55 + const waitForProfileHandle = ( 56 + handle: string, 57 + timeout?: number, 58 + ): Promise<void> => actions.waitForProfileHandle(page, handle, timeout); 40 59 41 - const maybeFollowTarget = () => actions.maybeFollow(page); 60 + const maybeFollowTarget = (): ReturnType<PageAuthActions["maybeFollow"]> => 61 + actions.maybeFollow(page); 42 62 43 - const maybeUnfollowTarget = () => actions.maybeUnfollow(page); 63 + const maybeUnfollowTarget = (): ReturnType< 64 + PageAuthActions["maybeUnfollow"] 65 + > => actions.maybeUnfollow(page); 44 66 45 - const openNotifications = () => actions.openNotifications(page); 67 + const openNotifications = (): Promise<void> => 68 + actions.openNotifications(page); 46 69 47 - const openSavedPosts = () => actions.openSavedPosts(page); 70 + const openSavedPosts = (): Promise<void> => actions.openSavedPosts(page); 48 71 49 - const openProfileTab = (name) => actions.openProfileTab(page, name); 72 + const openProfileTab = (name: string): Promise<void> => 73 + actions.openProfileTab(page, name); 50 74 51 75 return { 52 76 login,
+26 -7
src/browser/lib/single-actions/feed.ts
··· 1 + import type { 2 + PageFeedActions, 3 + SingleActions, 4 + SingleActionsOptions, 5 + } from "../browser-types.js"; 1 6 import { dismissBlockingOverlays } from "../runtime-utils.js"; 2 7 import { createPageFeedActions } from "../page-feed-actions.js"; 3 8 ··· 6 11 wait, 7 12 normalizeText, 8 13 buttonText, 9 - }) => { 10 - const actions = createPageFeedActions({ 14 + }: SingleActionsOptions): Pick< 15 + SingleActions, 16 + | "composePost" 17 + | "findRowByPrimaryText" 18 + | "findFirstFeedItem" 19 + | "clickQuote" 20 + | "clickReply" 21 + | "ensureBookmarked" 22 + | "ensureNotBookmarked" 23 + | "ensureLiked" 24 + | "ensureNotLiked" 25 + | "ensureReposted" 26 + | "ensureNotReposted" 27 + | "maybeDeleteOwnPostByText" 28 + > => { 29 + const actions: PageFeedActions = createPageFeedActions({ 11 30 wait: (_page, ms) => wait(ms), 12 31 normalizeText, 13 32 buttonText, ··· 15 34 }); 16 35 17 36 return { 18 - composePost: (text) => actions.composePost(page, text), 19 - findRowByPrimaryText: (needle, timeout) => 37 + composePost: (text: string) => actions.composePost(page, text), 38 + findRowByPrimaryText: (needle: string, timeout?: number) => 20 39 actions.findRowByPrimaryText(page, needle, timeout), 21 - findFirstFeedItem: (timeout) => 22 - actions.findFirstFeedItem(page, timeout || 60000), 40 + findFirstFeedItem: (timeout?: number) => 41 + actions.findFirstFeedItem(page, timeout ?? 60000), 23 42 clickQuote: (row, text) => actions.clickQuote(page, row, text), 24 43 clickReply: (row, text) => actions.clickReply(page, row, text), 25 44 ensureBookmarked: (row) => actions.ensureBookmarked(page, row), ··· 28 47 ensureNotLiked: (row) => actions.ensureNotLiked(page, row), 29 48 ensureReposted: (row) => actions.ensureReposted(page, row), 30 49 ensureNotReposted: (row) => actions.ensureNotReposted(page, row), 31 - maybeDeleteOwnPostByText: (text, successNote) => 50 + maybeDeleteOwnPostByText: (text: string, successNote: string) => 32 51 actions.maybeDeleteOwnPostByText(page, text, successNote), 33 52 }; 34 53 };
+118 -41
src/browser/lib/single-actions/profile.ts
··· 1 + import type { SingleActions, SingleActionsOptions } from "../browser-types.js"; 2 + import type { FlexibleRecord } from "../../../types.js"; 3 + import { isRecord, isString } from "../../../guards.js"; 1 4 import { dismissBlockingOverlays } from "../runtime-utils.js"; 2 5 import { createPageProfileEditActions } from "../page-profile-edit-actions.js"; 3 6 ··· 9 12 fetchStatus, 10 13 pollJson, 11 14 avatarPngBase64, 12 - }) => { 13 - const publicCheckTimeoutMs = config.publicCheckTimeoutMs ?? 180000; 15 + }: SingleActionsOptions): Pick< 16 + SingleActions, 17 + | "verifyPublicHandleResolution" 18 + | "verifyPublicAuthorFeed" 19 + | "verifyPublicProfile" 20 + | "verifyPublicProfileAfterEdit" 21 + | "verifyLocalProfileAfterEdit" 22 + | "editProfile" 23 + > => { 24 + const publicCheckTimeoutMs = config.publicCheckTimeoutMs; 14 25 const pageActions = createPageProfileEditActions({ 15 26 artifactsDir: config.artifactsDir, 16 27 wait: (_page, ms) => wait(ms), ··· 19 30 notes: summary.notes, 20 31 }); 21 32 22 - const verifyPublicHandleResolution = async () => { 33 + const verifyPublicHandleResolution = async (): Promise<FlexibleRecord> => { 23 34 const result = await pollJson( 24 35 "public handle resolution", 25 36 () => 26 37 `${config.publicApiUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 27 - ({ ok, json }) => 28 - ok && typeof json?.did === "string" && json.did.length > 0, 38 + ({ ok, json }) => { 39 + const resolved = isRecord(json) ? json : undefined; 40 + const did = isString(resolved?.did) ? resolved.did : undefined; 41 + return ok && did !== undefined && did.length > 0; 42 + }, 29 43 publicCheckTimeoutMs, 30 44 ); 31 - return { did: result.json.did }; 45 + const json = isRecord(result.json) ? result.json : undefined; 46 + return { did: typeof json?.did === "string" ? json.did : undefined }; 32 47 }; 33 48 34 - const verifyPublicAuthorFeed = async () => { 49 + const verifyPublicAuthorFeed = async (): Promise<FlexibleRecord> => { 35 50 const result = await pollJson( 36 51 "public author feed indexing", 37 52 () => 38 53 `${config.publicApiUrl}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(config.handle)}&limit=20`, 39 - ({ ok, json }) => 40 - ok && 41 - Array.isArray(json?.feed) && 42 - json.feed.some((item) => item?.post?.record?.text === config.postText), 54 + ({ ok, json }) => { 55 + const feed = 56 + isRecord(json) && Array.isArray(json.feed) ? json.feed : []; 57 + return ( 58 + ok && 59 + feed.some( 60 + (item) => 61 + isRecord(item) && 62 + isRecord(item.post) && 63 + isRecord(item.post.record) && 64 + item.post.record.text === config.postText, 65 + ) 66 + ); 67 + }, 43 68 publicCheckTimeoutMs, 44 69 ); 45 - const matching = result.json.feed.find( 46 - (item) => item?.post?.record?.text === config.postText, 70 + const feed = 71 + isRecord(result.json) && Array.isArray(result.json.feed) 72 + ? result.json.feed.filter(isRecord) 73 + : []; 74 + const matching = feed.find( 75 + (item) => 76 + isRecord(item) && 77 + isRecord(item.post) && 78 + isRecord(item.post.record) && 79 + item.post.record.text === config.postText, 47 80 ); 48 81 return { 49 - uri: matching?.post?.uri, 50 - cid: matching?.post?.cid, 82 + uri: 83 + isRecord(matching) && 84 + isRecord(matching.post) && 85 + isString(matching.post.uri) 86 + ? matching.post.uri 87 + : undefined, 88 + cid: 89 + isRecord(matching) && 90 + isRecord(matching.post) && 91 + isString(matching.post.cid) 92 + ? matching.post.cid 93 + : undefined, 51 94 }; 52 95 }; 53 96 54 - const verifyPublicProfile = async () => { 97 + const verifyPublicProfile = async (): Promise<FlexibleRecord> => { 55 98 const result = await pollJson( 56 99 "public profile indexing", 57 100 () => 58 101 `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 59 102 ({ ok, json }) => 60 - ok && typeof json?.postsCount === "number" && json.postsCount > 0, 103 + ok && 104 + isRecord(json) && 105 + typeof json.postsCount === "number" && 106 + json.postsCount > 0, 61 107 publicCheckTimeoutMs, 62 108 ); 109 + const json = isRecord(result.json) ? result.json : {}; 63 110 return { 64 - postsCount: result.json.postsCount, 65 - followersCount: result.json.followersCount, 66 - followsCount: result.json.followsCount, 67 - avatar: result.json.avatar, 68 - description: result.json.description, 111 + postsCount: json.postsCount, 112 + followersCount: json.followersCount, 113 + followsCount: json.followsCount, 114 + avatar: json.avatar, 115 + description: json.description, 69 116 }; 70 117 }; 71 118 72 - const verifyPublicProfileAfterEdit = async () => { 119 + const verifyPublicProfileAfterEdit = async (): Promise<FlexibleRecord> => { 73 120 const result = await pollJson( 74 121 "public profile edit indexing", 75 122 () => 76 123 `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(config.handle)}`, 77 124 ({ ok, json }) => 78 125 ok && 79 - json?.description === config.profileNote && 80 - typeof json?.avatar === "string" && 126 + isRecord(json) && 127 + json.description === config.profileNote && 128 + typeof json.avatar === "string" && 81 129 json.avatar.length > 0, 82 130 publicCheckTimeoutMs, 83 131 ); 84 - const avatarResult = await fetchStatus(result.json.avatar); 132 + const json = isRecord(result.json) ? result.json : {}; 133 + if (!isString(json.avatar)) { 134 + throw new Error("public profile edit indexing returned no avatar URL"); 135 + } 136 + const avatarResult = await fetchStatus(json.avatar); 85 137 if (!avatarResult.ok) { 86 - throw new Error(`public avatar URL returned ${avatarResult.status}`); 138 + throw new Error( 139 + `public avatar URL returned ${String(avatarResult.status)}`, 140 + ); 87 141 } 88 142 return { 89 - avatar: result.json.avatar, 143 + avatar: json.avatar, 90 144 avatarStatus: avatarResult.status, 91 - description: result.json.description, 145 + description: json.description, 92 146 }; 93 147 }; 94 148 95 - const verifyLocalProfileAfterEdit = async () => { 149 + const verifyLocalProfileAfterEdit = async (): Promise<FlexibleRecord> => { 96 150 const didResult = await pollJson( 97 151 "local handle resolution after profile edit", 98 152 () => 99 153 `${config.pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(config.handle)}`, 100 - ({ ok, json }) => 101 - ok && typeof json?.did === "string" && json.did.length > 0, 154 + ({ ok, json }) => { 155 + const resolved = isRecord(json) ? json : undefined; 156 + const did = isString(resolved?.did) ? resolved.did : undefined; 157 + return ok && did !== undefined && did.length > 0; 158 + }, 102 159 30000, 103 160 ); 104 - const did = didResult.json.did; 161 + const didJson = isRecord(didResult.json) ? didResult.json : undefined; 162 + const did = isString(didJson?.did) ? didJson.did : undefined; 163 + if (did === undefined) { 164 + throw new Error("local handle resolution did not return a did"); 165 + } 105 166 const result = await pollJson( 106 167 "local profile record after edit", 107 168 () => 108 169 `${config.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, 109 - ({ ok, json }) => 110 - ok && 111 - json?.value?.description === config.profileNote && 112 - typeof json?.value?.avatar?.ref?.$link === "string" && 113 - json.value.avatar.ref.$link.length > 0, 170 + ({ ok, json }) => { 171 + const profileRecord = isRecord(json) ? json : undefined; 172 + const value = isRecord(profileRecord?.value) 173 + ? profileRecord.value 174 + : undefined; 175 + const avatar = isRecord(value?.avatar) ? value.avatar : undefined; 176 + const ref = isRecord(avatar?.ref) ? avatar.ref : undefined; 177 + return ( 178 + ok && 179 + value?.description === config.profileNote && 180 + typeof ref?.$link === "string" && 181 + ref.$link.length > 0 182 + ); 183 + }, 114 184 30000, 115 185 ); 186 + const json = isRecord(result.json) ? result.json : {}; 187 + const value = isRecord(json.value) ? json.value : {}; 188 + const avatar = isRecord(value.avatar) ? value.avatar : {}; 189 + const ref = isRecord(avatar.ref) ? avatar.ref : {}; 116 190 return { 117 191 did, 118 - avatarCid: result.json.value.avatar.ref.$link, 119 - description: result.json.value.description, 192 + avatarCid: ref.$link, 193 + description: value.description, 120 194 }; 121 195 }; 122 196 123 - const editProfile = () => 197 + const editProfile = (): Promise<{ 198 + avatarFile: string; 199 + profileNote: string; 200 + }> => 124 201 pageActions.editProfile(page, { 125 202 profileNote: config.profileNote, 126 203 handle: config.handle,
+4 -1
src/browser/lib/single-scenario.ts
··· 1 + import type { SingleScenarioContext } from "./browser-types.js"; 1 2 import { 2 3 runSingleBootstrapPhase, 3 4 runSingleCleanupPhase, ··· 5 6 runSingleTargetInteractionPhase, 6 7 } from "./single-scenario/phases.js"; 7 8 8 - export const runSingleScenario = async (ctx) => { 9 + export const runSingleScenario = async ( 10 + ctx: SingleScenarioContext, 11 + ): Promise<void> => { 9 12 await runSingleBootstrapPhase(ctx); 10 13 await runSingleTargetInteractionPhase(ctx); 11 14 await runSingleProfilePhase(ctx);
+68 -50
src/browser/lib/single-scenario/phases.ts
··· 1 - export const runSingleBootstrapPhase = async (ctx) => { 1 + import type { SingleScenarioContext } from "../browser-types.js"; 2 + 3 + export const runSingleBootstrapPhase = async ( 4 + ctx: SingleScenarioContext, 5 + ): Promise<void> => { 2 6 const { 3 7 step, 4 8 config, ··· 19 23 ensureNotLiked, 20 24 ensureNotReposted, 21 25 } = ctx; 26 + const postText = 27 + typeof config.postText === "string" ? config.postText : "unknown post"; 22 28 23 29 await step("login", login); 24 30 await step("age-assurance", completeAgeAssuranceIfNeeded, { optional: true }); 25 - await step("compose-own-post", () => composePost(config.postText)); 26 - if (config.publicChecks !== false) { 31 + await step("compose-own-post", () => composePost(postText)); 32 + if (config.publicChecks) { 27 33 await step("public-resolve-handle", verifyPublicHandleResolution); 28 34 await step("public-profile", verifyPublicProfile); 29 35 await step("public-author-feed", verifyPublicAuthorFeed); ··· 32 38 33 39 const ownPost = await step("find-own-post", async () => { 34 40 const started = Date.now(); 35 - let lastError; 41 + let lastError: Error | undefined; 36 42 while (Date.now() - started < 60000) { 37 43 try { 38 44 await gotoProfile(config.handle); ··· 41 47 .getByTestId("postsFeed") 42 48 .first() 43 49 .waitFor({ state: "visible", timeout: 15000 }); 44 - const row = await findRowByPrimaryText(config.postText, 15000); 50 + const row = await findRowByPrimaryText(postText, 15000); 45 51 const rowTestId = await row.getAttribute("data-testid"); 46 52 return { note: "found own post", rowFound: true, rowTestId }; 47 53 } catch (error) { 48 - lastError = error; 54 + lastError = error instanceof Error ? error : new Error(String(error)); 49 55 } 50 56 51 57 await page ··· 56 62 57 63 throw ( 58 64 lastError ?? 59 - new Error(`feed item with primary text not found: ${config.postText}`) 65 + new Error(`feed item with primary text not found: ${postText}`) 60 66 ); 61 67 }); 62 68 ··· 64 70 return; 65 71 } 66 72 67 - const row = await findRowByPrimaryText(config.postText); 73 + const row = await findRowByPrimaryText(postText); 68 74 await step("like-own-post", () => ensureLiked(row), { optional: true }); 69 75 await step("repost-own-post", () => ensureReposted(row), { optional: true }); 70 76 await step("quote-own-post", () => clickQuote(row, config.quoteText), { ··· 74 80 "reply-own-post", 75 81 async () => { 76 82 await gotoProfile(config.handle); 77 - const refreshed = await findRowByPrimaryText(config.postText, 60000); 83 + const refreshed = await findRowByPrimaryText(postText, 60000); 78 84 await clickReply(refreshed, config.replyText); 79 85 }, 80 86 { optional: true }, ··· 83 89 "unlike-own-post", 84 90 async () => { 85 91 await gotoProfile(config.handle); 86 - const refreshed = await findRowByPrimaryText(config.postText, 60000); 87 - return ensureNotLiked(refreshed); 92 + const refreshed = await findRowByPrimaryText(postText, 60000); 93 + return await ensureNotLiked(refreshed); 88 94 }, 89 95 { optional: true }, 90 96 ); ··· 92 98 "undo-repost-own-post", 93 99 async () => { 94 100 await gotoProfile(config.handle); 95 - const refreshed = await findRowByPrimaryText(config.postText, 60000); 96 - return ensureNotReposted(refreshed); 101 + const refreshed = await findRowByPrimaryText(postText, 60000); 102 + return await ensureNotReposted(refreshed); 97 103 }, 98 104 { optional: true }, 99 105 ); 100 106 }; 101 107 102 - export const runSingleTargetInteractionPhase = async (ctx) => { 108 + export const runSingleTargetInteractionPhase = async ( 109 + ctx: SingleScenarioContext, 110 + ): Promise<void> => { 103 111 const { 104 112 step, 105 113 config, ··· 119 127 maybeUnfollowTarget, 120 128 openNotifications, 121 129 } = ctx; 130 + const targetShortHandle = config.targetHandle.replace(/^@/, ""); 131 + const targetHandle = 132 + typeof config.targetHandle === "string" 133 + ? config.targetHandle 134 + : "unknown target"; 135 + const quoteText = 136 + typeof config.quoteText === "string" ? config.quoteText : "quote"; 137 + const replyText = 138 + typeof config.replyText === "string" ? config.replyText : "reply"; 122 139 123 140 await step("target-profile", async () => { 124 - await gotoProfile(config.targetHandle); 141 + await gotoProfile(targetHandle); 125 142 }); 126 143 await step("follow-target", maybeFollowTarget, { optional: true }); 127 144 ··· 129 146 "inspect-target-post", 130 147 async () => { 131 148 const row = await findFirstFeedItem(20000); 132 - const preview = ((await row.textContent()) || "") 149 + const preview = ((await row.textContent()) ?? "") 133 150 .replace(/\s+/g, " ") 134 151 .slice(0, 160); 135 152 return { note: preview }; ··· 141 158 "bookmark-target-post", 142 159 async () => { 143 160 const row = await findFirstFeedItem(20000); 144 - return ensureBookmarked(row); 161 + return await ensureBookmarked(row); 145 162 }, 146 163 { optional: true }, 147 164 ); ··· 150 167 "saved-posts-page", 151 168 async () => { 152 169 await openSavedPosts(); 153 - const handleText = page 154 - .getByText(`@${config.targetHandle.replace(/^@/, "")}`) 155 - .first(); 170 + const handleText = page.getByText(`@${targetShortHandle}`).first(); 156 171 await handleText.waitFor({ state: "visible", timeout: 20000 }); 157 - return { note: `saved post by ${config.targetHandle}` }; 172 + return { note: `saved post by ${targetHandle}` }; 158 173 }, 159 174 { optional: true }, 160 175 ); ··· 162 177 await step( 163 178 "like-target-post", 164 179 async () => { 165 - await gotoProfile(config.targetHandle); 180 + await gotoProfile(targetHandle); 166 181 const row = await findFirstFeedItem(20000); 167 - return ensureLiked(row); 182 + return await ensureLiked(row); 168 183 }, 169 184 { optional: true }, 170 185 ); ··· 172 187 await step( 173 188 "repost-target-post", 174 189 async () => { 175 - await gotoProfile(config.targetHandle); 190 + await gotoProfile(targetHandle); 176 191 const row = await findFirstFeedItem(20000); 177 - return ensureReposted(row); 192 + return await ensureReposted(row); 178 193 }, 179 194 { optional: true }, 180 195 ); ··· 182 197 await step( 183 198 "quote-target-post", 184 199 async () => { 185 - await gotoProfile(config.targetHandle); 200 + await gotoProfile(targetHandle); 186 201 const row = await findFirstFeedItem(20000); 187 - await clickQuote( 188 - row, 189 - `${config.quoteText} to @${config.targetHandle.replace(/^@/, "")}`, 190 - ); 202 + await clickQuote(row, `${quoteText} to @${targetShortHandle}`); 191 203 return { note: "quoted target post" }; 192 204 }, 193 205 { optional: true }, ··· 196 208 await step( 197 209 "reply-target-post", 198 210 async () => { 199 - await gotoProfile(config.targetHandle); 211 + await gotoProfile(targetHandle); 200 212 const row = await findFirstFeedItem(20000); 201 - await clickReply( 202 - row, 203 - `${config.replyText} to @${config.targetHandle.replace(/^@/, "")}`, 204 - ); 213 + await clickReply(row, `${replyText} to @${targetShortHandle}`); 205 214 return { note: "replied to target post" }; 206 215 }, 207 216 { optional: true }, ··· 210 219 await step( 211 220 "unlike-target-post", 212 221 async () => { 213 - await gotoProfile(config.targetHandle); 222 + await gotoProfile(targetHandle); 214 223 const row = await findFirstFeedItem(20000); 215 - return ensureNotLiked(row); 224 + return await ensureNotLiked(row); 216 225 }, 217 226 { optional: true }, 218 227 ); ··· 220 229 await step( 221 230 "undo-repost-target-post", 222 231 async () => { 223 - await gotoProfile(config.targetHandle); 232 + await gotoProfile(targetHandle); 224 233 const row = await findFirstFeedItem(20000); 225 - return ensureNotReposted(row); 234 + return await ensureNotReposted(row); 226 235 }, 227 236 { optional: true }, 228 237 ); ··· 230 239 await step( 231 240 "unbookmark-target-post", 232 241 async () => { 233 - await gotoProfile(config.targetHandle); 242 + await gotoProfile(targetHandle); 234 243 const row = await findFirstFeedItem(20000); 235 - return ensureNotBookmarked(row); 244 + return await ensureNotBookmarked(row); 236 245 }, 237 246 { optional: true }, 238 247 ); ··· 240 249 await step( 241 250 "unfollow-target", 242 251 async () => { 243 - await gotoProfile(config.targetHandle); 244 - return maybeUnfollowTarget(); 252 + await gotoProfile(targetHandle); 253 + return await maybeUnfollowTarget(); 245 254 }, 246 255 { optional: true }, 247 256 ); ··· 249 258 await step( 250 259 "refollow-target", 251 260 async () => { 252 - await gotoProfile(config.targetHandle); 253 - return maybeFollowTarget(); 261 + await gotoProfile(targetHandle); 262 + return await maybeFollowTarget(); 254 263 }, 255 264 { optional: true }, 256 265 ); ··· 269 278 ); 270 279 }; 271 280 272 - export const runSingleProfilePhase = async (ctx) => { 281 + export const runSingleProfilePhase = async ( 282 + ctx: SingleScenarioContext, 283 + ): Promise<void> => { 273 284 const { 274 285 step, 275 286 config, ··· 288 299 await editProfile(); 289 300 }); 290 301 await step("local-profile-after-edit", verifyLocalProfileAfterEdit); 291 - if (config.publicChecks !== false) { 302 + if (config.publicChecks) { 292 303 await step("public-profile-after-edit", verifyPublicProfileAfterEdit); 293 304 } 294 305 }; 295 306 296 - export const runSingleCleanupPhase = async (ctx) => { 307 + export const runSingleCleanupPhase = async ( 308 + ctx: SingleScenarioContext, 309 + ): Promise<void> => { 297 310 const { 298 311 step, 299 312 config, ··· 301 314 openProfileTab, 302 315 maybeDeleteOwnPostByText, 303 316 } = ctx; 317 + const targetShortHandle = config.targetHandle.replace(/^@/, ""); 318 + const quoteText = 319 + typeof config.quoteText === "string" ? config.quoteText : "quote"; 320 + const replyText = 321 + typeof config.replyText === "string" ? config.replyText : "reply"; 304 322 305 323 await step( 306 324 "cleanup-own-posts-tab", ··· 314 332 315 333 await step("delete-own-target-quote", () => { 316 334 return maybeDeleteOwnPostByText( 317 - `${config.quoteText} to @${config.targetHandle.replace(/^@/, "")}`, 335 + `${quoteText} to @${targetShortHandle}`, 318 336 "deleted target quote post", 319 337 ); 320 338 }); ··· 339 357 340 358 await step("delete-own-target-reply", () => { 341 359 return maybeDeleteOwnPostByText( 342 - `${config.replyText} to @${config.targetHandle.replace(/^@/, "")}`, 360 + `${replyText} to @${targetShortHandle}`, 343 361 "deleted target reply post", 344 362 ); 345 363 });
+8 -6
src/browser/run-dual.ts
··· 17 17 errorMessage, 18 18 sleep, 19 19 } from "./lib/runtime-utils.js"; 20 - import type { DualRunConfig, FlexibleRecord, Summary } from "../types.js"; 20 + import type { DualRunConfig, Summary } from "../types.js"; 21 21 import { createDualRunConfig } from "../config.js"; 22 + import { parseJsonRecord } from "../guards.js"; 22 23 23 24 export const runDualFromConfig = async ( 24 25 config: DualRunConfig, ··· 163 164 configPath: string, 164 165 ): Promise<Summary> => { 165 166 const config = createDualRunConfig( 166 - JSON.parse(await fs.readFile(configPath, "utf8")) as FlexibleRecord, 167 + parseJsonRecord(await fs.readFile(configPath, "utf8"), "dual config"), 167 168 ); 168 169 return await runDualFromConfig(config); 169 170 }; 170 171 171 172 export const runDualFromArgv = async (argv = process.argv): Promise<number> => { 172 - const configPath = argv[2]; 173 - if (configPath === undefined) { 173 + const configPath = argv[2] ?? ""; 174 + if (configPath.length === 0) { 174 175 process.stderr.write( 175 176 "usage: node dist/src/browser/run-dual.js <config.json>\n", 176 177 ); ··· 180 181 return summary.ok === true ? 0 : 1; 181 182 }; 182 183 184 + const entryScript = process.argv[1] ?? ""; 183 185 const isDirectExecution = 184 - Boolean(process.argv[1]) && 185 - fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); 186 + entryScript.length > 0 && 187 + fileURLToPath(import.meta.url) === path.resolve(entryScript); 186 188 187 189 if (isDirectExecution) { 188 190 const exitCode = await runDualFromArgv(process.argv);
+30 -18
src/browser/run-single.ts
··· 29 29 import type { 30 30 FetchJsonResult, 31 31 FetchStatusResult, 32 - FlexibleRecord, 33 - SingleRunConfig, 34 32 Summary, 33 + SingleRunConfig, 35 34 } from "../types.js"; 36 35 import { createSingleRunConfig } from "../config.js"; 36 + import { parseJsonRecord } from "../guards.js"; 37 37 38 38 export const runSingleFromConfig = async ( 39 39 config: SingleRunConfig, ··· 63 63 }); 64 64 const page = await context.newPage(); 65 65 66 - if (config.browserExecutablePath) { 66 + if ( 67 + config.browserExecutablePath !== undefined && 68 + config.browserExecutablePath.length > 0 69 + ) { 67 70 summary.notes.push( 68 71 `requested browser executable: ${config.browserExecutablePath}`, 69 72 ); ··· 87 90 }), 88 91 }); 89 92 93 + interface TimeoutOptions { 94 + timeoutMs?: number; 95 + } 96 + 90 97 const wait = (ms: number): Promise<void> => page.waitForTimeout(ms); 91 98 const fetchJson = ( 92 99 url: string, 93 - options: FlexibleRecord = {}, 94 - ): Promise<FetchJsonResult> => 95 - fetchJsonWithTimeout(url, { 100 + options: TimeoutOptions = {}, 101 + ): Promise<FetchJsonResult> => { 102 + const timeoutValue = options.timeoutMs; 103 + const timeoutMs = typeof timeoutValue === "number" ? timeoutValue : 30000; 104 + return fetchJsonWithTimeout(url, { 96 105 headers: { accept: "application/json" }, 97 - timeoutMs: 98 - typeof options["timeoutMs"] === "number" ? options["timeoutMs"] : 30000, 106 + timeoutMs, 99 107 }); 108 + }; 100 109 101 110 const fetchStatus = ( 102 111 url: string, 103 - options: FlexibleRecord = {}, 104 - ): Promise<FetchStatusResult> => 105 - fetchStatusWithTimeout(url, { 106 - timeoutMs: 107 - typeof options["timeoutMs"] === "number" ? options["timeoutMs"] : 30000, 112 + options: TimeoutOptions = {}, 113 + ): Promise<FetchStatusResult> => { 114 + const timeoutValue = options.timeoutMs; 115 + const timeoutMs = typeof timeoutValue === "number" ? timeoutValue : 30000; 116 + return fetchStatusWithTimeout(url, { 117 + timeoutMs, 108 118 }); 119 + }; 109 120 110 121 const pollJson = ( 111 122 name: string, ··· 168 179 configPath: string, 169 180 ): Promise<Summary> => { 170 181 const config = createSingleRunConfig( 171 - JSON.parse(await fs.readFile(configPath, "utf8")) as FlexibleRecord, 182 + parseJsonRecord(await fs.readFile(configPath, "utf8"), "single config"), 172 183 ); 173 184 return await runSingleFromConfig(config); 174 185 }; ··· 176 187 export const runSingleFromArgv = async ( 177 188 argv = process.argv, 178 189 ): Promise<number> => { 179 - const configPath = argv[2]; 180 - if (configPath === undefined) { 190 + const configPath = argv[2] ?? ""; 191 + if (configPath.length === 0) { 181 192 process.stderr.write( 182 193 "usage: node dist/src/browser/run-single.js <config.json>\n", 183 194 ); ··· 187 198 return summary.ok === true ? 0 : 1; 188 199 }; 189 200 201 + const entryScript = process.argv[1] ?? ""; 190 202 const isDirectExecution = 191 - Boolean(process.argv[1]) && 192 - fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); 203 + entryScript.length > 0 && 204 + fileURLToPath(import.meta.url) === path.resolve(entryScript); 193 205 194 206 if (isDirectExecution) { 195 207 const exitCode = await runSingleFromArgv(process.argv);
+39 -49
src/config.ts
··· 5 5 SingleRunConfig, 6 6 SuiteConfig, 7 7 } from "./types.js"; 8 + import { getRecord } from "./guards.js"; 8 9 9 10 const DEFAULTS = { 10 11 appUrl: "https://bsky.app", ··· 15 16 headless: true, 16 17 strictErrors: false, 17 18 publicChecks: true, 19 + }; 20 + 21 + const DEFAULT_ACCOUNT_TEXTS = { 22 + postText: "browser smoke root post", 23 + mediaPostText: "browser smoke image post", 24 + quoteText: "browser smoke quote post", 25 + replyText: "browser smoke reply post", 26 + profileNote: "browser smoke profile note", 18 27 }; 19 28 20 29 const requireString = (value: unknown, label: string): string => { ··· 98 107 handle: requireString(handle, "account.handle"), 99 108 password: requireString(password, "account.password"), 100 109 birthdate: optionalString(birthdate) ?? DEFAULTS.birthdate, 110 + postText: optionalString(postText) ?? DEFAULT_ACCOUNT_TEXTS.postText, 111 + mediaPostText: 112 + optionalString(mediaPostText) ?? DEFAULT_ACCOUNT_TEXTS.mediaPostText, 113 + quoteText: optionalString(quoteText) ?? DEFAULT_ACCOUNT_TEXTS.quoteText, 114 + replyText: optionalString(replyText) ?? DEFAULT_ACCOUNT_TEXTS.replyText, 115 + profileNote: 116 + optionalString(profileNote) ?? DEFAULT_ACCOUNT_TEXTS.profileNote, 101 117 cleanupPostPrefixes: normalizeCleanupPrefixes(cleanupPostPrefixes), 102 118 ...rest, 103 119 }; ··· 107 123 normalized.loginIdentifier = login; 108 124 } 109 125 110 - const post = optionalString(postText); 111 - const mediaPost = optionalString(mediaPostText); 112 - const quote = optionalString(quoteText); 113 - const reply = optionalString(replyText); 114 - const note = optionalString(profileNote); 115 - 116 - if (post !== undefined) { 117 - normalized.postText = post; 118 - } 119 - if (mediaPost !== undefined) { 120 - normalized.mediaPostText = mediaPost; 121 - } 122 - if (quote !== undefined) { 123 - normalized.quoteText = quote; 124 - } 125 - if (reply !== undefined) { 126 - normalized.replyText = reply; 127 - } 128 - if (note !== undefined) { 129 - normalized.profileNote = note; 130 - } 131 - 132 126 return normalized; 133 127 }; 134 128 ··· 149 143 adapter, 150 144 ...rest 151 145 }: FlexibleRecord = {}): SuiteConfig => { 152 - const normalized = { 146 + const normalizedBase = { 153 147 pdsUrl: requireString(pdsUrl, "pdsUrl"), 154 148 artifactsDir: requireString(artifactsDir, "artifactsDir"), 155 149 appUrl: optionalString(appUrl) ?? DEFAULTS.appUrl, ··· 162 156 strictErrors: Boolean(strictErrors), 163 157 publicChecks: Boolean(publicChecks), 164 158 ...rest, 165 - } as SuiteConfig; 159 + }; 166 160 167 161 const derivedPdsHost = 168 - optionalString(pdsHost) ?? derivePdsHost(normalized.pdsUrl); 162 + optionalString(pdsHost) ?? derivePdsHost(normalizedBase.pdsUrl); 169 163 if (derivedPdsHost === undefined) { 170 164 throw new Error("pdsHost could not be derived from pdsUrl"); 171 165 } 172 - normalized.pdsHost = derivedPdsHost; 173 166 174 167 const maybeTarget = optionalString(targetHandle); 175 - if (maybeTarget !== undefined) { 176 - normalized.targetHandle = maybeTarget; 177 - } 178 - 179 168 const maybeRemoteReplyPostUrl = optionalPostUrl( 180 169 remoteReplyPostUrl, 181 170 "remoteReplyPostUrl", 182 171 ); 183 - if (maybeRemoteReplyPostUrl !== undefined) { 184 - normalized.remoteReplyPostUrl = maybeRemoteReplyPostUrl; 185 - } 186 - 187 172 const maybeBrowserExecutablePath = optionalString(browserExecutablePath); 188 - if (maybeBrowserExecutablePath !== undefined) { 189 - normalized.browserExecutablePath = maybeBrowserExecutablePath; 190 - } 191 - 192 173 const maybeAdapter = optionalString(adapter); 193 - if (maybeAdapter !== undefined) { 194 - normalized.adapter = maybeAdapter; 195 - } 196 - 197 - return normalized; 174 + return { 175 + ...normalizedBase, 176 + pdsHost: derivedPdsHost, 177 + ...(maybeTarget !== undefined ? { targetHandle: maybeTarget } : {}), 178 + ...(maybeRemoteReplyPostUrl !== undefined 179 + ? { remoteReplyPostUrl: maybeRemoteReplyPostUrl } 180 + : {}), 181 + ...(maybeBrowserExecutablePath !== undefined 182 + ? { browserExecutablePath: maybeBrowserExecutablePath } 183 + : {}), 184 + ...(maybeAdapter !== undefined ? { adapter: maybeAdapter } : {}), 185 + }; 198 186 }; 199 187 200 188 export const createSingleRunConfig = ({ ··· 206 194 if (suite.targetHandle === undefined) { 207 195 throw new Error("targetHandle is required for single-mode runs"); 208 196 } 197 + const normalizedAccount = getRecord(account) ?? {}; 209 198 return { 210 199 ...suite, 211 - ...createAccountConfig((account as FlexibleRecord | undefined) ?? {}), 200 + ...createAccountConfig(normalizedAccount), 201 + targetHandle: suite.targetHandle, 212 202 editProfile: Boolean(editProfile), 213 - } as SingleRunConfig; 203 + }; 214 204 }; 215 205 216 206 export const createDualRunConfig = ({ ··· 219 209 accountSource, 220 210 ...rest 221 211 }: FlexibleRecord = {}): DualRunConfig => { 212 + const normalizedPrimary = getRecord(primary) ?? {}; 213 + const normalizedSecondary = getRecord(secondary) ?? {}; 222 214 const normalized: DualRunConfig = { 223 215 ...createSuiteConfig(rest), 224 - primary: createAccountConfig((primary as FlexibleRecord | undefined) ?? {}), 225 - secondary: createAccountConfig( 226 - (secondary as FlexibleRecord | undefined) ?? {}, 227 - ), 216 + primary: createAccountConfig(normalizedPrimary), 217 + secondary: createAccountConfig(normalizedSecondary), 228 218 }; 229 219 230 220 const maybeAccountSource = optionalString(accountSource);
+64
src/guards.ts
··· 1 + import type { FlexibleRecord } from "./types.js"; 2 + 3 + export const isRecord = (value: unknown): value is FlexibleRecord => 4 + typeof value === "object" && value !== null && !Array.isArray(value); 5 + 6 + export const isString = (value: unknown): value is string => 7 + typeof value === "string"; 8 + 9 + export const isNumber = (value: unknown): value is number => 10 + typeof value === "number" && Number.isFinite(value); 11 + 12 + export const isBoolean = (value: unknown): value is boolean => 13 + typeof value === "boolean"; 14 + 15 + export const getRecord = (value: unknown): FlexibleRecord | undefined => 16 + isRecord(value) ? value : undefined; 17 + 18 + export const getString = ( 19 + record: FlexibleRecord | undefined, 20 + key: string, 21 + ): string | undefined => { 22 + const value = record?.[key]; 23 + return isString(value) ? value : undefined; 24 + }; 25 + 26 + export const getNumber = ( 27 + record: FlexibleRecord | undefined, 28 + key: string, 29 + ): number | undefined => { 30 + const value = record?.[key]; 31 + return isNumber(value) ? value : undefined; 32 + }; 33 + 34 + export const getRecordValue = ( 35 + record: FlexibleRecord | undefined, 36 + key: string, 37 + ): FlexibleRecord | undefined => { 38 + const value = record?.[key]; 39 + return getRecord(value); 40 + }; 41 + 42 + export const getUnknown = ( 43 + record: FlexibleRecord | undefined, 44 + key: string, 45 + ): unknown => record?.[key]; 46 + 47 + export const getRecordArray = ( 48 + record: FlexibleRecord | undefined, 49 + key: string, 50 + ): FlexibleRecord[] => { 51 + const value = record?.[key]; 52 + return Array.isArray(value) ? value.filter(isRecord) : []; 53 + }; 54 + 55 + export const parseJsonRecord = ( 56 + text: string, 57 + label = "JSON payload", 58 + ): FlexibleRecord => { 59 + const parsed: unknown = JSON.parse(text); 60 + if (!isRecord(parsed)) { 61 + throw new Error(`${label} must be a JSON object`); 62 + } 63 + return parsed; 64 + };
+103 -24
src/types.ts
··· 5 5 value?: FlexibleRecord; 6 6 } 7 7 8 - export interface AccountConfig extends FlexibleRecord { 8 + export interface RenderedProfileCountsRaw { 9 + followersText?: string; 10 + followsText?: string; 11 + } 12 + 13 + export interface RenderedProfileCounts { 14 + followersCount: number; 15 + followsCount: number; 16 + raw: RenderedProfileCountsRaw; 17 + } 18 + 19 + export interface ProfileCountsSnapshot { 20 + rendered: RenderedProfileCounts; 21 + api: { 22 + followersCount?: number; 23 + followsCount?: number; 24 + }; 25 + } 26 + 27 + export interface AccountConfig { 9 28 handle: string; 10 29 password: string; 11 30 birthdate: string; 12 31 cleanupPostPrefixes: string[]; 13 32 loginIdentifier?: string; 14 - postText?: string; 15 - mediaPostText?: string; 16 - quoteText?: string; 17 - replyText?: string; 18 - profileNote?: string; 33 + postText: string; 34 + mediaPostText: string; 35 + quoteText: string; 36 + replyText: string; 37 + profileNote: string; 19 38 did?: string; 20 39 email?: string; 21 40 pdsUrl?: string; ··· 32 51 quotePost?: RepoRecord; 33 52 replyPost?: RepoRecord; 34 53 listRecord?: RepoRecord; 35 - baselineCounts?: FlexibleRecord; 54 + listItemRecord?: RepoRecord; 55 + remoteReplyPost?: RepoRecord; 56 + remoteReplyWasFollowingTarget?: boolean; 57 + session?: FlexibleRecord; 58 + baselineCounts?: ProfileCountsSnapshot; 36 59 } 37 60 38 - export interface SuiteConfig extends FlexibleRecord { 61 + export interface RuntimeDualAccount extends AccountConfig { 62 + did: string; 63 + accessJwt: string; 64 + shortHandle: string; 65 + listName: string; 66 + listDescription: string; 67 + listUpdatedName: string; 68 + listUpdatedDescription: string; 69 + session: SessionInfo; 70 + } 71 + 72 + export interface SessionInfo extends FlexibleRecord { 73 + accessJwt: string; 74 + did: string; 75 + } 76 + 77 + export interface SuiteConfig { 39 78 pdsUrl: string; 40 79 pdsHost: string; 41 80 artifactsDir: string; ··· 65 104 accountSource?: string; 66 105 } 67 106 68 - export interface ExampleBaseConfig extends FlexibleRecord { 107 + export interface ExampleBaseConfig { 69 108 pdsUrl: string; 70 109 targetHandle: string; 71 110 strictErrors: boolean; 72 111 primaryHandle: string; 73 112 secondaryHandle: string; 113 + remoteReplyPostUrl?: string; 74 114 } 75 115 76 - export interface Adapter extends FlexibleRecord { 116 + export interface Adapter { 77 117 name: string; 78 118 description: string; 79 119 accountStrategy: string; ··· 93 133 jsonOnly?: boolean; 94 134 } 95 135 96 - export interface SummaryStep extends FlexibleRecord { 136 + export interface SummaryStep { 97 137 name: string; 98 138 status: string; 99 139 at: string; 140 + error?: string; 141 + note?: string; 142 + rowFound?: boolean; 143 + rowTestId?: string | null; 144 + screenshot?: string; 145 + screenshots?: Record<string, string | undefined>; 100 146 } 101 147 102 - export interface SummaryUnexpected extends FlexibleRecord { 103 - console: FlexibleRecord[]; 104 - requestFailures: FlexibleRecord[]; 105 - httpFailures: FlexibleRecord[]; 106 - pageErrors: FlexibleRecord[]; 148 + export interface ConsoleEntry { 149 + page?: string; 150 + type: string; 151 + text: string; 152 + } 153 + 154 + export interface PageErrorEntry { 155 + page?: string; 156 + message: string; 157 + stack?: string; 158 + } 159 + 160 + export interface RequestFailureEntry { 161 + page?: string; 162 + url: string; 163 + method: string; 164 + errorText: string; 165 + } 166 + 167 + export interface HttpFailureEntry { 168 + page?: string; 169 + url: string; 170 + status: number; 171 + method: string; 172 + } 173 + 174 + export interface XrpcEntry { 175 + page?: string; 176 + url: string; 177 + status: number; 178 + method: string; 179 + } 180 + 181 + export interface SummaryUnexpected { 182 + console: ConsoleEntry[]; 183 + requestFailures: RequestFailureEntry[]; 184 + httpFailures: HttpFailureEntry[]; 185 + pageErrors: PageErrorEntry[]; 107 186 total?: number; 108 187 } 109 188 110 - export interface Summary extends FlexibleRecord { 189 + export interface Summary { 111 190 startedAt: string; 112 191 finishedAt?: string; 113 192 steps: SummaryStep[]; 114 - console: FlexibleRecord[]; 115 - pageErrors: FlexibleRecord[]; 116 - requestFailures: FlexibleRecord[]; 117 - httpFailures: FlexibleRecord[]; 118 - xrpc: FlexibleRecord[]; 193 + console: ConsoleEntry[]; 194 + pageErrors: PageErrorEntry[]; 195 + requestFailures: RequestFailureEntry[]; 196 + httpFailures: HttpFailureEntry[]; 197 + xrpc: XrpcEntry[]; 119 198 notes: string[]; 120 199 unexpected?: SummaryUnexpected; 121 200 fatal?: string; 122 201 ok?: boolean; 123 202 } 124 203 125 - export interface FetchJsonResult extends FlexibleRecord { 204 + export interface FetchJsonResult { 126 205 ok: boolean; 127 206 status: number; 128 207 text: string; 129 208 json: FlexibleRecord | FlexibleRecord[] | null; 130 209 } 131 210 132 - export interface FetchStatusResult extends FlexibleRecord { 211 + export interface FetchStatusResult { 133 212 ok: boolean; 134 213 status: number; 135 214 url: string;
+1 -2
tsconfig.json
··· 10 10 "skipLibCheck": true, 11 11 "resolveJsonModule": true, 12 12 "types": ["node"], 13 - "strict": false, 14 - "strictNullChecks": true, 13 + "strict": true, 15 14 "declaration": false, 16 15 "sourceMap": true 17 16 },