perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

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

Expand browser smoke into a reusable dual-account harness

alice f64ff173 d4e42d78

+1358 -10
+225 -10
script/perlsky-browser-smoke
··· 7 7 use File::Spec; 8 8 use FindBin qw($Bin); 9 9 use Getopt::Long qw(GetOptionsFromArray); 10 - use JSON::PP qw(encode_json); 10 + use JSON::PP qw(decode_json encode_json); 11 + use Mojo::UserAgent; 11 12 use POSIX qw(strftime); 12 13 13 14 my $root = abs_path(File::Spec->catdir($Bin, '..')); ··· 34 35 pds_url => $ENV{PERLSKY_BROWSER_PDS_URL} || 'https://perlsky.mosphere.at', 35 36 handle => $ENV{PERLSKY_BROWSER_HANDLE}, 36 37 password => $ENV{PERLSKY_BROWSER_PASSWORD}, 38 + secondary_handle => $ENV{PERLSKY_BROWSER_SECONDARY_HANDLE}, 39 + secondary_password => $ENV{PERLSKY_BROWSER_SECONDARY_PASSWORD}, 40 + secondary_email => $ENV{PERLSKY_BROWSER_SECONDARY_EMAIL}, 37 41 target_handle => $ENV{PERLSKY_BROWSER_TARGET_HANDLE} || 'alice.mosphere.at', 38 42 artifacts_dir => $ENV{PERLSKY_BROWSER_ARTIFACTS} || $default_artifacts, 39 43 birthdate => $ENV{PERLSKY_BROWSER_BIRTHDATE} || '1990-01-01', 44 + secondary_birthdate => $ENV{PERLSKY_BROWSER_SECONDARY_BIRTHDATE} || '1990-01-01', 40 45 public_api_url => $ENV{PERLSKY_BROWSER_PUBLIC_API_URL} || 'https://public.api.bsky.app', 41 46 public_check_timeout_ms => $ENV{PERLSKY_BROWSER_PUBLIC_CHECK_TIMEOUT_MS} || 180_000, 42 47 post_text => $ENV{PERLSKY_BROWSER_POST_TEXT}, 43 48 quote_text => $ENV{PERLSKY_BROWSER_QUOTE_TEXT}, 44 49 reply_text => $ENV{PERLSKY_BROWSER_REPLY_TEXT}, 45 50 profile_note => $ENV{PERLSKY_BROWSER_PROFILE_NOTE}, 51 + secondary_post_text => $ENV{PERLSKY_BROWSER_SECONDARY_POST_TEXT}, 52 + secondary_quote_text => $ENV{PERLSKY_BROWSER_SECONDARY_QUOTE_TEXT}, 53 + secondary_reply_text => $ENV{PERLSKY_BROWSER_SECONDARY_REPLY_TEXT}, 54 + secondary_profile_note => $ENV{PERLSKY_BROWSER_SECONDARY_PROFILE_NOTE}, 46 55 headful => $ENV{PERLSKY_BROWSER_HEADFUL} ? 1 : 0, 47 56 edit_profile => $ENV{PERLSKY_BROWSER_EDIT_PROFILE} ? 1 : 0, 48 57 public_checks => $ENV{PERLSKY_BROWSER_SKIP_PUBLIC_CHECKS} ? 0 : 1, ··· 56 65 print <<'USAGE'; 57 66 Usage: 58 67 script/perlsky-browser-smoke install 68 + script/perlsky-browser-smoke bootstrap-secondary [options] 69 + script/perlsky-browser-smoke bootstrap-pair [options] 59 70 script/perlsky-browser-smoke run [options] 71 + script/perlsky-browser-smoke run-dual [options] 72 + script/perlsky-browser-smoke run-dual-bootstrap [options] 73 + script/perlsky-browser-smoke run-pair-bootstrap [options] 60 74 61 75 Options: 62 76 --handle HANDLE 63 77 --password PASSWORD 78 + --secondary-handle HANDLE 79 + --secondary-password PASSWORD 80 + --secondary-email EMAIL 64 81 --target-handle HANDLE 65 82 --app-url URL 66 83 --pds-url URL 67 84 --artifacts-dir DIR 68 85 --birthdate YYYY-MM-DD 86 + --secondary-birthdate YYYY-MM-DD 69 87 --public-api-url URL 70 88 --public-check-timeout-ms MS 71 89 --browser-executable-path PATH ··· 73 91 --quote-text TEXT 74 92 --reply-text TEXT 75 93 --profile-note TEXT 94 + --secondary-post-text TEXT 95 + --secondary-quote-text TEXT 96 + --secondary-reply-text TEXT 97 + --secondary-profile-note TEXT 76 98 --headful 77 99 --edit-profile 78 100 --public-checks ··· 81 103 Environment: 82 104 PERLSKY_BROWSER_HANDLE 83 105 PERLSKY_BROWSER_PASSWORD 106 + PERLSKY_BROWSER_SECONDARY_HANDLE 107 + PERLSKY_BROWSER_SECONDARY_PASSWORD 108 + PERLSKY_BROWSER_SECONDARY_EMAIL 84 109 PERLSKY_BROWSER_TARGET_HANDLE 85 110 PERLSKY_BROWSER_APP_URL 86 111 PERLSKY_BROWSER_PDS_URL 87 112 PERLSKY_BROWSER_ARTIFACTS 88 113 PERLSKY_BROWSER_BIRTHDATE 114 + PERLSKY_BROWSER_SECONDARY_BIRTHDATE 89 115 PERLSKY_BROWSER_PUBLIC_API_URL 90 116 PERLSKY_BROWSER_PUBLIC_CHECK_TIMEOUT_MS 91 117 PERLSKY_BROWSER_EXECUTABLE_PATH ··· 93 119 PERLSKY_BROWSER_QUOTE_TEXT 94 120 PERLSKY_BROWSER_REPLY_TEXT 95 121 PERLSKY_BROWSER_PROFILE_NOTE 122 + PERLSKY_BROWSER_SECONDARY_POST_TEXT 123 + PERLSKY_BROWSER_SECONDARY_QUOTE_TEXT 124 + PERLSKY_BROWSER_SECONDARY_REPLY_TEXT 125 + PERLSKY_BROWSER_SECONDARY_PROFILE_NOTE 96 126 PERLSKY_BROWSER_HEADFUL=1 97 127 PERLSKY_BROWSER_EDIT_PROFILE=1 98 128 PERLSKY_BROWSER_SKIP_PUBLIC_CHECKS=1 ··· 105 135 \@ARGV, 106 136 'handle=s' => \$opt{handle}, 107 137 'password=s' => \$opt{password}, 138 + 'secondary-handle=s' => \$opt{secondary_handle}, 139 + 'secondary-password=s' => \$opt{secondary_password}, 140 + 'secondary-email=s' => \$opt{secondary_email}, 108 141 'target-handle=s' => \$opt{target_handle}, 109 142 'app-url=s' => \$opt{app_url}, 110 143 'pds-url=s' => \$opt{pds_url}, 111 144 'artifacts-dir=s' => \$opt{artifacts_dir}, 112 145 'birthdate=s' => \$opt{birthdate}, 146 + 'secondary-birthdate=s' => \$opt{secondary_birthdate}, 113 147 'public-api-url=s' => \$opt{public_api_url}, 114 148 'public-check-timeout-ms=i' => \$opt{public_check_timeout_ms}, 115 149 'browser-executable-path=s' => \$opt{browser_executable_path}, ··· 117 151 'quote-text=s' => \$opt{quote_text}, 118 152 'reply-text=s' => \$opt{reply_text}, 119 153 'profile-note=s' => \$opt{profile_note}, 154 + 'secondary-post-text=s' => \$opt{secondary_post_text}, 155 + 'secondary-quote-text=s' => \$opt{secondary_quote_text}, 156 + 'secondary-reply-text=s' => \$opt{secondary_reply_text}, 157 + 'secondary-profile-note=s' => \$opt{secondary_profile_note}, 120 158 'headful!' => \$opt{headful}, 121 159 'edit-profile!' => \$opt{edit_profile}, 122 160 'public-checks!' => \$opt{public_checks}, ··· 141 179 $opt{quote_text} //= "perlsky browser smoke quote $run_id"; 142 180 $opt{reply_text} //= "perlsky browser smoke reply $run_id"; 143 181 $opt{profile_note} //= "perlsky browser smoke profile edit $run_id"; 182 + $opt{secondary_post_text} //= "perlsky browser secondary post $run_id"; 183 + $opt{secondary_quote_text} //= "perlsky browser secondary quote $run_id"; 184 + $opt{secondary_reply_text} //= "perlsky browser secondary reply $run_id"; 185 + $opt{secondary_profile_note} //= "perlsky browser secondary profile edit $run_id"; 144 186 145 187 my ($pds_host) = $opt{pds_url} =~ m{\Ahttps?://([^/]+)}; 146 188 $pds_host //= $opt{pds_url}; 147 189 190 + if ($cmd eq 'bootstrap-secondary') { 191 + $opt{secondary_handle} //= "smokee2eb$run_id"; 192 + $opt{secondary_handle} =~ s/[^a-z0-9-]//g; 193 + $opt{secondary_password} //= 'E2e-2026-03-11'; 194 + $opt{secondary_email} //= sprintf('%s@example.com', $opt{secondary_handle}); 195 + my $account = bootstrap_secondary(\%opt); 196 + print encode_json($account), "\n"; 197 + exit 0; 198 + } 199 + 200 + if ($cmd eq 'bootstrap-pair') { 201 + my ($primary_account, $secondary_account) = bootstrap_pair(\%opt, $run_id); 202 + print encode_json({ 203 + primary => $primary_account, 204 + secondary => $secondary_account, 205 + }), "\n"; 206 + exit 0; 207 + } 208 + 148 209 my $config = { 149 210 appUrl => $opt{app_url}, 150 211 pdsUrl => $opt{pds_url}, 151 212 pdsHost => $pds_host, 152 - handle => $opt{handle}, 153 - password => $opt{password}, 154 213 targetHandle => $opt{target_handle}, 155 214 artifactsDir => $artifacts_dir, 156 - birthdate => $opt{birthdate}, 157 215 publicApiUrl => $opt{public_api_url}, 158 216 publicCheckTimeoutMs => 0 + $opt{public_check_timeout_ms}, 159 217 publicChecks => $opt{public_checks} ? JSON::PP::true : JSON::PP::false, 160 218 strictErrors => $opt{strict_errors} ? JSON::PP::true : JSON::PP::false, 161 219 browserExecutablePath => $opt{browser_executable_path}, 162 - postText => $opt{post_text}, 163 - quoteText => $opt{quote_text}, 164 - replyText => $opt{reply_text}, 165 - profileNote => $opt{profile_note}, 166 220 headless => $opt{headful} ? JSON::PP::false : JSON::PP::true, 167 - editProfile => $opt{edit_profile} ? JSON::PP::true : JSON::PP::false, 168 221 }; 169 222 223 + if ($cmd eq 'run-dual-bootstrap') { 224 + $opt{secondary_handle} //= "smokee2eb$run_id"; 225 + $opt{secondary_handle} =~ s/[^a-z0-9-]//g; 226 + $opt{secondary_password} //= 'E2e-2026-03-11'; 227 + $opt{secondary_email} //= sprintf('%s@example.com', $opt{secondary_handle}); 228 + my $account = bootstrap_secondary(\%opt); 229 + $opt{secondary_handle} = $account->{handle}; 230 + $opt{secondary_password} = $account->{password}; 231 + $opt{secondary_email} = $account->{email}; 232 + my $secondary_path = File::Spec->catfile($artifacts_dir, 'secondary-account.json'); 233 + open(my $fh, '>:raw', $secondary_path) or die "unable to write $secondary_path: $!"; 234 + print {$fh} encode_json($account); 235 + close $fh; 236 + $config->{secondaryAccount} = $account; 237 + $cmd = 'run-dual'; 238 + } 239 + 240 + if ($cmd eq 'run-pair-bootstrap') { 241 + my ($primary_account, $secondary_account) = bootstrap_pair(\%opt, $run_id); 242 + $opt{handle} = $primary_account->{handle}; 243 + $opt{password} = $primary_account->{password}; 244 + $opt{secondary_handle} = $secondary_account->{handle}; 245 + $opt{secondary_password} = $secondary_account->{password}; 246 + my $primary_path = File::Spec->catfile($artifacts_dir, 'primary-account.json'); 247 + open(my $pfh, '>:raw', $primary_path) or die "unable to write $primary_path: $!"; 248 + print {$pfh} encode_json($primary_account); 249 + close $pfh; 250 + my $secondary_path = File::Spec->catfile($artifacts_dir, 'secondary-account.json'); 251 + open(my $sfh, '>:raw', $secondary_path) or die "unable to write $secondary_path: $!"; 252 + print {$sfh} encode_json($secondary_account); 253 + close $sfh; 254 + $config->{primaryAccount} = $primary_account; 255 + $config->{secondaryAccount} = $secondary_account; 256 + $cmd = 'run-dual'; 257 + } 258 + 259 + if ($cmd eq 'run-dual') { 260 + die "--secondary-handle is required for run-dual\n" 261 + unless defined $opt{secondary_handle} && length $opt{secondary_handle}; 262 + die "--secondary-password is required for run-dual\n" 263 + unless defined $opt{secondary_password} && length $opt{secondary_password}; 264 + $config->{primary} = { 265 + handle => $opt{handle}, 266 + password => $opt{password}, 267 + birthdate => $opt{birthdate}, 268 + postText => $opt{post_text}, 269 + quoteText => $opt{quote_text}, 270 + replyText => $opt{reply_text}, 271 + profileNote => $opt{profile_note}, 272 + }; 273 + $config->{secondary} = { 274 + handle => $opt{secondary_handle}, 275 + password => $opt{secondary_password}, 276 + birthdate => $opt{secondary_birthdate}, 277 + postText => $opt{secondary_post_text}, 278 + quoteText => $opt{secondary_quote_text}, 279 + replyText => $opt{secondary_reply_text}, 280 + profileNote => $opt{secondary_profile_note}, 281 + }; 282 + } else { 283 + $config->{handle} = $opt{handle}; 284 + $config->{password} = $opt{password}; 285 + $config->{birthdate} = $opt{birthdate}; 286 + $config->{postText} = $opt{post_text}; 287 + $config->{quoteText} = $opt{quote_text}; 288 + $config->{replyText} = $opt{reply_text}; 289 + $config->{profileNote} = $opt{profile_note}; 290 + $config->{editProfile} = $opt{edit_profile} ? JSON::PP::true : JSON::PP::false; 291 + } 292 + 170 293 my $config_path = File::Spec->catfile($artifacts_dir, 'config.json'); 171 294 open(my $cfg, '>:raw', $config_path) or die "unable to write $config_path: $!"; 172 295 print {$cfg} encode_json($config); 173 296 close $cfg; 174 297 175 298 local $ENV{PLAYWRIGHT_BROWSERS_PATH} = $cache_dir; 176 - my $script = File::Spec->catfile($tools_dir, 'smoke.mjs'); 299 + my $script = File::Spec->catfile($tools_dir, $cmd eq 'run-dual' ? 'dual-smoke.mjs' : 'smoke.mjs'); 177 300 my @cmdline = ('node', $script, $config_path); 178 301 system(@cmdline) == 0 or die "browser smoke failed\n"; 179 302 ··· 188 311 my @cmdline = @_; 189 312 system(@cmdline) == 0 or die "command failed: @cmdline\n"; 190 313 } 314 + 315 + sub bootstrap_secondary { 316 + my ($opt) = @_; 317 + return _bootstrap_account( 318 + $opt, 319 + handle => $opt->{secondary_handle}, 320 + email => $opt->{secondary_email}, 321 + password => $opt->{secondary_password}, 322 + ); 323 + } 324 + 325 + sub bootstrap_pair { 326 + my ($opt, $run_id) = @_; 327 + my $primary_handle = "smokee2ea$run_id"; 328 + my $secondary_handle = "smokee2eb$run_id"; 329 + $primary_handle =~ s/[^a-z0-9-]//g; 330 + $secondary_handle =~ s/[^a-z0-9-]//g; 331 + my $password = 'E2e-2026-03-11'; 332 + my $primary_account = _bootstrap_account( 333 + $opt, 334 + handle => $primary_handle, 335 + email => sprintf('%s@example.com', $primary_handle), 336 + password => $password, 337 + ); 338 + my $secondary_account = _bootstrap_account( 339 + $opt, 340 + handle => $secondary_handle, 341 + email => sprintf('%s@example.com', $secondary_handle), 342 + password => $password, 343 + ); 344 + return ($primary_account, $secondary_account); 345 + } 346 + 347 + sub _bootstrap_account { 348 + my ($opt, %args) = @_; 349 + my $ua = Mojo::UserAgent->new; 350 + my $base = $opt->{pds_url}; 351 + $base =~ s{/\z}{}; 352 + my $session_tx = $ua->post( 353 + "$base/xrpc/com.atproto.server.createSession" => { 354 + 'content-type' => 'application/json', 355 + } => json => { 356 + identifier => $opt->{handle}, 357 + password => $opt->{password}, 358 + } 359 + ); 360 + die _tx_error($session_tx, 'createSession for primary smoke account failed') 361 + unless $session_tx->result->is_success; 362 + my $access_jwt = $session_tx->result->json->{accessJwt} 363 + or die "primary createSession response missing accessJwt\n"; 364 + 365 + my $invite_tx = $ua->post( 366 + "$base/xrpc/com.atproto.server.createInviteCode" => { 367 + 'content-type' => 'application/json', 368 + authorization => "Bearer $access_jwt", 369 + } => json => { 370 + useCount => 1, 371 + } 372 + ); 373 + die _tx_error($invite_tx, 'createInviteCode failed') 374 + unless $invite_tx->result->is_success; 375 + my $invite = $invite_tx->result->json->{code} 376 + or die "createInviteCode response missing code\n"; 377 + 378 + my $account_tx = $ua->post( 379 + "$base/xrpc/com.atproto.server.createAccount" => { 380 + 'content-type' => 'application/json', 381 + } => json => { 382 + handle => $args{handle}, 383 + email => $args{email}, 384 + password => $args{password}, 385 + inviteCode => $invite, 386 + } 387 + ); 388 + die _tx_error($account_tx, "createAccount failed for $args{handle}") 389 + unless $account_tx->result->is_success; 390 + my $json = $account_tx->result->json; 391 + return { 392 + inviteCode => $invite, 393 + handle => $json->{handle}, 394 + did => $json->{did}, 395 + email => $json->{email}, 396 + password => $args{password}, 397 + }; 398 + } 399 + 400 + sub _tx_error { 401 + my ($tx, $prefix) = @_; 402 + my $res = $tx->result; 403 + my $body = $res ? ($res->body // q()) : q(); 404 + return "$prefix: " . ($res ? $res->code : 'no-response') . " $body\n"; 405 + }
+1133
tools/browser-automation/dual-smoke.mjs
··· 1 + import fs from 'node:fs/promises'; 2 + import path from 'node:path'; 3 + import { chromium } from 'playwright'; 4 + 5 + const configPath = process.argv[2]; 6 + if (!configPath) { 7 + console.error('usage: node dual-smoke.mjs <config.json>'); 8 + process.exit(2); 9 + } 10 + 11 + const config = JSON.parse(await fs.readFile(configPath, 'utf8')); 12 + await fs.mkdir(config.artifactsDir, { recursive: true }); 13 + const appBaseUrl = config.appUrl.replace(/\/$/, ''); 14 + 15 + const summary = { 16 + startedAt: new Date().toISOString(), 17 + appUrl: config.appUrl, 18 + pdsUrl: config.pdsUrl, 19 + publicApiUrl: config.publicApiUrl, 20 + targetHandle: config.targetHandle, 21 + primaryHandle: config.primary?.handle, 22 + secondaryHandle: config.secondary?.handle, 23 + steps: [], 24 + console: [], 25 + pageErrors: [], 26 + requestFailures: [], 27 + httpFailures: [], 28 + xrpc: [], 29 + notes: [], 30 + }; 31 + 32 + const AVATAR_PNG_BASE64 = 33 + 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAV0lEQVR4nO3PQQ0AIBDAMMC/58MCP7KkVbDX1pk5A6gWUC2gWkC1gGoB1QKqBVQLqBZQLaBaQLWAagHVAqoFVAuoFlAtoFpAtYBqAdUCqgVUC6gWUC2gWkD1B4a2AX/y3CvgAAAAAElFTkSuQmCC'; 34 + 35 + const ignoredConsole = [ 36 + /events\.bsky\.app\/.*ERR_BLOCKED_BY_CLIENT/i, 37 + /slider-vertical/i, 38 + /Password field is not contained in a form/i, 39 + ]; 40 + 41 + const ignoredRequestFailure = [ 42 + { url: /events\.bsky\.app\//i, error: /ERR_(BLOCKED_BY_CLIENT|ABORTED)/i }, 43 + { url: /workers\.dev\/api\/config/i, error: /ERR_ABORTED/i }, 44 + { url: /app-config\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 45 + { url: /live-events\.workers\.bsky\.app\/config/i, error: /ERR_ABORTED/i }, 46 + { url: /events\.bsky\.app\/t/i, error: /ERR_ABORTED/i }, 47 + { url: /events\.bsky\.app\/gb\/api\/features\//i, error: /ERR_ABORTED/i }, 48 + { url: /(?:video\.bsky\.app\/watch|video\.cdn\.bsky\.app\/hls)\/.*\/(?:(?:playlist|video)\.m3u8|.*\.ts)/i, error: /ERR_ABORTED/i }, 49 + { url: /\/xrpc\/chat\.bsky\.convo\.getLog/i, error: /ERR_ABORTED/i }, 50 + ]; 51 + 52 + const ignoredHttpFailure = [ 53 + { url: /c\.1password\.com\/richicons/i, status: 404 }, 54 + ]; 55 + 56 + const browserCandidates = async () => { 57 + const base = { 58 + headless: config.headless !== false, 59 + chromiumSandbox: true, 60 + }; 61 + const candidates = []; 62 + if (config.browserExecutablePath) { 63 + candidates.push({ 64 + label: `executable:${config.browserExecutablePath}`, 65 + options: { ...base, executablePath: config.browserExecutablePath }, 66 + }); 67 + } 68 + const systemChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 69 + if (!config.browserExecutablePath) { 70 + try { 71 + await fs.access(systemChrome); 72 + candidates.push({ 73 + label: 'system-google-chrome', 74 + options: { ...base, executablePath: systemChrome }, 75 + }); 76 + } catch { 77 + // Fall back to Playwright-managed Chromium below. 78 + } 79 + } 80 + candidates.push({ 81 + label: 'playwright-chromium', 82 + options: { ...base, channel: 'chromium' }, 83 + }); 84 + return candidates; 85 + }; 86 + 87 + const launchBrowser = async () => { 88 + const errors = []; 89 + for (const candidate of await browserCandidates()) { 90 + try { 91 + const browser = await chromium.launch(candidate.options); 92 + summary.notes.push(`browser launch candidate succeeded: ${candidate.label}`); 93 + return browser; 94 + } catch (error) { 95 + errors.push(`${candidate.label}: ${String(error?.message ?? error)}`); 96 + } 97 + } 98 + throw new Error(`unable to launch browser via any candidate: ${errors.join(' | ')}`); 99 + }; 100 + 101 + const browser = await launchBrowser(); 102 + const primaryContext = await browser.newContext({ 103 + viewport: { width: 1440, height: 1000 }, 104 + }); 105 + const secondaryContext = await browser.newContext({ 106 + viewport: { width: 1440, height: 1000 }, 107 + }); 108 + const primaryPage = await primaryContext.newPage(); 109 + const secondaryPage = await secondaryContext.newPage(); 110 + 111 + const attachPageLogging = (name, page) => { 112 + page.on('console', (msg) => { 113 + summary.console.push({ 114 + page: name, 115 + type: msg.type(), 116 + text: msg.text(), 117 + }); 118 + }); 119 + 120 + page.on('pageerror', (error) => { 121 + summary.pageErrors.push({ 122 + page: name, 123 + message: String(error?.message ?? error), 124 + stack: error?.stack, 125 + }); 126 + }); 127 + 128 + page.on('requestfailed', (req) => { 129 + summary.requestFailures.push({ 130 + page: name, 131 + url: req.url(), 132 + method: req.method(), 133 + errorText: req.failure()?.errorText ?? 'unknown', 134 + }); 135 + }); 136 + 137 + page.on('response', (res) => { 138 + const status = res.status(); 139 + if (res.url().includes('/xrpc/')) { 140 + summary.xrpc.push({ 141 + page: name, 142 + url: res.url(), 143 + status, 144 + method: res.request().method(), 145 + }); 146 + if (summary.xrpc.length > 300) { 147 + summary.xrpc.shift(); 148 + } 149 + } 150 + if (status >= 400) { 151 + summary.httpFailures.push({ 152 + page: name, 153 + url: res.url(), 154 + status, 155 + method: res.request().method(), 156 + }); 157 + } 158 + }); 159 + }; 160 + 161 + attachPageLogging('primary', primaryPage); 162 + attachPageLogging('secondary', secondaryPage); 163 + 164 + const screenshot = async (pageName, name) => { 165 + const page = pageName === 'primary' ? primaryPage : secondaryPage; 166 + const file = path.join(config.artifactsDir, `${name}-${pageName}.png`); 167 + await page.screenshot({ path: file, fullPage: true }); 168 + return file; 169 + }; 170 + 171 + const recordStep = (name, status, extra = {}) => { 172 + summary.steps.push({ 173 + name, 174 + status, 175 + at: new Date().toISOString(), 176 + ...extra, 177 + }); 178 + }; 179 + 180 + const normalizeText = (text) => (text || '').replace(/\s+/g, ' ').trim(); 181 + 182 + const isIgnoredConsole = (entry) => 183 + ignoredConsole.some((pattern) => pattern.test(entry.text || '')); 184 + 185 + const isIgnoredRequestFailure = (entry) => 186 + ignoredRequestFailure.some( 187 + (rule) => rule.url.test(entry.url || '') && rule.error.test(entry.errorText || ''), 188 + ); 189 + 190 + const isIgnoredHttpFailure = (entry) => 191 + ignoredHttpFailure.some( 192 + (rule) => rule.url.test(entry.url || '') && (!rule.status || rule.status === entry.status), 193 + ); 194 + 195 + const step = async (name, fn, { optional = false, pageNames = [] } = {}) => { 196 + try { 197 + const result = await fn(); 198 + const screenshots = {}; 199 + for (const pageName of pageNames) { 200 + screenshots[pageName] = await screenshot(pageName, name); 201 + } 202 + recordStep(name, 'ok', { screenshots, ...(result ?? {}) }); 203 + return result; 204 + } catch (error) { 205 + const screenshots = {}; 206 + for (const pageName of pageNames) { 207 + screenshots[pageName] = await screenshot(pageName, `${name}-error`).catch(() => undefined); 208 + } 209 + recordStep(name, optional ? 'skipped' : 'failed', { 210 + screenshots, 211 + error: String(error?.message ?? error), 212 + }); 213 + if (!optional) { 214 + throw error; 215 + } 216 + return null; 217 + } 218 + }; 219 + 220 + const wait = async (page, ms) => { 221 + await page.waitForTimeout(ms); 222 + }; 223 + 224 + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 225 + 226 + const buttonText = async (locator) => { 227 + const label = await locator.getAttribute('aria-label'); 228 + if (label && label.trim()) { 229 + return label.trim(); 230 + } 231 + const text = await locator.innerText().catch(() => ''); 232 + return text.trim(); 233 + }; 234 + 235 + const dismissBlockingOverlays = async (page) => { 236 + const backdrop = page.locator('[aria-label*="click to close"]').last(); 237 + if (await backdrop.count()) { 238 + await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 239 + await wait(page, 400); 240 + } 241 + 242 + const dialog = page.locator('[role="dialog"][aria-modal="true"]').last(); 243 + if (await dialog.count()) { 244 + const close = dialog.getByRole('button', { name: /close/i }).last(); 245 + if (await close.count()) { 246 + await close.click({ noWaitAfter: true }).catch(() => undefined); 247 + await wait(page, 400); 248 + } 249 + await page.keyboard.press('Escape').catch(() => undefined); 250 + await wait(page, 400); 251 + } 252 + }; 253 + 254 + const fetchJson = async (url, options = {}) => { 255 + const res = await fetch(url, options); 256 + const text = await res.text(); 257 + let json; 258 + try { 259 + json = text ? JSON.parse(text) : null; 260 + } catch { 261 + json = null; 262 + } 263 + return { ok: res.ok, status: res.status, text, json }; 264 + }; 265 + 266 + const fetchStatus = async (url) => { 267 + const res = await fetch(url, { 268 + redirect: 'follow', 269 + }); 270 + return { ok: res.ok, status: res.status, url: res.url }; 271 + }; 272 + 273 + const xrpcJson = async (nsid, { method = 'GET', token, params, body } = {}) => { 274 + const url = new URL(`${config.pdsUrl}/xrpc/${nsid}`); 275 + if (params) { 276 + for (const [key, value] of Object.entries(params)) { 277 + url.searchParams.set(key, value); 278 + } 279 + } 280 + const headers = { accept: 'application/json' }; 281 + if (token) { 282 + headers.authorization = `Bearer ${token}`; 283 + } 284 + if (body !== undefined) { 285 + headers['content-type'] = 'application/json'; 286 + } 287 + return fetchJson(url.toString(), { 288 + method, 289 + headers, 290 + body: body === undefined ? undefined : JSON.stringify(body), 291 + }); 292 + }; 293 + 294 + const listOwnRecords = async (account, collection, limit = 100) => { 295 + const result = await xrpcJson('com.atproto.repo.listRecords', { 296 + token: account.accessJwt, 297 + params: { 298 + repo: account.did, 299 + collection, 300 + limit: String(limit), 301 + }, 302 + }); 303 + if (!result.ok) { 304 + throw new Error( 305 + `listRecords failed for ${account.handle} collection ${collection}: ${result.status} ${result.text}`, 306 + ); 307 + } 308 + return result.json?.records || []; 309 + }; 310 + 311 + const listOwnPosts = async (account, limit = 100) => 312 + listOwnRecords(account, 'app.bsky.feed.post', limit); 313 + 314 + const waitForOwnRecord = async (account, collection, predicate, timeoutMs = 60000) => { 315 + const started = Date.now(); 316 + while (Date.now() - started < timeoutMs) { 317 + const records = await listOwnRecords(account, collection); 318 + const match = records.find(predicate); 319 + if (match) { 320 + return match; 321 + } 322 + await sleep(2000); 323 + } 324 + throw new Error(`record not observed for ${account.handle} in ${collection}`); 325 + }; 326 + 327 + const waitForOwnPostRecord = async (account, text, timeoutMs = 60000) => { 328 + return waitForOwnRecord( 329 + account, 330 + 'app.bsky.feed.post', 331 + (record) => record?.value?.text === text, 332 + timeoutMs, 333 + ); 334 + }; 335 + 336 + const waitForFollowRecord = async (account, subjectDid, timeoutMs = 60000) => 337 + waitForOwnRecord( 338 + account, 339 + 'app.bsky.graph.follow', 340 + (record) => record?.value?.subject === subjectDid, 341 + timeoutMs, 342 + ); 343 + 344 + const createSession = async (handle, password) => { 345 + const result = await xrpcJson('com.atproto.server.createSession', { 346 + method: 'POST', 347 + body: { 348 + identifier: handle, 349 + password, 350 + }, 351 + }); 352 + if (!result.ok) { 353 + throw new Error(`createSession failed for ${handle}: ${result.status} ${result.text}`); 354 + } 355 + return result.json; 356 + }; 357 + 358 + const pollNotifications = async ({ account, authorHandle, reasons, minIndexedAt }) => { 359 + const started = Date.now(); 360 + let last; 361 + while (Date.now() - started < 180000) { 362 + last = await xrpcJson('app.bsky.notification.listNotifications', { 363 + token: account.accessJwt, 364 + params: { limit: '100' }, 365 + }); 366 + if (last.ok && Array.isArray(last.json?.notifications)) { 367 + const matching = last.json.notifications.filter((item) => { 368 + if (item?.author?.handle !== authorHandle) { 369 + return false; 370 + } 371 + const indexedAt = Date.parse(item?.indexedAt || item?.record?.createdAt || 0); 372 + if (Number.isFinite(minIndexedAt) && indexedAt < minIndexedAt) { 373 + return false; 374 + } 375 + return reasons.includes(item?.reason); 376 + }); 377 + const seenReasons = new Set(matching.map((item) => item.reason)); 378 + if (reasons.every((reason) => seenReasons.has(reason))) { 379 + return { 380 + notifications: matching, 381 + allNotifications: last.json.notifications.slice(0, 12), 382 + }; 383 + } 384 + } 385 + await sleep(5000); 386 + } 387 + throw new Error( 388 + `notifications not observed for ${account.handle}; last status=${last?.status ?? 'none'} body=${last?.text ?? ''}`, 389 + ); 390 + }; 391 + 392 + const accountFromConfig = (entry) => ({ 393 + ...entry, 394 + shortHandle: entry.handle.replace(/^@/, ''), 395 + }); 396 + 397 + const primary = accountFromConfig(config.primary); 398 + const secondary = accountFromConfig(config.secondary); 399 + 400 + const pageFor = (name) => (name === 'primary' ? primaryPage : secondaryPage); 401 + 402 + const login = async (page, account) => { 403 + await page.goto(config.appUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); 404 + await page.getByRole('button', { name: 'Sign in' }).nth(0).click({ noWaitAfter: true }); 405 + await wait(page, 1000); 406 + await page.getByRole('button', { name: 'Bluesky Social' }).evaluate((el) => el.click()); 407 + await wait(page, 500); 408 + await page.getByText('Custom').evaluate((el) => el.click()); 409 + await wait(page, 500); 410 + await page.getByPlaceholder('my-server.com').fill(config.pdsHost); 411 + await page.getByRole('button', { name: 'Done' }).evaluate((el) => el.click()); 412 + await wait(page, 500); 413 + const close = page.getByRole('button', { name: 'Close welcome modal' }); 414 + if (await close.count()) { 415 + await close.evaluate((el) => el.click()); 416 + await wait(page, 300); 417 + } 418 + await page.getByPlaceholder('Username or email address').fill(account.handle); 419 + await page.getByPlaceholder('Password').fill(account.password); 420 + await page.getByTestId('loginNextButton').click({ noWaitAfter: true }); 421 + await wait(page, 3000); 422 + }; 423 + 424 + const ensureAvatarFixture = async () => { 425 + const file = path.join(config.artifactsDir, 'avatar-fixture.png'); 426 + await fs.writeFile(file, Buffer.from(AVATAR_PNG_BASE64, 'base64')); 427 + return file; 428 + }; 429 + 430 + const completeAgeAssuranceIfNeeded = async (page, account) => { 431 + const addBirthdate = page.getByRole('button', { name: /update your birthdate/i }); 432 + if (await addBirthdate.count()) { 433 + await addBirthdate.click({ noWaitAfter: true }); 434 + await wait(page, 800); 435 + await page.getByTestId('birthdayInput').fill(account.birthdate); 436 + await page.getByRole('button', { name: /save birthdate/i }).click({ noWaitAfter: true }); 437 + await wait(page, 3000); 438 + summary.notes.push(`Completed age-assurance birthdate gate for ${account.handle}`); 439 + } 440 + }; 441 + 442 + const gotoProfile = async (page, handle) => { 443 + await page.goto(`${appBaseUrl}/profile/${encodeURIComponent(handle)}`, { 444 + waitUntil: 'domcontentloaded', 445 + timeout: 60000, 446 + }); 447 + await wait(page, 3000); 448 + }; 449 + 450 + const waitForProfileHandle = async (page, handle, timeout = 20000) => { 451 + const shortHandle = handle.replace(/^@/, ''); 452 + const handleText = shortHandle.startsWith('@') ? shortHandle : `@${shortHandle}`; 453 + await page.getByText(handleText).first().waitFor({ state: 'visible', timeout }); 454 + }; 455 + 456 + const composePost = async (page, text) => { 457 + await page.locator('[aria-label="Compose new post"]').last().click({ noWaitAfter: true }); 458 + await wait(page, 800); 459 + const editor = page.locator('[aria-label="Rich-Text Editor"]').last(); 460 + await editor.click({ noWaitAfter: true }); 461 + await editor.fill(text); 462 + await wait(page, 300); 463 + await page.getByRole('button', { name: 'Publish post' }).click({ noWaitAfter: true }); 464 + await wait(page, 4000); 465 + }; 466 + 467 + const dismissModalBackdropIfPresent = async (page) => { 468 + const backdrop = page.locator('[aria-label*="click to close"]').last(); 469 + if (await backdrop.count()) { 470 + await backdrop.click({ force: true, noWaitAfter: true }).catch(() => undefined); 471 + await wait(page, 400); 472 + } 473 + }; 474 + 475 + const uploadProfileAvatar = async (page) => { 476 + const avatarFile = await ensureAvatarFixture(); 477 + let fileInputs = page.locator('input[type="file"]'); 478 + let count = await fileInputs.count(); 479 + 480 + if (count === 0) { 481 + const changeAvatar = page.getByTestId('changeAvatarBtn').first(); 482 + if (await changeAvatar.count()) { 483 + await changeAvatar.click({ noWaitAfter: true }); 484 + await wait(page, 500); 485 + const uploadFromFiles = page.getByTestId('changeAvatarLibraryBtn').first(); 486 + if (await uploadFromFiles.count()) { 487 + const chooserPromise = page.waitForEvent('filechooser', { timeout: 10000 }); 488 + await uploadFromFiles.click({ noWaitAfter: true }); 489 + const chooser = await chooserPromise; 490 + await chooser.setFiles(avatarFile); 491 + await wait(page, 750); 492 + const editImageHeading = page.getByText(/^Edit image$/).last(); 493 + if (await editImageHeading.count()) { 494 + await editImageHeading.waitFor({ state: 'visible', timeout: 10000 }); 495 + const cropSave = page.getByRole('button', { name: 'Save' }).last(); 496 + await cropSave.click({ noWaitAfter: true }); 497 + await editImageHeading.waitFor({ state: 'hidden', timeout: 15000 }); 498 + } 499 + await wait(page, 1500); 500 + return avatarFile; 501 + } 502 + } 503 + } 504 + 505 + if (count === 0) { 506 + throw new Error('profile avatar file input unavailable'); 507 + } 508 + 509 + await fileInputs.first().setInputFiles(avatarFile); 510 + await wait(page, 1500); 511 + return avatarFile; 512 + }; 513 + 514 + const editProfile = async (page, account) => { 515 + const edit = page.getByRole('button', { name: /edit profile/i }); 516 + if (!(await edit.count())) { 517 + throw new Error(`edit profile button unavailable for ${account.handle}`); 518 + } 519 + await edit.click({ noWaitAfter: true }); 520 + await wait(page, 1000); 521 + await dismissModalBackdropIfPresent(page); 522 + const avatarFile = await uploadProfileAvatar(page); 523 + const bioField = page.locator('textarea[aria-label="Description"]').first(); 524 + if (await bioField.count()) { 525 + await bioField.fill(account.profileNote); 526 + const actual = await bioField.inputValue(); 527 + if (actual !== account.profileNote) { 528 + throw new Error(`profile description fill did not stick for ${account.handle}: ${actual}`); 529 + } 530 + } 531 + const save = page.getByTestId('editProfileSaveBtn'); 532 + await save.waitFor({ state: 'visible', timeout: 15000 }); 533 + await page.waitForFunction(() => { 534 + const btn = document.querySelector('[data-testid="editProfileSaveBtn"]'); 535 + return !!btn && !btn.hasAttribute('disabled') && btn.getAttribute('aria-disabled') !== 'true'; 536 + }, undefined, { timeout: 15000 }); 537 + await save.click({ noWaitAfter: true }); 538 + await page.waitForFunction(() => !document.querySelector('[data-testid="editProfileSaveBtn"]'), undefined, { 539 + timeout: 15000, 540 + }); 541 + await wait(page, 3000); 542 + return { avatarFile, profileNote: account.profileNote }; 543 + }; 544 + 545 + const verifyLocalProfileAfterEdit = async (account) => { 546 + const didResult = await xrpcJson('com.atproto.identity.resolveHandle', { 547 + params: { handle: account.handle }, 548 + }); 549 + if (!didResult.ok || didResult.json?.did !== account.did) { 550 + throw new Error(`handle did mismatch for ${account.handle}`); 551 + } 552 + const result = await xrpcJson('com.atproto.repo.getRecord', { 553 + params: { 554 + repo: account.did, 555 + collection: 'app.bsky.actor.profile', 556 + rkey: 'self', 557 + }, 558 + }); 559 + if (!result.ok) { 560 + throw new Error(`profile record lookup failed for ${account.handle}: ${result.status} ${result.text}`); 561 + } 562 + const avatarCid = result.json?.value?.avatar?.ref?.$link; 563 + const description = result.json?.value?.description; 564 + if (description !== account.profileNote || typeof avatarCid !== 'string' || !avatarCid.length) { 565 + throw new Error(`profile record did not contain expected avatar/description for ${account.handle}`); 566 + } 567 + return { avatarCid, description }; 568 + }; 569 + 570 + const verifyPublicProfileAfterEdit = async (account) => { 571 + const started = Date.now(); 572 + let result; 573 + while (Date.now() - started < (config.publicCheckTimeoutMs ?? 180000)) { 574 + result = await fetchJson( 575 + `${config.publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(account.handle)}`, 576 + ); 577 + if ( 578 + result.ok && 579 + result.json?.description === account.profileNote && 580 + typeof result.json?.avatar === 'string' && 581 + result.json.avatar.length > 0 582 + ) { 583 + break; 584 + } 585 + await sleep(5000); 586 + } 587 + if (!result?.ok) { 588 + throw new Error(`public profile lookup failed for ${account.handle}: ${result?.status} ${result?.text}`); 589 + } 590 + if (result.json?.description !== account.profileNote || typeof result.json?.avatar !== 'string') { 591 + throw new Error(`public profile missing updated description/avatar for ${account.handle}`); 592 + } 593 + const avatarResult = await fetchStatus(result.json.avatar); 594 + if (!avatarResult.ok) { 595 + throw new Error(`public avatar URL returned ${avatarResult.status} for ${account.handle}`); 596 + } 597 + return { 598 + avatar: result.json.avatar, 599 + avatarStatus: avatarResult.status, 600 + description: result.json.description, 601 + }; 602 + }; 603 + 604 + const findRowByPrimaryText = async (page, needle, timeout = 60000) => { 605 + const started = Date.now(); 606 + while (Date.now() - started < timeout) { 607 + const rows = page.locator('[data-testid^="feedItem-by-"]'); 608 + const count = await rows.count(); 609 + for (let i = 0; i < count; i += 1) { 610 + const row = rows.nth(i); 611 + const primaryText = row.locator('[data-testid="postText"]').first(); 612 + if (!(await primaryText.count())) { 613 + continue; 614 + } 615 + const text = normalizeText(await primaryText.textContent()); 616 + if (text === needle) { 617 + await row.waitFor({ state: 'visible', timeout: 10000 }); 618 + return row; 619 + } 620 + } 621 + await wait(page, 1000); 622 + } 623 + throw new Error(`feed item with primary text not found: ${needle}`); 624 + }; 625 + 626 + const maybeFindRowByPrimaryText = async (page, needle, timeout = 10000) => { 627 + try { 628 + return await findRowByPrimaryText(page, needle, timeout); 629 + } catch { 630 + return null; 631 + } 632 + }; 633 + 634 + const clickLike = async (page, row) => { 635 + const btn = row.getByTestId('likeBtn').first(); 636 + await btn.click({ noWaitAfter: true }); 637 + await wait(page, 1500); 638 + }; 639 + 640 + const ensureLiked = async (page, row) => { 641 + const btn = row.getByTestId('likeBtn').first(); 642 + const before = await buttonText(btn); 643 + if (/unlike/i.test(before)) { 644 + return { note: 'already liked' }; 645 + } 646 + await clickLike(page, row); 647 + return { note: await buttonText(btn) }; 648 + }; 649 + 650 + const ensureNotLiked = async (page, row) => { 651 + const btn = row.getByTestId('likeBtn').first(); 652 + const before = await buttonText(btn); 653 + if (!/unlike/i.test(before)) { 654 + return { note: 'already not liked' }; 655 + } 656 + await clickLike(page, row); 657 + return { note: await buttonText(btn) }; 658 + }; 659 + 660 + const clickRepost = async (page, row) => { 661 + await dismissBlockingOverlays(page); 662 + const btn = row.getByTestId('repostBtn').first(); 663 + await btn.click({ noWaitAfter: true }); 664 + await wait(page, 500); 665 + const repost = page.getByText(/^Repost$/).last(); 666 + if (await repost.count()) { 667 + await repost.click({ noWaitAfter: true }); 668 + await wait(page, 1500); 669 + await dismissBlockingOverlays(page); 670 + } 671 + }; 672 + 673 + const ensureReposted = async (page, row) => { 674 + const btn = row.getByTestId('repostBtn').first(); 675 + const before = await buttonText(btn); 676 + if (/undo repost|remove repost/i.test(before)) { 677 + return { note: 'already reposted' }; 678 + } 679 + await clickRepost(page, row); 680 + return { note: await buttonText(btn) }; 681 + }; 682 + 683 + const ensureNotReposted = async (page, row) => { 684 + const btn = row.getByTestId('repostBtn').first(); 685 + const before = await buttonText(btn); 686 + if (!/undo repost|remove repost/i.test(before)) { 687 + return { note: 'already not reposted' }; 688 + } 689 + await btn.click({ noWaitAfter: true }); 690 + await wait(page, 1500); 691 + return { note: await buttonText(btn) }; 692 + }; 693 + 694 + const clickQuote = async (page, row, text) => { 695 + await dismissBlockingOverlays(page); 696 + const btn = row.getByTestId('repostBtn').first(); 697 + await btn.click({ noWaitAfter: true }); 698 + await wait(page, 500); 699 + const quote = page.getByText(/^Quote post$/).last(); 700 + if (!(await quote.count())) { 701 + throw new Error('quote option not available'); 702 + } 703 + await quote.click({ noWaitAfter: true }); 704 + await publishComposer(page, text, { 705 + applyWritesLabel: 'quote publish', 706 + publishLabel: /publish post/i, 707 + }); 708 + await dismissBlockingOverlays(page); 709 + }; 710 + 711 + const clickReply = async (page, row, text) => { 712 + await dismissBlockingOverlays(page); 713 + const btn = row.getByTestId('replyBtn').first(); 714 + await btn.click({ noWaitAfter: true }); 715 + await wait(page, 1000); 716 + 717 + const composeReply = page.getByRole('button', { name: /compose reply/i }).last(); 718 + if (await composeReply.count()) { 719 + await composeReply.click({ noWaitAfter: true }); 720 + await wait(page, 500); 721 + } else { 722 + const writeYourReply = page.getByText(/^Write your reply$/).last(); 723 + if (await writeYourReply.count()) { 724 + await writeYourReply.click({ noWaitAfter: true }); 725 + await wait(page, 500); 726 + } 727 + } 728 + 729 + await publishComposer(page, text, { 730 + applyWritesLabel: 'reply publish', 731 + publishLabel: /publish reply|reply/i, 732 + }); 733 + await dismissBlockingOverlays(page); 734 + }; 735 + 736 + const waitForVisibleEditor = async (page) => { 737 + const editors = page.locator('[aria-label="Rich-Text Editor"]'); 738 + const started = Date.now(); 739 + while (Date.now() - started < 20000) { 740 + const count = await editors.count(); 741 + for (let i = count - 1; i >= 0; i -= 1) { 742 + const editor = editors.nth(i); 743 + if (await editor.isVisible().catch(() => false)) { 744 + return editor; 745 + } 746 + } 747 + await wait(page, 250); 748 + } 749 + throw new Error('visible rich-text editor not found'); 750 + }; 751 + 752 + const publishComposer = async (page, text, { applyWritesLabel, publishLabel }) => { 753 + const editor = await waitForVisibleEditor(page); 754 + await editor.click({ noWaitAfter: true }); 755 + await editor.fill(text); 756 + 757 + const publish = page.getByTestId('composerPublishBtn').last(); 758 + await publish.waitFor({ state: 'visible', timeout: 15000 }); 759 + const responsePromise = page.waitForResponse( 760 + (res) => 761 + res.url().includes('/xrpc/com.atproto.repo.applyWrites') && 762 + res.request().method() === 'POST', 763 + { timeout: 30000 }, 764 + ); 765 + await publish.click({ noWaitAfter: true }); 766 + const response = await responsePromise; 767 + if (response.status() !== 200) { 768 + throw new Error(`${applyWritesLabel} failed with status ${response.status()}`); 769 + } 770 + await wait(page, 4000); 771 + 772 + const buttonName = publishLabel instanceof RegExp ? publishLabel : /publish/i; 773 + await page.getByTestId('composerPublishBtn').getByRole('button', { name: buttonName }).waitFor({ 774 + state: 'detached', 775 + timeout: 15000, 776 + }).catch(() => undefined); 777 + }; 778 + 779 + const maybeFollow = async (page) => { 780 + const follow = page.getByTestId('followBtn').first(); 781 + if (await follow.count()) { 782 + const label = (await follow.getAttribute('aria-label')) ?? ''; 783 + if (/following/i.test(label) || /^Following$/i.test((await follow.innerText()).trim())) { 784 + return { note: 'already following' }; 785 + } 786 + await follow.click({ noWaitAfter: true }); 787 + await wait(page, 2000); 788 + return { note: 'follow attempted' }; 789 + } 790 + const roleFollow = page.getByRole('button', { name: /follow/i }).first(); 791 + if (!(await roleFollow.count())) { 792 + return { note: 'follow button unavailable' }; 793 + } 794 + const label = (await roleFollow.getAttribute('aria-label')) ?? ''; 795 + if (/following/i.test(label) || /^Following$/i.test((await roleFollow.innerText()).trim())) { 796 + return { note: 'already following' }; 797 + } 798 + await roleFollow.click({ noWaitAfter: true }); 799 + await wait(page, 2000); 800 + return { note: 'follow attempted via role button' }; 801 + }; 802 + 803 + const maybeUnfollow = async (page) => { 804 + const btn = page.getByTestId('unfollowBtn').first(); 805 + if (!(await btn.count())) { 806 + return { note: 'already not following' }; 807 + } 808 + await btn.click({ noWaitAfter: true }); 809 + await wait(page, 2000); 810 + return { note: 'unfollow attempted' }; 811 + }; 812 + 813 + const openNotifications = async (page) => { 814 + await page.goto(`${appBaseUrl}/notifications`, { 815 + waitUntil: 'domcontentloaded', 816 + timeout: 60000, 817 + }); 818 + await wait(page, 3000); 819 + const heading = page.getByText(/^Notifications$/).first(); 820 + if (await heading.count()) { 821 + await heading.waitFor({ state: 'visible', timeout: 15000 }); 822 + } 823 + }; 824 + 825 + const waitForNotificationsFeed = async (page) => { 826 + const feed = page.getByTestId('notifsFeed').first(); 827 + if (await feed.count()) { 828 + await feed.waitFor({ state: 'visible', timeout: 15000 }); 829 + return feed; 830 + } 831 + return null; 832 + }; 833 + 834 + const waitForNotificationFeedItem = async (page, handle, timeout = 20000) => { 835 + const exact = page.getByTestId(`feedItem-by-${handle}`).first(); 836 + try { 837 + await exact.waitFor({ state: 'visible', timeout }); 838 + return exact; 839 + } catch { 840 + const fallback = page.locator(`[data-testid^="feedItem-by-${handle}"]`).first(); 841 + await fallback.waitFor({ state: 'visible', timeout }); 842 + return fallback; 843 + } 844 + }; 845 + 846 + const openProfileTab = async (page, name) => { 847 + const tab = page.getByRole('tab', { name }).first(); 848 + await tab.waitFor({ state: 'visible', timeout: 15000 }); 849 + await tab.click({ noWaitAfter: true }); 850 + await wait(page, 2000); 851 + }; 852 + 853 + const openPostOptions = async (page, row) => { 854 + const btn = row.getByTestId('postDropdownBtn').first(); 855 + await btn.click({ noWaitAfter: true }); 856 + const menu = page.locator('[role="menu"]').last(); 857 + await menu.waitFor({ state: 'visible', timeout: 10000 }); 858 + return menu; 859 + }; 860 + 861 + const deletePostRow = async (page, row) => { 862 + await openPostOptions(page, row); 863 + const deleteItem = page.getByRole('menuitem', { name: /delete post/i }).first(); 864 + await deleteItem.waitFor({ state: 'visible', timeout: 10000 }); 865 + await deleteItem.click({ noWaitAfter: true }); 866 + const dialog = page.locator('[role="dialog"]').last(); 867 + await dialog.waitFor({ state: 'visible', timeout: 10000 }); 868 + const confirm = page.getByRole('button', { name: /^Delete$/i }).last(); 869 + await confirm.click({ noWaitAfter: true }); 870 + await dialog.waitFor({ state: 'hidden', timeout: 15000 }); 871 + await wait(page, 3000); 872 + }; 873 + 874 + const maybeDeleteOwnPostByText = async (page, text, successNote) => { 875 + const row = await maybeFindRowByPrimaryText(page, text, 10000); 876 + if (!row) { 877 + return { note: `not surfaced for cleanup: ${text}` }; 878 + } 879 + await deletePostRow(page, row); 880 + return { note: successNote }; 881 + }; 882 + 883 + const ensureBodyContainsAny = async (page, needles) => { 884 + const started = Date.now(); 885 + while (Date.now() - started < 60000) { 886 + const bodyText = normalizeText(await page.locator('body').textContent()); 887 + if (needles.some((needle) => bodyText.includes(needle))) { 888 + return { note: 'notification text visible in UI' }; 889 + } 890 + await wait(page, 2000); 891 + } 892 + throw new Error(`body did not contain any of: ${needles.join(', ')}`); 893 + }; 894 + 895 + try { 896 + await step('primary-login', () => login(primaryPage, primary), { pageNames: ['primary'] }); 897 + await step('primary-age-assurance', () => completeAgeAssuranceIfNeeded(primaryPage, primary), { 898 + optional: true, 899 + pageNames: ['primary'], 900 + }); 901 + await step('secondary-login', () => login(secondaryPage, secondary), { pageNames: ['secondary'] }); 902 + await step('secondary-age-assurance', () => completeAgeAssuranceIfNeeded(secondaryPage, secondary), { 903 + optional: true, 904 + pageNames: ['secondary'], 905 + }); 906 + 907 + primary.session = await createSession(primary.handle, primary.password); 908 + primary.accessJwt = primary.session.accessJwt; 909 + primary.did = primary.session.did; 910 + secondary.session = await createSession(secondary.handle, secondary.password); 911 + secondary.accessJwt = secondary.session.accessJwt; 912 + secondary.did = secondary.session.did; 913 + 914 + await step('primary-compose-root-post', () => composePost(primaryPage, primary.postText), { 915 + pageNames: ['primary'], 916 + }); 917 + 918 + primary.rootPost = await waitForOwnPostRecord(primary, primary.postText); 919 + 920 + await step('primary-own-profile', async () => { 921 + await gotoProfile(primaryPage, primary.handle); 922 + await waitForProfileHandle(primaryPage, primary.handle); 923 + const row = await findRowByPrimaryText(primaryPage, primary.postText, 60000); 924 + const rowTestId = await row.getAttribute('data-testid'); 925 + return { rowTestId }; 926 + }, { pageNames: ['primary'] }); 927 + 928 + await step('secondary-compose-root-post', () => composePost(secondaryPage, secondary.postText), { 929 + pageNames: ['secondary'], 930 + }); 931 + 932 + secondary.rootPost = await waitForOwnPostRecord(secondary, secondary.postText); 933 + 934 + await step('secondary-own-profile', async () => { 935 + await gotoProfile(secondaryPage, secondary.handle); 936 + await waitForProfileHandle(secondaryPage, secondary.handle); 937 + const row = await findRowByPrimaryText(secondaryPage, secondary.postText, 60000); 938 + const rowTestId = await row.getAttribute('data-testid'); 939 + return { rowTestId }; 940 + }, { pageNames: ['secondary'] }); 941 + 942 + await step('primary-edit-profile', () => editProfile(primaryPage, primary), { 943 + pageNames: ['primary'], 944 + }); 945 + 946 + await step('primary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(primary)); 947 + 948 + await step('primary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(primary)); 949 + 950 + await step('secondary-edit-profile', () => editProfile(secondaryPage, secondary), { 951 + pageNames: ['secondary'], 952 + }); 953 + 954 + await step('secondary-local-profile-after-edit', () => verifyLocalProfileAfterEdit(secondary)); 955 + 956 + await step('secondary-public-profile-after-edit', () => verifyPublicProfileAfterEdit(secondary)); 957 + 958 + const primaryWaveStarted = Date.now() - 1000; 959 + await step('primary-open-secondary-profile', async () => { 960 + await gotoProfile(primaryPage, secondary.handle); 961 + await waitForProfileHandle(primaryPage, secondary.handle); 962 + }, { pageNames: ['primary'] }); 963 + 964 + await step('primary-reset-follow-secondary', () => maybeUnfollow(primaryPage), { 965 + optional: true, 966 + pageNames: ['primary'], 967 + }); 968 + 969 + await step('primary-follow-secondary', () => maybeFollow(primaryPage), { 970 + pageNames: ['primary'], 971 + }); 972 + 973 + await step('primary-follow-secondary-record', async () => { 974 + const record = await waitForFollowRecord(primary, secondary.did); 975 + return { uri: record.uri }; 976 + }); 977 + 978 + await step('primary-like-secondary-post', async () => { 979 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 980 + return ensureLiked(primaryPage, row); 981 + }, { pageNames: ['primary'] }); 982 + 983 + await step('primary-repost-secondary-post', async () => { 984 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 985 + return ensureReposted(primaryPage, row); 986 + }, { pageNames: ['primary'] }); 987 + 988 + await step('primary-quote-secondary-post', async () => { 989 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 990 + await clickQuote(primaryPage, row, primary.quoteText); 991 + primary.quotePost = await waitForOwnPostRecord(primary, primary.quoteText); 992 + return { quoteText: primary.quoteText, uri: primary.quotePost.uri }; 993 + }, { pageNames: ['primary'] }); 994 + 995 + await step('primary-reply-secondary-post', async () => { 996 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 997 + await clickReply(primaryPage, row, primary.replyText); 998 + primary.replyPost = await waitForOwnPostRecord(primary, primary.replyText); 999 + return { replyText: primary.replyText, uri: primary.replyPost.uri }; 1000 + }, { pageNames: ['primary'] }); 1001 + 1002 + await step('secondary-notification-api-primary-engagement-wave', async () => { 1003 + const result = await pollNotifications({ 1004 + account: secondary, 1005 + authorHandle: primary.handle, 1006 + reasons: ['like', 'repost', 'quote', 'reply'], 1007 + minIndexedAt: primaryWaveStarted, 1008 + }); 1009 + return { 1010 + reasons: result.notifications.map((item) => item.reason), 1011 + sample: result.allNotifications.slice(0, 5), 1012 + }; 1013 + }); 1014 + 1015 + await step('secondary-notifications-page', async () => { 1016 + await openNotifications(secondaryPage); 1017 + const feed = await waitForNotificationsFeed(secondaryPage); 1018 + return { note: feed ? 'notifications feed visible' : 'notifications page visible without explicit feed testid' }; 1019 + }, { pageNames: ['secondary'] }); 1020 + 1021 + const secondaryWaveStarted = Date.now() - 1000; 1022 + await step('secondary-open-primary-profile', async () => { 1023 + await gotoProfile(secondaryPage, primary.handle); 1024 + await waitForProfileHandle(secondaryPage, primary.handle); 1025 + }, { pageNames: ['secondary'] }); 1026 + 1027 + await step('secondary-reset-follow-primary', () => maybeUnfollow(secondaryPage), { 1028 + optional: true, 1029 + pageNames: ['secondary'], 1030 + }); 1031 + 1032 + await step('secondary-follow-primary', () => maybeFollow(secondaryPage), { 1033 + pageNames: ['secondary'], 1034 + }); 1035 + 1036 + await step('secondary-follow-primary-record', async () => { 1037 + const record = await waitForFollowRecord(secondary, primary.did); 1038 + return { uri: record.uri }; 1039 + }); 1040 + 1041 + await step('primary-notification-api-secondary-follow', async () => { 1042 + const result = await pollNotifications({ 1043 + account: primary, 1044 + authorHandle: secondary.handle, 1045 + reasons: ['follow'], 1046 + minIndexedAt: secondaryWaveStarted, 1047 + }); 1048 + return { 1049 + reasons: result.notifications.map((item) => item.reason), 1050 + sample: result.allNotifications.slice(0, 5), 1051 + }; 1052 + }, { optional: true }); 1053 + 1054 + await step('primary-notifications-page', async () => { 1055 + await openNotifications(primaryPage); 1056 + const feed = await waitForNotificationsFeed(primaryPage); 1057 + return { note: feed ? 'notifications feed visible' : 'notifications page visible without explicit feed testid' }; 1058 + }, { pageNames: ['primary'] }); 1059 + 1060 + await step('primary-cleanup-unlike-secondary-post', async () => { 1061 + await gotoProfile(primaryPage, secondary.handle); 1062 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1063 + return ensureNotLiked(primaryPage, row); 1064 + }, { optional: true, pageNames: ['primary'] }); 1065 + 1066 + await step('primary-cleanup-undo-repost-secondary-post', async () => { 1067 + await gotoProfile(primaryPage, secondary.handle); 1068 + const row = await findRowByPrimaryText(primaryPage, secondary.postText, 60000); 1069 + return ensureNotReposted(primaryPage, row); 1070 + }, { optional: true, pageNames: ['primary'] }); 1071 + 1072 + await step('primary-cleanup-unfollow-secondary', async () => { 1073 + await gotoProfile(primaryPage, secondary.handle); 1074 + return maybeUnfollow(primaryPage); 1075 + }, { optional: true, pageNames: ['primary'] }); 1076 + 1077 + await step('secondary-cleanup-unfollow-primary', async () => { 1078 + await gotoProfile(secondaryPage, primary.handle); 1079 + return maybeUnfollow(secondaryPage); 1080 + }, { optional: true, pageNames: ['secondary'] }); 1081 + 1082 + await step('primary-cleanup-delete-quote', async () => { 1083 + await gotoProfile(primaryPage, primary.handle); 1084 + await openProfileTab(primaryPage, 'Posts'); 1085 + return maybeDeleteOwnPostByText(primaryPage, primary.quoteText, 'deleted quote post'); 1086 + }, { pageNames: ['primary'] }); 1087 + 1088 + await step('primary-cleanup-delete-reply', async () => { 1089 + await gotoProfile(primaryPage, primary.handle); 1090 + await openProfileTab(primaryPage, 'Replies'); 1091 + return maybeDeleteOwnPostByText(primaryPage, primary.replyText, 'deleted reply post'); 1092 + }, { optional: true, pageNames: ['primary'] }); 1093 + 1094 + await step('secondary-cleanup-delete-root-post', async () => { 1095 + await gotoProfile(secondaryPage, secondary.handle); 1096 + await openProfileTab(secondaryPage, 'Posts'); 1097 + return maybeDeleteOwnPostByText(secondaryPage, secondary.postText, 'deleted root post'); 1098 + }, { pageNames: ['secondary'] }); 1099 + 1100 + await step('primary-cleanup-delete-root-post', async () => { 1101 + await gotoProfile(primaryPage, primary.handle); 1102 + await openProfileTab(primaryPage, 'Posts'); 1103 + return maybeDeleteOwnPostByText(primaryPage, primary.postText, 'deleted root post'); 1104 + }, { optional: true, pageNames: ['primary'] }); 1105 + } catch (error) { 1106 + summary.fatal = String(error?.message ?? error); 1107 + } 1108 + 1109 + summary.finishedAt = new Date().toISOString(); 1110 + summary.unexpected = { 1111 + console: summary.console.filter((entry) => !isIgnoredConsole(entry)), 1112 + requestFailures: summary.requestFailures.filter((entry) => !isIgnoredRequestFailure(entry)), 1113 + httpFailures: summary.httpFailures.filter((entry) => !isIgnoredHttpFailure(entry)), 1114 + pageErrors: summary.pageErrors, 1115 + }; 1116 + summary.unexpected.total = 1117 + summary.unexpected.console.length + 1118 + summary.unexpected.requestFailures.length + 1119 + summary.unexpected.httpFailures.length + 1120 + summary.unexpected.pageErrors.length; 1121 + if (!summary.fatal && config.strictErrors !== false && summary.unexpected.total > 0) { 1122 + summary.fatal = `Unexpected browser/runtime errors: ${summary.unexpected.total}`; 1123 + } 1124 + summary.ok = !summary.fatal; 1125 + await screenshot('primary', 'final').catch(() => undefined); 1126 + await screenshot('secondary', 'final').catch(() => undefined); 1127 + await fs.writeFile( 1128 + path.join(config.artifactsDir, 'summary.json'), 1129 + JSON.stringify(summary, null, 2) + '\n', 1130 + 'utf8', 1131 + ); 1132 + console.log(JSON.stringify(summary, null, 2)); 1133 + await browser.close();