From 091670ff777b465ee403b29ccc6c7de1406708cc Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 21 Aug 2026 00:04:18 +0000 Subject: [PATCH] fix(e2e): close bootstrap checker receiver gaps Preserve unsafe bootstrap events through supported parenthesized forms and grant loop bootstrap credit only when syntax proves an enumerable runtime iteration. Co-authored-by: Larry Co-authored-by: Brother Darryl Signed-off-by: Logan Johnson --- AGENTS.md | 15 +- desktop/biome.json | 24 +- desktop/package.json | 6 +- desktop/playwright.config.ts | 1 + desktop/scripts/check-e2e-bootstrap.mjs | 983 ++++++++++++ desktop/scripts/check-e2e-bootstrap.test.mjs | 1342 +++++++++++++++++ .../tests/e2e/active-turn-resilience.spec.ts | 4 +- .../activity-scope-label-screenshots.spec.ts | 7 +- .../e2e/add-community-screenshots.spec.ts | 4 +- .../tests/e2e/agent-access-warning.spec.ts | 10 +- .../e2e/agent-error-state-screenshots.spec.ts | 4 +- .../e2e/agent-lifecycle-feedback.spec.ts | 6 +- .../tests/e2e/agent-numeric-tuning.spec.ts | 8 +- .../e2e/agent-provider-dropdowns.spec.ts | 8 +- .../e2e/agent-readiness-screenshots.spec.ts | 6 +- .../e2e/agent-snapshot-recipient.spec.ts | 4 +- .../tests/e2e/agents-everywhere.live.spec.ts | 2 +- desktop/tests/e2e/agents.spec.ts | 4 +- desktop/tests/e2e/animated-avatar.spec.ts | 4 +- desktop/tests/e2e/appearance-previews.spec.ts | 5 +- desktop/tests/e2e/badge.spec.ts | 36 +- desktop/tests/e2e/boot-splash.spec.ts | 6 +- desktop/tests/e2e/bootstrap.spec.ts | 20 + .../tests/e2e/buzz-theme-screenshots.spec.ts | 12 +- .../e2e/channel-activity-popover.spec.ts | 12 +- .../tests/e2e/channel-add-screenshots.spec.ts | 10 +- desktop/tests/e2e/channel-browser.spec.ts | 79 +- .../e2e/channel-composer-overflow.spec.ts | 12 +- desktop/tests/e2e/channel-controls.spec.ts | 4 +- .../e2e/channel-dense-second-reach.spec.ts | 4 +- desktop/tests/e2e/channel-mute.spec.ts | 14 +- .../channel-shared-header-backdrop.spec.ts | 4 +- desktop/tests/e2e/channel-sort.spec.ts | 4 +- desktop/tests/e2e/channel-star.spec.ts | 10 +- .../e2e/channel-window-mock-paging.spec.ts | 6 +- desktop/tests/e2e/channels.spec.ts | 210 +-- .../tests/e2e/cold-switch-longtask.perf.ts | 4 +- desktop/tests/e2e/community-rail.spec.ts | 54 +- desktop/tests/e2e/composer-image-draw.spec.ts | 8 +- .../tests/e2e/composer-link-shortcut.spec.ts | 4 +- .../e2e/composer-selection-formatting.spec.ts | 10 +- .../e2e/composer-tooltip-dismiss.spec.ts | 10 +- .../e2e/config-bridge-screenshots.spec.ts | 6 +- .../tests/e2e/core-memory-screenshots.spec.ts | 4 +- desktop/tests/e2e/custom-emoji-ui.spec.ts | 8 +- desktop/tests/e2e/custom-emoji.spec.ts | 6 +- desktop/tests/e2e/deep-link-invite.spec.ts | 20 +- .../tests/e2e/dm-double-notification.spec.ts | 4 +- .../e2e/dm-history-live-regression.spec.ts | 10 +- .../e2e/dm-new-message-screenshots.spec.ts | 8 +- .../tests/e2e/doctor-cta-screenshots.spec.ts | 10 +- desktop/tests/e2e/doctor-states.spec.ts | 32 +- .../e2e/drafts-all-fix-screenshots.spec.ts | 60 +- desktop/tests/e2e/drafts-screenshots.spec.ts | 6 +- desktop/tests/e2e/edit-agent-run-on.spec.ts | 4 +- desktop/tests/e2e/edit-agent.spec.ts | 8 +- desktop/tests/e2e/empty-edit-delete.spec.ts | 4 +- .../e2e/entity-link-recipient-cards.spec.ts | 10 +- desktop/tests/e2e/file-attachment.spec.ts | 30 +- ...reground-responsiveness-regression.spec.ts | 4 +- .../global-agent-config-screenshots.spec.ts | 12 +- .../e2e/harness-catalog-screenshots.spec.ts | 4 +- desktop/tests/e2e/harness-management.spec.ts | 6 +- .../e2e/home-collapsed-top-chrome.spec.ts | 4 +- ...d-communities-settings-screenshots.spec.ts | 4 +- .../tests/e2e/huddle-transcription.spec.ts | 54 +- .../e2e/human-edit-agent-content.spec.ts | 14 +- .../tests/e2e/identity-archive-hide.spec.ts | 20 +- desktop/tests/e2e/identity-archive.spec.ts | 6 +- desktop/tests/e2e/identity-key-help.spec.ts | 6 +- desktop/tests/e2e/identity-lost.spec.ts | 32 +- .../e2e/image-attachment-gallery.spec.ts | 22 +- desktop/tests/e2e/inbox-edit.spec.ts | 16 +- desktop/tests/e2e/inbox-live-update.spec.ts | 16 +- desktop/tests/e2e/inbox-reactions.spec.ts | 4 +- .../e2e/inbox-refactor-screenshots.spec.ts | 11 +- .../tests/e2e/inline-custom-harness.spec.ts | 8 +- desktop/tests/e2e/integration.spec.ts | 34 +- desktop/tests/e2e/invite-link-copy.spec.ts | 10 +- .../e2e/invites-settings-screenshots.spec.ts | 4 +- desktop/tests/e2e/key-import-reveal.spec.ts | 4 +- .../e2e/live-broadcast-reply-timeline.spec.ts | 4 +- .../e2e/local-archive-screenshots.spec.ts | 4 +- .../tests/e2e/markdown-parse-cache.spec.ts | 4 +- desktop/tests/e2e/mentions.spec.ts | 140 +- desktop/tests/e2e/mesh-compute.spec.ts | 6 +- .../e2e/message-feedback-snapshots.spec.ts | 6 +- desktop/tests/e2e/messaging.spec.ts | 126 +- desktop/tests/e2e/mobile-pairing-qr.spec.ts | 10 +- desktop/tests/e2e/navigation.spec.ts | 42 +- .../e2e/needs-restart-screenshots.spec.ts | 4 +- desktop/tests/e2e/nostr-bind.spec.ts | 10 +- .../tests/e2e/observer-archive-policy.spec.ts | 8 +- .../e2e/observer-feed-screenshots.spec.ts | 4 +- .../e2e/onboarding-agent-defaults.spec.ts | 52 +- .../tests/e2e/onboarding-avatar-skip.spec.ts | 14 +- desktop/tests/e2e/onboarding-backup.spec.ts | 8 +- .../onboarding-docked-cta-screenshots.spec.ts | 10 +- desktop/tests/e2e/onboarding.spec.ts | 136 +- desktop/tests/e2e/overscroll-boundary.spec.ts | 6 +- .../tests/e2e/parity-ancestor-island.spec.ts | 4 +- desktop/tests/e2e/perf/metrics.test.mjs | 53 + desktop/tests/e2e/perf/metrics.ts | 68 +- .../e2e/persistent-agent-audience.spec.ts | 7 +- desktop/tests/e2e/persona-env-vars.spec.ts | 4 +- ...persona-model-combobox-screenshots.spec.ts | 4 +- desktop/tests/e2e/persona-sync.spec.ts | 4 +- desktop/tests/e2e/profile-active-turn.spec.ts | 4 +- .../tests/e2e/profile-backup-settings.spec.ts | 4 +- .../e2e/profile-custom-emoji-status.spec.ts | 4 +- desktop/tests/e2e/profile-nsec-reveal.spec.ts | 6 +- desktop/tests/e2e/profile.spec.ts | 70 +- .../tests/e2e/project-commit-detail.spec.ts | 29 +- desktop/tests/e2e/project-inbox.spec.ts | 4 +- .../tests/e2e/project-issue-comments.spec.ts | 4 +- desktop/tests/e2e/project-pr-review.spec.ts | 55 +- .../tests/e2e/projects-v3-screenshots.spec.ts | 11 +- .../e2e/pubkey-display-screenshots.spec.ts | 10 +- desktop/tests/e2e/reaction-names.spec.ts | 6 +- desktop/tests/e2e/reaction-order.spec.ts | 4 +- desktop/tests/e2e/relay-connectivity.spec.ts | 14 +- .../e2e/relay-reconnect-affordance.spec.ts | 6 +- desktop/tests/e2e/relay-reconnect.spec.ts | 34 +- desktop/tests/e2e/relay-restart.live.spec.ts | 4 +- desktop/tests/e2e/release-smoke.spec.ts | 83 +- .../tests/e2e/reminder-click-repro.spec.ts | 6 +- desktop/tests/e2e/reminders.spec.ts | 8 +- desktop/tests/e2e/scroll-history.spec.ts | 38 +- desktop/tests/e2e/scroll-smoothness.perf.ts | 6 +- desktop/tests/e2e/scrollback-buzzbugs.perf.ts | 4 +- .../e2e/search-scope-screenshots.spec.ts | 4 +- .../tests/e2e/send-channel-binding.spec.ts | 6 +- .../tests/e2e/settings-section-layout.spec.ts | 6 +- .../e2e/sidebar-more-unread-overlap.spec.ts | 4 +- .../tests/e2e/sidebar-offcanvas-rail.spec.ts | 4 +- desktop/tests/e2e/sidebar-relay-card.spec.ts | 24 +- desktop/tests/e2e/sidebar-snapshot.spec.ts | 22 +- desktop/tests/e2e/sidebar.spec.ts | 36 +- .../tests/e2e/signout-confirmation.spec.ts | 10 +- desktop/tests/e2e/signout-screenshots.spec.ts | 6 +- desktop/tests/e2e/smoke.spec.ts | 52 +- desktop/tests/e2e/spoiler.spec.ts | 16 +- desktop/tests/e2e/stream.spec.ts | 42 +- desktop/tests/e2e/team-mentions.spec.ts | 4 +- desktop/tests/e2e/team-snapshot.spec.ts | 4 +- desktop/tests/e2e/terminal-wheel.spec.ts | 6 +- desktop/tests/e2e/thread-focus-mode.spec.ts | 6 +- .../e2e/thread-reply-anchor-roleplay.spec.ts | 4 +- desktop/tests/e2e/thread-unread.spec.ts | 28 +- .../tests/e2e/threadpane-ultrawide.spec.ts | 4 +- desktop/tests/e2e/timeline-no-shift.spec.ts | 12 +- .../e2e/top-chrome-zoom-clearance.spec.ts | 8 +- desktop/tests/e2e/typing-latency.perf.ts | 4 +- desktop/tests/e2e/unread-pill.spec.ts | 10 +- desktop/tests/e2e/video-attachment.spec.ts | 22 +- desktop/tests/e2e/virtualization.spec.ts | 31 +- desktop/tests/e2e/voice-settings.spec.ts | 12 +- .../tests/e2e/warm-switch-markdown.perf.ts | 4 +- .../welcome-agent-modal-screenshots.spec.ts | 8 +- desktop/tests/e2e/where-to-run-config.spec.ts | 4 +- .../tests/e2e/workflow-local-controls.spec.ts | 4 +- .../e2e/workflow-reaction-picker.spec.ts | 4 +- .../e2e/workflow-title-stability.spec.ts | 10 +- desktop/tests/e2e/workflows.spec.ts | 7 +- desktop/tests/helpers/bootstrap.ts | 66 + desktop/tests/helpers/bridge.ts | 24 +- desktop/tests/helpers/onboarding.ts | 13 +- desktop/tests/helpers/test.ts | 13 + package.json | 3 +- web/biome.json | 24 +- web/package.json | 6 +- web/playwright.config.ts | 2 +- web/scripts/check-e2e-bootstrap.mjs | 983 ++++++++++++ web/scripts/check-e2e-bootstrap.test.mjs | 1342 +++++++++++++++++ web/tests/e2e/bootstrap.spec.ts | 20 + web/tests/e2e/smoke.spec.ts | 65 +- web/tests/helpers/bootstrap.ts | 66 + web/tests/helpers/test.ts | 13 + 178 files changed, 6391 insertions(+), 1334 deletions(-) create mode 100644 desktop/scripts/check-e2e-bootstrap.mjs create mode 100644 desktop/scripts/check-e2e-bootstrap.test.mjs create mode 100644 desktop/tests/e2e/bootstrap.spec.ts create mode 100644 desktop/tests/e2e/perf/metrics.test.mjs create mode 100644 desktop/tests/helpers/bootstrap.ts create mode 100644 desktop/tests/helpers/test.ts create mode 100644 web/scripts/check-e2e-bootstrap.mjs create mode 100644 web/scripts/check-e2e-bootstrap.test.mjs create mode 100644 web/tests/e2e/bootstrap.spec.ts create mode 100644 web/tests/helpers/bootstrap.ts create mode 100644 web/tests/helpers/test.ts diff --git a/AGENTS.md b/AGENTS.md index b1f11bd3db1..1b8a6482a85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,9 +398,18 @@ you; prefer them over a manual build plus `playwright test`. serves old code. Kill port 4173 and re-run `pnpm build:e2e` before re-running tests after code changes. -**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding) -must run BEFORE `installMockBridge(page)` — React reads state on mount, the -bridge triggers mount. +**Explicit E2E bootstrap:** Browser specs import `test`, `expect`, and +`bootstrapE2ePage` from the suite's `tests/helpers/test`, not `@playwright/test`. +After all pre-navigation setup (including `addInitScript`, routes, and +`installMockBridge(page)`), every test using the Playwright `page` fixture must +`await bootstrapE2ePage(page, ...)` before accessing browser storage or the app. +This commits the first navigation to the configured HTTP origin and reports a +missing build or preview server before later storage access can misreport it as +a `localStorage` failure. Non-browser CLI/relay harnesses without a `page` +fixture do not bootstrap. Keep storage init scripts origin-guarded; do not +suppress storage errors. + +Rejects loop coverage when the condition is the boolean literal false, or when for...of / for...in uses a statically empty literal iterable/object; transparent parentheses around those literals are equivalent. Other runtime or constant-expression iteration counts are outside this syntactic check. **Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before `__BUZZ_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a diff --git a/desktop/biome.json b/desktop/biome.json index 12e778e4a92..70c87f83490 100644 --- a/desktop/biome.json +++ b/desktop/biome.json @@ -1,3 +1,25 @@ { - "extends": ["../biome.json"] + "extends": ["../biome.json"], + "overrides": [ + { + "includes": ["tests/e2e/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "@playwright/test": { + "importNames": ["test", "base", "default", "*"], + "message": "Import test from ../helpers/test so every E2E test receives automatic origin bootstrap." + } + } + } + } + } + } + } + } + ] } diff --git a/desktop/package.json b/desktop/package.json index 683c1529d25..ffd3e1cd2f5 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -12,7 +12,7 @@ "check:px-text": "node ./scripts/check-px-text.mjs", "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation", + "check": "biome check . && pnpm check:px-text && pnpm check:e2e-bootstrap && pnpm check:pubkey-truncation", "format": "biome format --write .", "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", "preview": "vite preview", @@ -22,7 +22,9 @@ "test:e2e:integration": "pnpm build:e2e && playwright test --project=integration", "test:e2e:release-smoke": "pnpm build:e2e && playwright test --config=playwright.release-smoke.config.ts", "test:e2e:report": "playwright show-report", - "tauri:build": "tauri build" + "tauri:build": "tauri build", + "check:e2e-bootstrap": "pnpm test:check-e2e-bootstrap && node ./scripts/check-e2e-bootstrap.mjs", + "test:check-e2e-bootstrap": "node --test ./scripts/check-e2e-bootstrap.test.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b1a2d5623b2..8c9f9f1f9c6 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/bootstrap.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", diff --git a/desktop/scripts/check-e2e-bootstrap.mjs b/desktop/scripts/check-e2e-bootstrap.mjs new file mode 100644 index 00000000000..cf4c357f9bb --- /dev/null +++ b/desktop/scripts/check-e2e-bootstrap.mjs @@ -0,0 +1,983 @@ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const SPEC_PATTERN = /\.(spec|perf)\.ts$/; +const TEST_MEMBERS = new Set(["only", "skip", "fixme", "fail"]); +const CONTAINERS = new Set(["forEach", "map"]); + +function callbackOf(call) { + const callback = call.arguments.at(-1); + return callback && + (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) + ? callback + : undefined; +} + +function bindingContainsPageFixture(name) { + if (ts.isIdentifier(name)) return name.text === "page"; + if (ts.isObjectBindingPattern(name)) + return name.elements.some( + (element) => + element.propertyName?.getText() === "page" || + (!element.propertyName && bindingContainsPageFixture(element.name)), + ); + return false; +} + +function stringKey(expression) { + return ts.isStringLiteral(expression) || + ts.isNoSubstitutionTemplateLiteral(expression) + ? expression.text + : undefined; +} + +function bindingInitializers(callback) { + const bindings = []; + const collect = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer + ) + bindings.push([node.name.text, node.initializer]); + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(node.left) + ) + bindings.push([node.left.text, node.right]); + ts.forEachChild(node, collect); + }; + collect(callback.body); + return bindings; +} + +function callbackUsesPageFixture(callback) { + if ( + callback.parameters.some((parameter) => + bindingContainsPageFixture(parameter.name), + ) + ) + return true; + + const fixtureObjects = new Set(); + for (const parameter of callback.parameters) { + if (ts.isIdentifier(parameter.name)) + fixtureObjects.add(parameter.name.text); + if (ts.isObjectBindingPattern(parameter.name)) { + for (const element of parameter.name.elements) { + if (element.dotDotDotToken && ts.isIdentifier(element.name)) + fixtureObjects.add(element.name.text); + } + } + } + + const bindings = bindingInitializers(callback); + let changed = true; + while (changed) { + changed = false; + for (const [name, initializer] of bindings) { + if ( + ts.isIdentifier(initializer) && + fixtureObjects.has(initializer.text) && + !fixtureObjects.has(name) + ) { + fixtureObjects.add(name); + changed = true; + } + } + } + + let usesPage = false; + const visit = (node) => { + if (usesPage) return; + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + fixtureObjects.has(node.expression.text) && + node.name.text === "page" + ) { + usesPage = true; + return; + } + if ( + ts.isElementAccessExpression(node) && + ts.isIdentifier(node.expression) && + fixtureObjects.has(node.expression.text) && + node.argumentExpression && + stringKey(node.argumentExpression) === "page" + ) { + usesPage = true; + return; + } + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer && + ts.isIdentifier(node.initializer) && + fixtureObjects.has(node.initializer.text) && + bindingContainsPageFixture(node.name) + ) { + usesPage = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(callback.body); + return usesPage || callbackCreatesPage(callback); +} + +function fixturePageReceivers(callback) { + const receivers = new Set(); + const fixtureObjects = new Set(); + for (const parameter of callback.parameters) { + if (ts.isIdentifier(parameter.name)) + fixtureObjects.add(parameter.name.text); + if (ts.isObjectBindingPattern(parameter.name)) { + for (const element of parameter.name.elements) { + if (element.dotDotDotToken && ts.isIdentifier(element.name)) + fixtureObjects.add(element.name.text); + if ( + ts.isIdentifier(element.name) && + (element.propertyName?.getText() === "page" || + (!element.propertyName && element.name.text === "page")) + ) + receivers.add(element.name.text); + } + } + } + const bindings = bindingInitializers(callback); + let changed = true; + while (changed) { + changed = false; + for (const [name, initializer] of bindings) { + if ( + ts.isIdentifier(initializer) && + fixtureObjects.has(initializer.text) && + !fixtureObjects.has(name) + ) { + fixtureObjects.add(name); + changed = true; + } + } + } + const visit = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer && + ts.isIdentifier(node.initializer) && + fixtureObjects.has(node.initializer.text) + ) { + for (const element of node.name.elements) { + if ( + ts.isIdentifier(element.name) && + (element.propertyName?.getText() === "page" || + (!element.propertyName && element.name.text === "page")) + ) + receivers.add(element.name.text); + } + } + ts.forEachChild(node, visit); + }; + visit(callback.body); + return receivers; +} + +function memberExpression(expression) { + const member = unwrapParentheses(expression); + return ts.isPropertyAccessExpression(member) || + ts.isElementAccessExpression(member) + ? member + : undefined; +} + +function propertyName(expression) { + const member = memberExpression(expression); + if (!member) return undefined; + if (ts.isPropertyAccessExpression(member)) return member.name.text; + return member.argumentExpression && + ts.isStringLiteral(member.argumentExpression) + ? member.argumentExpression.text + : undefined; +} + +function receiverName(expression) { + const member = memberExpression(expression); + if (!member) return undefined; + const receiver = unwrapParentheses(member.expression); + return ts.isIdentifier(receiver) ? receiver.text : undefined; +} + +function createdPageReceiver(initializer) { + let expression = unwrapParentheses(initializer); + if (ts.isAwaitExpression(expression)) + expression = unwrapParentheses(expression.expression); + return ( + ts.isCallExpression(expression) && + propertyName(expression.expression) === "newPage" + ); +} + +function createdPageAliases(callback) { + const createdPages = new Set(); + const bindings = bindingInitializers(callback); + let changed = true; + while (changed) { + changed = false; + for (const [name, initializer] of bindings) { + const isCreated = createdPageReceiver(initializer); + const isAlias = + ts.isIdentifier(initializer) && createdPages.has(initializer.text); + if ((isCreated || isAlias) && !createdPages.has(name)) { + createdPages.add(name); + changed = true; + } + } + } + return createdPages; +} + +function callbackCreatesPage(callback) { + return createdPageAliases(callback).size > 0; +} + +function directCall(statement) { + const expression = + ts.isExpressionStatement(statement) || ts.isReturnStatement(statement) + ? statement.expression + : ts.isVariableStatement(statement) && + statement.declarationList.declarations.length === 1 + ? statement.declarationList.declarations[0].initializer + : undefined; + let unwrapped = expression ? unwrapParentheses(expression) : undefined; + if (unwrapped && ts.isAwaitExpression(unwrapped)) + unwrapped = unwrapParentheses(unwrapped.expression); + return unwrapped && ts.isCallExpression(unwrapped) ? unwrapped : undefined; +} + +function calleeIdentifier(call) { + const callee = unwrapParentheses(call.expression); + return ts.isIdentifier(callee) ? callee : undefined; +} + +function readsLocalStorage(node) { + let found = false; + const visit = (child) => { + if (ts.isIdentifier(child) && child.text === "localStorage") found = true; + if (!found) ts.forEachChild(child, visit); + }; + ts.forEachChild(node, visit); + return found; +} + +function unsafeOperationReceiver(call) { + const receiver = receiverName(call.expression); + if (!receiver) return undefined; + const operation = propertyName(call.expression); + if ( + operation !== "goto" && + !(operation === "evaluate" && call.arguments.some(readsLocalStorage)) + ) + return undefined; + return receiver; +} + +function callbackEvents(callback, canonical, helpers, seen = new Set()) { + const parameters = callback.parameters.map((parameter) => + ts.isIdentifier(parameter.name) ? parameter.name.text : undefined, + ); + const events = []; + for (const { statement, conditional } of executableStatements(callback)) { + const call = directCall(statement); + if (!call) continue; + const awaited = awaitedCall(statement); + if ( + !conditional && + awaited && + calleeIdentifier(awaited)?.text === canonical.bootstrap && + awaited.arguments[0] && + ts.isIdentifier(awaited.arguments[0]) + ) { + const index = parameters.indexOf(awaited.arguments[0].text); + if (index >= 0) events.push({ kind: "bootstrap", index }); + } + const receiver = unsafeOperationReceiver(call); + if (receiver) { + const index = parameters.indexOf(receiver); + if (index < 0) return undefined; + events.push({ kind: "unsafe", index }); + } + const helperName = calleeIdentifier(call)?.text; + if (helperName) { + const helper = helpers.get(helperName); + if (!helper) continue; + if (seen.has(helperName)) return undefined; + const nestedSeen = new Set(seen).add(helperName); + const nestedEvents = callbackEvents( + helper, + canonical, + helpers, + nestedSeen, + ); + if (!nestedEvents) return undefined; + for (const event of nestedEvents) { + const argument = call.arguments[event.index]; + if (!argument || !ts.isIdentifier(argument)) return undefined; + const index = parameters.indexOf(argument.text); + if (index < 0) return undefined; + events.push({ + kind: + event.kind === "bootstrap" && (!awaited || conditional) + ? "conditional-bootstrap" + : event.kind, + index, + }); + } + } + } + return events; +} + +function hasUnbootstrappedReceiverOperation( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped = false, +) { + const receivers = new Set([ + ...createdPageAliases(callback), + ...fixturePageReceivers(callback), + ]); + if (!receivers.size) return false; + const bootstrapped = fixtureReceiversBootstrapped + ? fixturePageReceivers(callback) + : new Set(); + for (const { statement, conditional } of executableStatements(callback)) { + const call = directCall(statement); + if (!call) continue; + const awaited = awaitedCall(statement); + if ( + !conditional && + awaited && + calleeIdentifier(awaited)?.text === canonical.bootstrap && + awaited.arguments[0] && + ts.isIdentifier(awaited.arguments[0]) && + receivers.has(awaited.arguments[0].text) + ) + bootstrapped.add(awaited.arguments[0].text); + const receiver = unsafeOperationReceiver(call); + if (receiver && receivers.has(receiver) && !bootstrapped.has(receiver)) + return true; + const helperName = calleeIdentifier(call)?.text; + if (helperName) { + const helper = helpers.get(helperName); + if (!helper) continue; + const events = callbackEvents( + helper, + canonical, + helpers, + new Set([helperName]), + ); + if (!events) return true; + for (const event of events) { + const argument = call.arguments[event.index]; + if (!argument || !ts.isIdentifier(argument)) return true; + const mappedReceiver = argument.text; + if (!receivers.has(mappedReceiver)) return true; + if (event.kind === "unsafe" && !bootstrapped.has(mappedReceiver)) + return true; + if (!conditional && event.kind === "bootstrap") + bootstrapped.add(mappedReceiver); + } + } + } + return false; +} + +function memberOf(call) { + return ts.isPropertyAccessExpression(call.expression) && + ts.isIdentifier(call.expression.expression) && + call.expression.expression.text === "test" + ? call.expression.name.text + : undefined; +} + +function helperImport(file, filename, canonicalHelperPath) { + for (const statement of file.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) + ) + continue; + const resolved = path.resolve( + path.dirname(filename), + `${statement.moduleSpecifier.text}.ts`, + ); + const canonical = canonicalHelperPath + ? resolved === canonicalHelperPath + : statement.moduleSpecifier.text === "../helpers/test"; + if (!canonical) continue; + const elements = statement.importClause?.namedBindings; + if (!elements || !ts.isNamedImports(elements)) return undefined; + const names = new Map( + elements.elements.map((element) => [ + element.name.text, + element.propertyName?.text ?? element.name.text, + ]), + ); + if ( + names.get("test") === "test" && + names.get("bootstrapE2ePage") === "bootstrapE2ePage" + ) + return { test: "test", bootstrap: "bootstrapE2ePage" }; + } +} + +function bindingNameShadows(name, canonical) { + if (ts.isIdentifier(name)) + return name.text === canonical.test || name.text === canonical.bootstrap; + return name.elements.some( + (element) => + !ts.isOmittedExpression(element) && + bindingNameShadows(element.name, canonical), + ); +} + +function hasShadowingDeclaration(file, canonical) { + let shadowed = false; + const visit = (node) => { + if (shadowed) return; + if ( + ts.isVariableDeclaration(node) && + bindingNameShadows(node.name, canonical) + ) { + shadowed = true; + return; + } + if ( + (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && + node.name && + ts.isIdentifier(node.name) && + (node.name.text === canonical.test || + node.name.text === canonical.bootstrap) + ) { + shadowed = true; + return; + } + if ( + (ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node)) && + node.parameters.some((parameter) => + bindingNameShadows(parameter.name, canonical), + ) + ) { + shadowed = true; + return; + } + ts.forEachChild(node, visit); + }; + for (const statement of file.statements) { + if (ts.isImportDeclaration(statement)) continue; + visit(statement); + } + return shadowed; +} + +function collectModuleHelpers(file) { + const helpers = new Map(); + for (const statement of file.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) + helpers.set(statement.name.text, statement); + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.initializer && + (ts.isArrowFunction(declaration.initializer) || + ts.isFunctionExpression(declaration.initializer)) + ) + helpers.set(declaration.name.text, declaration.initializer); + } + } + } + return helpers; +} + +function awaitedCall(statement) { + const expression = + ts.isExpressionStatement(statement) || ts.isReturnStatement(statement) + ? statement.expression + : ts.isVariableStatement(statement) && + statement.declarationList.declarations.length === 1 + ? statement.declarationList.declarations[0].initializer + : undefined; + const unwrapped = expression ? unwrapParentheses(expression) : undefined; + if (!unwrapped || !ts.isAwaitExpression(unwrapped)) return undefined; + const call = unwrapParentheses(unwrapped.expression); + return ts.isCallExpression(call) ? call : undefined; +} + +function unwrapParentheses(expression) { + while (ts.isParenthesizedExpression(expression)) + expression = expression.expression; + return expression; +} + +function isStaticallyEmptyLoop(statement) { + if (ts.isForStatement(statement)) { + const condition = statement.condition + ? unwrapParentheses(statement.condition) + : undefined; + return condition?.kind === ts.SyntaxKind.FalseKeyword; + } + if (ts.isWhileStatement(statement)) + return ( + unwrapParentheses(statement.expression).kind === + ts.SyntaxKind.FalseKeyword + ); + if (ts.isForOfStatement(statement)) { + const expression = guaranteedIterableExpression(statement); + return ( + (ts.isArrayLiteralExpression(expression) && + !expression.elements.length) || + (ts.isStringLiteral(expression) && !expression.text.length) + ); + } + if (ts.isForInStatement(statement)) { + const expression = unwrapParentheses(statement.expression); + return ( + ts.isObjectLiteralExpression(expression) && !expression.properties.length + ); + } + return false; +} + +function unwrapConstAssertion(expression) { + expression = unwrapParentheses(expression); + return ts.isAsExpression(expression) && expression.type.getText() === "const" + ? unwrapParentheses(expression.expression) + : expression; +} + +function numericLiteral(expression) { + expression = unwrapParentheses(expression); + return ts.isNumericLiteral(expression) ? Number(expression.text) : undefined; +} + +function staticallyTrueComparison(expression, bindings = new Map()) { + expression = unwrapParentheses(expression); + if (!ts.isBinaryExpression(expression)) return false; + const left = ts.isIdentifier(expression.left) + ? bindings.get(expression.left.text) + : numericLiteral(expression.left); + const right = ts.isIdentifier(expression.right) + ? bindings.get(expression.right.text) + : numericLiteral(expression.right); + if (left === undefined || right === undefined) return false; + switch (expression.operatorToken.kind) { + case ts.SyntaxKind.LessThanToken: + return left < right; + case ts.SyntaxKind.LessThanEqualsToken: + return left <= right; + case ts.SyntaxKind.GreaterThanToken: + return left > right; + case ts.SyntaxKind.GreaterThanEqualsToken: + return left >= right; + case ts.SyntaxKind.EqualsEqualsToken: + case ts.SyntaxKind.EqualsEqualsEqualsToken: + return left === right; + default: + return false; + } +} + +function precedingConstInitializer(statement, name) { + const parent = statement.parent; + if (!ts.isBlock(parent)) return undefined; + for (const sibling of parent.statements) { + if (sibling === statement) return undefined; + if (!ts.isVariableStatement(sibling)) continue; + if (!(sibling.declarationList.flags & ts.NodeFlags.Const)) continue; + for (const declaration of sibling.declarationList.declarations) + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === name && + declaration.initializer + ) + return declaration.initializer; + } + return undefined; +} + +function guaranteedIterableExpression(statement) { + const expression = unwrapConstAssertion(statement.expression); + if (!ts.isIdentifier(expression)) return expression; + const initializer = precedingConstInitializer(statement, expression.text); + return initializer ? unwrapConstAssertion(initializer) : expression; +} + +function isConcreteObjectContributor(property) { + if (ts.isSpreadAssignment(property)) return false; + if (ts.isComputedPropertyName(property.name)) { + const expression = unwrapParentheses(property.name.expression); + return ( + ts.isStringLiteral(expression) || + ts.isNoSubstitutionTemplateLiteral(expression) || + ts.isNumericLiteral(expression) + ); + } + return ( + !ts.isPropertyAssignment(property) || property.name.text !== "__proto__" + ); +} + +function isGuaranteedIteration(statement) { + if (ts.isDoStatement(statement)) return true; + if (ts.isWhileStatement(statement)) + return isStaticallyTrue(statement.expression); + if (ts.isForOfStatement(statement)) { + const expression = guaranteedIterableExpression(statement); + return ( + (ts.isArrayLiteralExpression(expression) && + expression.elements.some((element) => !ts.isSpreadElement(element))) || + (ts.isStringLiteral(expression) && expression.text.length > 0) + ); + } + if (ts.isForInStatement(statement)) { + const expression = unwrapParentheses(statement.expression); + return ( + ts.isObjectLiteralExpression(expression) && + expression.properties.some(isConcreteObjectContributor) + ); + } + if (!ts.isForStatement(statement)) return false; + if (!statement.condition || isStaticallyTrue(statement.condition)) + return true; + const numericBindings = new Map(); + if ( + statement.initializer && + ts.isVariableDeclarationList(statement.initializer) + ) + for (const declaration of statement.initializer.declarations) { + const value = declaration.initializer + ? numericLiteral(declaration.initializer) + : undefined; + if (ts.isIdentifier(declaration.name) && value !== undefined) + numericBindings.set(declaration.name.text, value); + } + return staticallyTrueComparison(statement.condition, numericBindings); +} + +function isStaticallyFalse(expression) { + return unwrapParentheses(expression).kind === ts.SyntaxKind.FalseKeyword; +} + +function isStaticallyTrue(expression) { + return unwrapParentheses(expression).kind === ts.SyntaxKind.TrueKeyword; +} + +function* executableStatements(callback) { + const statements = ts.isBlock(callback.body) + ? callback.body.statements + : [ts.factory.createExpressionStatement(callback.body)]; + function* walk(statement, conditional = false) { + if (ts.isBlock(statement)) { + for (const child of statement.statements) yield* walk(child, conditional); + return; + } + if (ts.isIfStatement(statement)) { + if (!isStaticallyFalse(statement.expression)) + yield* walk(statement.thenStatement, true); + if (statement.elseStatement && !isStaticallyTrue(statement.expression)) + yield* walk(statement.elseStatement, true); + return; + } + if (ts.isSwitchStatement(statement)) { + for (const clause of statement.caseBlock.clauses) + for (const child of clause.statements) yield* walk(child, true); + return; + } + if (ts.isTryStatement(statement)) { + yield* walk(statement.tryBlock, conditional); + if (statement.catchClause) yield* walk(statement.catchClause.block, true); + if (statement.finallyBlock) + yield* walk(statement.finallyBlock, conditional); + return; + } + if ( + (ts.isForStatement(statement) || + ts.isForInStatement(statement) || + ts.isForOfStatement(statement) || + ts.isWhileStatement(statement) || + ts.isDoStatement(statement)) && + !isStaticallyEmptyLoop(statement) + ) { + yield* walk( + statement.statement, + conditional || !isGuaranteedIteration(statement), + ); + return; + } + yield { statement, conditional }; + } + for (const statement of statements) yield* walk(statement); +} + +function bootstrapReceivers( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped = false, +) { + const receivers = fixtureReceiversBootstrapped + ? fixturePageReceivers(callback) + : new Set(); + for (const { statement, conditional } of executableStatements(callback)) { + const call = awaitedCall(statement); + if (!call) continue; + if ( + !conditional && + calleeIdentifier(call)?.text === canonical.bootstrap && + call.arguments[0] && + ts.isIdentifier(call.arguments[0]) + ) + receivers.add(call.arguments[0].text); + const helperName = calleeIdentifier(call)?.text; + if (!conditional && helperName) { + const helper = helpers.get(helperName); + if (helper) { + const events = callbackEvents( + helper, + canonical, + helpers, + new Set([helperName]), + ); + if (!events) return undefined; + for (const event of events) { + if (event.kind !== "bootstrap") continue; + const argument = call.arguments[event.index]; + if (argument && ts.isIdentifier(argument)) + receivers.add(argument.text); + } + } + } + } + return receivers; +} + +function callbackBootstraps( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped = false, +) { + if ( + hasUnbootstrappedReceiverOperation( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped, + ) + ) + return false; + const receivers = bootstrapReceivers( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped, + ); + if (!receivers) return false; + return ( + [...fixturePageReceivers(callback)].every((receiver) => + receivers.has(receiver), + ) && receivers.size > 0 + ); +} + +function makeScope(parent) { + return { parent, hooks: [], tests: [], children: [] }; +} + +function hasPageFixtureTest(file) { + let found = false; + const visit = (node) => { + if (found) return; + if (ts.isCallExpression(node)) { + const callback = callbackOf(node); + if ( + callback && + ((ts.isIdentifier(node.expression) && + node.expression.text === "test") || + (ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === "test" && + (TEST_MEMBERS.has(node.expression.name.text) || + node.expression.name.text === "beforeEach"))) && + callbackUsesPageFixture(callback) + ) { + found = true; + return; + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(file, visit); + return found; +} + +function scanFile(file, canonical) { + const helpers = collectModuleHelpers(file); + const root = makeScope(undefined); + const isTestCall = (call) => + ts.isIdentifier(call.expression) && call.expression.text === canonical.test; + const isMember = (call, name) => memberOf(call) === name; + + const scanStatements = (statements, scope) => { + for (const statement of statements) { + if (ts.isBlock(statement)) { + scanStatements(statement.statements, scope); + continue; + } + if ( + ts.isForStatement(statement) || + ts.isForInStatement(statement) || + ts.isForOfStatement(statement) + ) { + scanStatements( + ts.isBlock(statement.statement) + ? statement.statement.statements + : [statement.statement], + scope, + ); + continue; + } + if ( + !ts.isExpressionStatement(statement) || + !ts.isCallExpression(statement.expression) + ) + continue; + const call = statement.expression; + const callback = callbackOf(call); + if (isMember(call, "describe") && callback) { + const child = makeScope(scope); + scope.children.push(child); + if (ts.isBlock(callback.body)) + scanStatements(callback.body.statements, child); + continue; + } + if (isMember(call, "beforeEach") && callback) { + scope.hooks.push(callbackBootstraps(callback, canonical, helpers)); + continue; + } + if ( + isTestCall(call) || + (memberOf(call) && TEST_MEMBERS.has(memberOf(call))) + ) { + if (callback) + scope.tests.push({ + bootstraps: callbackBootstraps(callback, canonical, helpers), + bootstrapsWithFixtureHook: callbackBootstraps( + callback, + canonical, + helpers, + true, + ), + usesPage: callbackUsesPageFixture(callback), + }); + continue; + } + if ( + ts.isPropertyAccessExpression(call.expression) && + CONTAINERS.has(call.expression.name.text) && + callback && + ts.isArrayLiteralExpression(call.expression.expression) + ) { + if (ts.isBlock(callback.body)) + scanStatements(callback.body.statements, scope); + } + } + }; + scanStatements(file.statements, root); + return root; +} + +function everyTestCovered(scope, inheritedHook = false) { + const coveredByHook = inheritedHook || scope.hooks.some(Boolean); + return ( + scope.tests.every( + (test) => + !test.usesPage || + test.bootstraps || + (coveredByHook && test.bootstrapsWithFixtureHook), + ) && scope.children.every((child) => everyTestCovered(child, coveredByHook)) + ); +} + +function testCount(scope) { + return ( + scope.tests.filter((test) => test.usesPage).length + + scope.children.reduce((count, child) => count + testCount(child), 0) + ); +} + +export function checkSource( + source, + filename = "fixture.spec.ts", + canonicalHelperPath, +) { + const file = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + ); + const canonical = helperImport(file, filename, canonicalHelperPath); + if (!canonical) + return "must directly import unaliased test and bootstrapE2ePage from the canonical tests/helpers/test module"; + if (hasShadowingDeclaration(file, canonical)) + return "must not shadow canonical test or bootstrapE2ePage imports"; + const scope = scanFile(file, canonical); + if (!testCount(scope)) return "does not register a browser test"; + if (!everyTestCovered(scope)) + return "must structurally await bootstrapE2ePage for every test, directly or through an applicable ancestor test.beforeEach"; +} + +function specFiles(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const child = path.join(directory, entry.name); + if (entry.isDirectory()) return specFiles(child); + return entry.isFile() && SPEC_PATTERN.test(entry.name) ? [child] : []; + }); +} + +export function runCheck(projectRoot) { + const e2eRoot = path.join(projectRoot, "tests/e2e"); + const canonicalHelperPath = path.join(projectRoot, "tests/helpers/test.ts"); + return specFiles(e2eRoot).flatMap((filename) => { + const relative = path.relative(e2eRoot, filename); + const source = fs.readFileSync(filename, "utf8"); + const file = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + ); + if (!hasPageFixtureTest(file)) return []; + const violation = checkSource(source, filename, canonicalHelperPath); + return violation ? [`${relative}: ${violation}`] : []; + }); +} + +if (process.argv[1] === import.meta.filename) { + const violations = runCheck(path.resolve(import.meta.dirname, "..")); + if (violations.length) { + console.error( + `E2E specs must register bootstrapE2ePage after their setup:\n${violations.join("\n")}`, + ); + process.exit(1); + } +} diff --git a/desktop/scripts/check-e2e-bootstrap.test.mjs b/desktop/scripts/check-e2e-bootstrap.test.mjs new file mode 100644 index 00000000000..b0c31c9d13e --- /dev/null +++ b/desktop/scripts/check-e2e-bootstrap.test.mjs @@ -0,0 +1,1342 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const projects = [ + ["desktop", "../scripts/check-e2e-bootstrap.mjs"], + ["web", "../scripts/check-e2e-bootstrap.mjs"], +]; +const imports = 'import { test, bootstrapE2ePage } from "../helpers/test";'; +const spec = (body) => + `${imports}\ntest("x", async ({ page }) => { ${body} });`; + +for (const [project, checkerPath] of projects) { + const { checkSource, runCheck } = await import(checkerPath); + const accepts = (source) => assert.equal(checkSource(source), undefined); + const rejects = (source) => assert.notEqual(checkSource(source), undefined); + + test(`${project}: accepts direct, hook, and beforeNavigate bootstrap patterns`, () => { + accepts(spec("await bootstrapE2ePage(page);")); + accepts( + `${imports}\ntest.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); });\ntest("x", async ({ page }) => { await page.title(); });`, + ); + accepts( + spec( + "await bootstrapE2ePage(page, { beforeNavigate: async () => page.addInitScript(() => {}) });", + ), + ); + }); + + test(`${project}: accepts module helpers and ratified containers`, () => { + accepts( + `${imports}\nasync function open(page) { return await bootstrapE2ePage(page); }\ntest("x", async ({ page }) => { const response = await open(page); void response; });`, + ); + accepts( + spec( + "try { await bootstrapE2ePage(page); } finally { await page.title(); }", + ), + ); + accepts( + spec( + "try { await page.title(); } finally { await bootstrapE2ePage(page); }", + ), + ); + accepts( + spec("for (const attempt of [0, 1]) { await bootstrapE2ePage(page); }"), + ); + accepts( + spec( + "for (let attempt = 0; attempt < 2; attempt += 1) { await bootstrapE2ePage(page); }", + ), + ); + }); + + test(`${project}: classifies indirect page fixture access and requires bootstrap`, () => { + const cases = [ + [ + "whole fixture object", + "fixtures", + "await fixtures.page.title();", + "const { page } = fixtures; await bootstrapE2ePage(page);", + ], + [ + "object rest", + "{ ...rest }", + "await rest.page.title();", + "const { page } = rest; await bootstrapE2ePage(page);", + ], + [ + "body destructuring", + "fixtures", + "const { page } = fixtures; await page.title();", + "const { page } = fixtures; await bootstrapE2ePage(page);", + ], + ]; + for (const [name, parameter, body, bootstrap] of cases) { + const source = `${imports}\ntest("${name}", async (${parameter}) => { ${body} });`; + assert.equal( + checkSource(source), + "must structurally await bootstrapE2ePage for every test, directly or through an applicable ancestor test.beforeEach", + ); + accepts( + `${imports}\ntest("${name}", async (${parameter}) => { ${bootstrap} ${body} });`, + ); + } + }); + + test(`${project}: follows bounded fixture-object aliases and element access`, () => { + const cases = [ + "const alias = fixtures; await alias.page.title();", + 'await fixtures["page"].title();', + "const first = fixtures; const second = first; const { page } = second; await page.title();", + ]; + for (const body of cases) { + const source = `${imports}\ntest("x", async (fixtures) => { ${body} });`; + assert.equal( + checkSource(source), + "must structurally await bootstrapE2ePage for every test, directly or through an applicable ancestor test.beforeEach", + ); + accepts( + `${imports}\ntest("x", async (fixtures) => { const { page } = fixtures; await bootstrapE2ePage(page); ${body} });`, + ); + } + }); + + test(`${project}: requires bootstrap for each created page receiver`, () => { + const unsafe = `${imports} + test("x", async ({ page, context }) => { + await bootstrapE2ePage(page); + const second = await context.newPage(); + await second.goto("/"); + });`; + assert.equal( + checkSource(unsafe), + "must structurally await bootstrapE2ePage for every test, directly or through an applicable ancestor test.beforeEach", + ); + rejects(`${imports} + test("x", async ({ page, context }) => { + const second = await context.newPage(); + await bootstrapE2ePage(second, "/"); + await page.title(); + });`); + accepts(`${imports} + test("x", async ({ page, context }) => { + const second = await context.newPage(); + await bootstrapE2ePage(page, "/"); + await bootstrapE2ePage(second, "/"); + await page.title(); + });`); + rejects(`${imports} + test("x", async ({ context }) => { + const second = await context.newPage(); + await second.goto("/"); + });`); + rejects(`${imports} + test("x", async ({ context }) => { + const second = await context.newPage(); + await second.goto("/"); + await bootstrapE2ePage(second, "/"); + });`); + rejects(`${imports} + test("x", async ({ page, context }) => { + await bootstrapE2ePage(page); + const second = await context.newPage(); + bootstrapE2ePage(second, "/"); + await second.goto("/"); + });`); + accepts(`${imports} + test("x", async ({ page, context }) => { + await bootstrapE2ePage(page); + const second = await context.newPage(); + await bootstrapE2ePage(second, "/"); + });`); + rejects(`${imports} + test("x", async ({ context }) => { + const second = await context.newPage(); + await second.goto("/"); + await bootstrapE2ePage(second, "/"); + await second.goto("/later"); + });`); + accepts(`${imports} + test("x", async ({ context }) => { + const second = await context.newPage(); + await bootstrapE2ePage(second, "/"); + await second.goto("/"); + await bootstrapE2ePage(second, "/later"); + await second.goto("/later"); + });`); + + rejects(`${imports} + async function navigate(target) { await target.goto("/"); } + test("fixture navigation before bootstrap", async ({ page }) => { + await navigate(page); + await bootstrapE2ePage(page, "/"); + });`); + rejects(`${imports} + async function readStorage(target) { await target.evaluate(() => localStorage.getItem("x")); } + test("fixture storage before bootstrap", async ({ page }) => { + await readStorage(page); + await bootstrapE2ePage(page, "/"); + });`); + accepts(`${imports} + test("fixture setup before bootstrap", async ({ page }) => { + await page.addInitScript(() => {}); + await bootstrapE2ePage(page, "/"); + });`); + + for (const body of [ + 'const second = await context.newPage(); if (false) await bootstrapE2ePage(second); await second.goto("/");', + 'const second = await context.newPage(); async function never() { await bootstrapE2ePage(second); } await second.goto("/");', + 'let second; second = await context.newPage(); await second.goto("/");', + 'const second = await context.newPage(); second.goto("/");', + ]) + rejects( + `${imports}\n test("x", async ({ context }) => { ${body} });`, + ); + }); + + test(`${project}: preserves helper event order and actual-argument mapping`, () => { + rejects(`${imports} + async function unsafeThenBootstrap(target) { + await target.goto("/"); + await bootstrapE2ePage(target); + } + test("x", async ({ page }) => { await unsafeThenBootstrap(page); });`); + accepts(`${imports} + async function bootstrapThenUnsafe(target) { + await bootstrapE2ePage(target); + await target.goto("/"); + } + test("x", async ({ page }) => { await bootstrapThenUnsafe(page); });`); + rejects(`${imports} + async function inner(safeTarget, unsafeTarget) { + await bootstrapE2ePage(safeTarget); + await unsafeTarget.goto("/"); + } + async function outer(unsafeTarget, other) { await inner(other, unsafeTarget); } + test("x", async ({ page }) => { + const other = {}; + await outer(page, other); + });`); + accepts(`${imports} + async function inner(target) { await bootstrapE2ePage(target); } + async function outer(unused, target) { await inner(target); } + test("x", async ({ page }) => { await outer(undefined, page); });`); + }); + + test(`${project}: conservatively checks executable conditional branches`, () => { + rejects( + spec( + 'if (condition) await page.goto("/"); await bootstrapE2ePage(page);', + ), + ); + rejects( + spec( + 'switch (mode) { case "go": await page.goto("/"); break; default: break; } await bootstrapE2ePage(page);', + ), + ); + accepts( + spec( + 'await bootstrapE2ePage(page); if (condition) await page.goto("/");', + ), + ); + accepts( + spec( + 'await bootstrapE2ePage(page); switch (mode) { case "go": await page.goto("/"); break; default: break; }', + ), + ); + rejects( + spec( + 'if (condition) await bootstrapE2ePage(page); await page.goto("/");', + ), + ); + accepts( + spec('if (false) await page.goto("/"); await bootstrapE2ePage(page);'), + ); + }); + + test(`${project}: keeps hook coverage receiver-aware`, () => { + rejects(`${imports} + test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); + test("x", async ({ context }) => { + const second = await context.newPage(); + await second.goto("/"); + });`); + accepts(`${imports} + test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); + test("x", async ({ page }) => { await page.title(); });`); + accepts(`${imports} + test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); + test("x", async ({ context }) => { + const second = await context.newPage(); + await bootstrapE2ePage(second); + await second.goto("/"); + });`); + }); + + test(`${project}: conservatively checks catch work`, () => { + rejects( + spec( + 'try { await page.title(); } catch { await page.goto("/"); } await bootstrapE2ePage(page);', + ), + ); + accepts( + spec( + 'await bootstrapE2ePage(page); try { await page.title(); } catch { await page.goto("/"); }', + ), + ); + rejects( + spec( + 'try { await page.title(); } catch { await bootstrapE2ePage(page); } await page.goto("/");', + ), + ); + }); + + test(`${project}: propagates unawaited helper unsafe events`, () => { + rejects( + `${imports} async function read(target) { await target.evaluate(() => localStorage.getItem("x")); } test("x", async ({ page }) => { read(page); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { go(page); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function boot(target) { await bootstrapE2ePage(target); } test("x", async ({ page }) => { boot(page); await page.goto("/"); });`, + ); + accepts( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { await bootstrapE2ePage(page); await go(page); });`, + ); + accepts( + `${imports} async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { setup(page); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: preserves safety events through parenthesized helper calls`, () => { + for (const call of ["(go(page));", "await (go(page));"]) + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { ${call} await bootstrapE2ePage(page); });`, + ); + for (const call of ["(go(page));", "await (go(page));"]) + accepts( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { await bootstrapE2ePage(page); ${call} });`, + ); + }); + + test(`${project}: preserves safety events through parenthesized helper callees`, () => { + for (const call of ["(go)(page);", "await (go)(page);"]) + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { ${call} await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { await (go(page)); await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { await (setup)(page); await (bootstrapE2ePage)(page); });`, + ); + }); + + test(`${project}: preserves supported operations through member parentheses`, () => { + for (const operation of [ + 'await (page.goto)("/");', + 'await (page).goto("/");', + 'await ((page)["goto"])("/");', + 'await (page.evaluate)(() => localStorage.getItem("x"));', + ]) + rejects(spec(`${operation} await bootstrapE2ePage(page);`)); + for (const operation of [ + 'await (target.goto)("/");', + 'await (target).goto("/");', + 'await (target.evaluate)(() => localStorage.getItem("x"));', + ]) + rejects( + `${imports} async function unsafe(target) { ${operation} } test("x", async ({ page }) => { await unsafe(page); await bootstrapE2ePage(page); });`, + ); + accepts(spec('await bootstrapE2ePage(page); await (page.goto)("/");')); + }); + + test(`${project}: recognizes parenthesized created-page operations`, () => { + for (const creation of [ + "await (context.newPage)()", + "await (context).newPage()", + 'await ((context)["newPage"])()', + "await ((context.newPage)())", + ]) + rejects( + `${imports} test("x", async ({ context }) => { const second = ${creation}; await second.goto("/"); });`, + ); + accepts( + `${imports} test("x", async ({ context }) => { const second = await (context.newPage)(); await bootstrapE2ePage(second); await (second.goto)("/"); });`, + ); + }); + + test(`${project}: grants loop bootstrap credit only for guaranteed iterations`, () => { + for (const loop of [ + "for (const value of values) await bootstrapE2ePage(page);", + "for (const key in values) await bootstrapE2ePage(page);", + "for (const value of [...[]]) await bootstrapE2ePage(page);", + "for (const value of [...values]) await bootstrapE2ePage(page);", + "for (const key in { ...{} }) await bootstrapE2ePage(page);", + "for (const key in { ...values }) await bootstrapE2ePage(page);", + "for (const key in { __proto__: null }) await bootstrapE2ePage(page);", + 'for (const key in { "__proto__": null }) await bootstrapE2ePage(page);', + "for (const key in { [Symbol()]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [Symbol.iterator]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [key]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [getKey()]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [Symbol.iterator]() {} }) await bootstrapE2ePage(page);", + "for (const key in { get [Symbol.iterator]() { return 1 } }) await bootstrapE2ePage(page);", + "for (const key in { set [Symbol.iterator](value) {} }) await bootstrapE2ePage(page);", + "for (const key in { [key]() {} }) await bootstrapE2ePage(page);", + "for (const key in { get [key]() { return 1 } }) await bootstrapE2ePage(page);", + "for (const key in { set [key](value) {} }) await bootstrapE2ePage(page);", + "while (condition) await bootstrapE2ePage(page);", + "for (; condition;) await bootstrapE2ePage(page);", + ]) + rejects(spec(`${loop} await page.goto("/");`)); + rejects( + spec( + 'let values = []; for (const value of values) await bootstrapE2ePage(page); values = [1]; await page.goto("/");', + ), + ); + accepts( + spec( + 'const values = [1]; for (const value of values) await bootstrapE2ePage(page); await page.goto("/");', + ), + ); + rejects( + spec( + 'const values = [...[]]; for (const value of values) await bootstrapE2ePage(page); await page.goto("/");', + ), + ); + for (const loop of [ + "for (const value of [1]) await bootstrapE2ePage(page);", + "for (const value of [...[], 1]) await bootstrapE2ePage(page);", + "for (const key in { ...{}, x: 1 }) await bootstrapE2ePage(page);", + 'for (const key in { ["__proto__"]: null }) await bootstrapE2ePage(page);', + "for (const key in { [`x`]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [1]: 1 }) await bootstrapE2ePage(page);", + 'for (const key in { ["x"]() {} }) await bootstrapE2ePage(page);', + 'for (const key in { get ["x"]() { return 1 } }) await bootstrapE2ePage(page);', + 'for (const key in { set ["x"](value) {} }) await bootstrapE2ePage(page);', + "for (const key in { __proto__: null, x: 1 }) await bootstrapE2ePage(page);", + 'for (const value of "x") await bootstrapE2ePage(page);', + "for (const key in { x: 1 }) await bootstrapE2ePage(page);", + "for (;;) { await bootstrapE2ePage(page); break; }", + "while (true) { await bootstrapE2ePage(page); break; }", + "do { await bootstrapE2ePage(page); } while (condition);", + ]) + accepts(spec(`${loop} await page.goto("/");`)); + rejects( + spec( + 'for (const value of values) await page.goto("/"); await bootstrapE2ePage(page);', + ), + ); + }); + + test(`${project}: invalidates unsafe events born on unmapped helper receivers`, () => { + for (const operation of [ + 'const alias = target; await alias.goto("/");', + 'const alias = target; await alias.evaluate(() => localStorage.getItem("x"));', + 'const unrelated = {}; await unrelated.goto("/");', + ]) + rejects( + `${imports} async function unsafe(target) { ${operation} } test("x", async ({ page }) => { await unsafe(page); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function unsafe(target) { await target.goto("/"); } test("x", async ({ page }) => { await unsafe(page); await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function setup(target) { const alias = target; await alias.addInitScript(() => {}); } test("x", async ({ page }) => { await setup(page); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: fails closed when callback helper events cannot map to receivers`, () => { + for (const actual of ["alias", "unrelated"]) + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { const alias = page; const unrelated = {}; await go(${actual}); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { await go(page); await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { const alias = page; await setup(alias); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: classifies bounded element-access operations`, () => { + rejects(spec('await page["goto"]("/"); await bootstrapE2ePage(page);')); + rejects( + spec( + 'await page["evaluate"](() => localStorage.getItem("x")); await bootstrapE2ePage(page);', + ), + ); + rejects( + `${imports} test("x", async ({ context }) => { const second = await context["newPage"](); await second.goto("/"); });`, + ); + accepts(spec('await bootstrapE2ePage(page); await page["goto"]("/");')); + accepts( + `${imports} test("x", async ({ context }) => { const second = await context["newPage"](); await bootstrapE2ePage(second); await second["goto"]("/"); });`, + ); + }); + + test(`${project}: fails closed for recursive and unmapped helpers`, () => { + rejects( + `${imports} async function setup(target) { await bootstrapE2ePage(target); await setup(target); } test("x", async ({ page }) => { await setup(page); });`, + ); + rejects( + `${imports} async function a(target) { await bootstrapE2ePage(target); await b(target); } async function b(target) { await a(target); } test("x", async ({ page }) => { await a(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { go(page as any); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { go((page)); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async (fixtures) => { go(fixtures.page); const { page } = fixtures; await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function inner(target) { await bootstrapE2ePage(target); } async function outer(target) { await inner(target); } test("x", async ({ page }) => { await outer(page); });`, + ); + accepts( + `${imports} async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { setup(page as any); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: rejects nested safety events mapped through local identifiers`, () => { + rejects( + `${imports} async function inner(target) { await target.goto("/"); } async function outer(target) { const alias = target; await inner(alias); } test("x", async ({ page }) => { await outer(page); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function inner(target) { await target.goto("/"); } async function outer(target) { await inner(target); } test("x", async ({ page }) => { await outer(page); await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function inner(target) { await target.addInitScript(() => {}); } async function outer(target) { const alias = target; await inner(alias); } test("x", async ({ page }) => { await outer(page); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: applies created-page receiver enforcement in whole-file checks`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-created-page-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + for (const [name, body] of [ + [ + "wrong-fixture-receiver.spec.ts", + 'const second = await context.newPage(); await bootstrapE2ePage(second, "/"); await page.title();', + ], + [ + "safe-fixture-receiver.spec.ts", + 'const second = await context.newPage(); await bootstrapE2ePage(page, "/"); await bootstrapE2ePage(second, "/"); await page.title();', + ], + [ + "fixture-navigation-before-bootstrap.spec.ts", + 'await page.goto("/"); await bootstrapE2ePage(page, "/");', + ], + [ + "fixture-storage-before-bootstrap.spec.ts", + 'await page.evaluate(() => localStorage.getItem("x")); await bootstrapE2ePage(page, "/");', + ], + [ + "fixture-setup-before-bootstrap.spec.ts", + 'await page.addInitScript(() => {}); await bootstrapE2ePage(page, "/");', + ], + [ + "dead.spec.ts", + 'const second = await context.newPage(); if (false) await bootstrapE2ePage(second); await second.goto("/");', + ], + [ + "nested.spec.ts", + 'const second = await context.newPage(); async function never() { await bootstrapE2ePage(second); } await second.goto("/");', + ], + [ + "assigned.spec.ts", + 'let second; second = await context.newPage(); await second.goto("/");', + ], + [ + "unawaited-navigation.spec.ts", + 'const second = await context.newPage(); second.goto("/");', + ], + [ + "initial-navigation.spec.ts", + 'const second = await context.newPage(); await second.goto("/"); await bootstrapE2ePage(second, "/"); await second.goto("/later");', + ], + [ + "conditional-unsafe.spec.ts", + 'if (condition) await page.goto("/"); await bootstrapE2ePage(page);', + ], + [ + "conditional-safe.spec.ts", + 'await bootstrapE2ePage(page); if (condition) await page.goto("/");', + ], + [ + "switch-unsafe.spec.ts", + 'switch (mode) { case "go": await page.goto("/"); break; default: break; } await bootstrapE2ePage(page);', + ], + [ + "switch-safe.spec.ts", + 'await bootstrapE2ePage(page); switch (mode) { case "go": await page.goto("/"); break; default: break; }', + ], + [ + "initial-bootstrap.spec.ts", + 'await bootstrapE2ePage(page, "/"); const second = await context.newPage(); await bootstrapE2ePage(second, "/"); await second.goto("/"); await bootstrapE2ePage(second, "/later"); await second.goto("/later");', + ], + ]) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} test("x", async ({ page, context }) => { ${body} });`, + ); + fs.writeFileSync( + path.join(root, "tests/e2e/helper-order-unsafe.spec.ts"), + `${imports} async function open(target) { await target.goto("/"); await bootstrapE2ePage(target); } test("x", async ({ page }) => { await open(page); });`, + ); + fs.writeFileSync( + path.join(root, "tests/e2e/helper-order-safe.spec.ts"), + `${imports} async function open(target) { await bootstrapE2ePage(target); await target.goto("/"); } test("x", async ({ page }) => { await open(page); });`, + ); + fs.writeFileSync( + path.join(root, "tests/e2e/helper-mapping-unsafe.spec.ts"), + `${imports} async function inner(safeTarget, unsafeTarget) { await bootstrapE2ePage(safeTarget); await unsafeTarget.goto("/"); } async function outer(unsafeTarget, other) { await inner(other, unsafeTarget); } test("x", async ({ page }) => { const other = {}; await outer(page, other); });`, + ); + fs.writeFileSync( + path.join(root, "tests/e2e/helper-mapping-safe.spec.ts"), + `${imports} async function inner(target) { await bootstrapE2ePage(target); } async function outer(unused, target) { await inner(target); } test("x", async ({ page }) => { await outer(undefined, page); });`, + ); + assert.equal(runCheck(root).length, 12); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: enforces helper-call boundaries in whole-file checks`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-helper-boundaries-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "parenthesized-awaited-unsafe.spec.ts", + "await (go(page)); await bootstrapE2ePage(page);", + ], + [ + "parenthesized-unawaited-unsafe.spec.ts", + "(go(page)); await bootstrapE2ePage(page);", + ], + [ + "parenthesized-awaited-safe.spec.ts", + "await bootstrapE2ePage(page); await (go(page));", + ], + [ + "parenthesized-unawaited-safe.spec.ts", + "await bootstrapE2ePage(page); (go(page));", + ], + [ + "callback-alias-unsafe.spec.ts", + "const alias = page; await go(alias); await bootstrapE2ePage(page);", + ], + [ + "callback-unrelated-unsafe.spec.ts", + "const unrelated = {}; await go(unrelated); await bootstrapE2ePage(page);", + ], + [ + "callback-direct-unsafe.spec.ts", + "await go(page); await bootstrapE2ePage(page);", + ], + [ + "callback-setup-alias-safe.spec.ts", + "const alias = page; await setup(alias); await bootstrapE2ePage(page);", + ], + ]; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} async function go(target) { await target.goto("/"); } async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { ${body} });`, + ); + assert.deepEqual( + runCheck(root) + .map((entry) => entry.split(":")[0]) + .sort(), + [ + "callback-alias-unsafe.spec.ts", + "callback-direct-unsafe.spec.ts", + "callback-unrelated-unsafe.spec.ts", + "parenthesized-awaited-unsafe.spec.ts", + "parenthesized-unawaited-unsafe.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: enforces event birth and callee parentheses in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-event-accounting-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "callee-awaited-unsafe.spec.ts", + "await (go)(page); await bootstrapE2ePage(page);", + ], + [ + "callee-unawaited-unsafe.spec.ts", + "(go)(page); await bootstrapE2ePage(page);", + ], + [ + "whole-call-control.spec.ts", + "await (go(page)); await bootstrapE2ePage(page);", + ], + [ + "birth-alias-goto.spec.ts", + "await aliasGoto(page); await bootstrapE2ePage(page);", + ], + [ + "birth-alias-storage.spec.ts", + "await aliasStorage(page); await bootstrapE2ePage(page);", + ], + [ + "birth-unrelated-goto.spec.ts", + "await unrelatedGoto(page); await bootstrapE2ePage(page);", + ], + [ + "birth-direct-control.spec.ts", + "await go(page); await bootstrapE2ePage(page);", + ], + [ + "setup-control.spec.ts", + "await (setup)(page); await (bootstrapE2ePage)(page);", + ], + ]; + const helpers = ` + async function go(target) { await target.goto("/"); } + async function aliasGoto(target) { const alias = target; await alias.goto("/"); } + async function aliasStorage(target) { const alias = target; await alias.evaluate(() => localStorage.getItem("x")); } + async function unrelatedGoto(target) { const unrelated = {}; await unrelated.goto("/"); } + async function setup(target) { const alias = target; await alias.addInitScript(() => {}); } + `; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} ${helpers} test("x", async ({ page }) => { ${body} });`, + ); + assert.deepEqual( + runCheck(root) + .map((entry) => entry.split(":")[0]) + .sort(), + [ + "birth-alias-goto.spec.ts", + "birth-alias-storage.spec.ts", + "birth-direct-control.spec.ts", + "birth-unrelated-goto.spec.ts", + "callee-awaited-unsafe.spec.ts", + "callee-unawaited-unsafe.spec.ts", + "whole-call-control.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: enforces member parentheses and loop accounting in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-conservative-accounting-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "member-callee.spec.ts", + 'await (page.goto)("/"); await bootstrapE2ePage(page);', + ], + [ + "member-receiver.spec.ts", + 'await (page).goto("/"); await bootstrapE2ePage(page);', + ], + [ + "member-storage.spec.ts", + 'await (page.evaluate)(() => localStorage.getItem("x")); await bootstrapE2ePage(page);', + ], + [ + "helper-member.spec.ts", + "await unsafe(page); await bootstrapE2ePage(page);", + ], + [ + "created-member.spec.ts", + 'const second = await (context.newPage)(); await second.goto("/");', + ], + [ + "created-safe.spec.ts", + 'await bootstrapE2ePage(page); const second = await (context).newPage(); await bootstrapE2ePage(second); await (second.goto)("/");', + ], + [ + "dynamic-for-of.spec.ts", + 'for (const value of values) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "dynamic-while.spec.ts", + 'while (condition) { await bootstrapE2ePage(page); break; } await page.goto("/");', + ], + [ + "spread-array.spec.ts", + 'for (const value of [...[]]) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "spread-object.spec.ts", + 'for (const key in { ...{} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "temporal-loop.spec.ts", + 'let values = []; for (const value of values) await bootstrapE2ePage(page); values = [1]; await page.goto("/");', + ], + [ + "proto-identifier.spec.ts", + 'for (const key in { __proto__: null }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "proto-string.spec.ts", + 'for (const key in { "__proto__": null }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "proto-computed-safe.spec.ts", + 'for (const key in { ["__proto__"]: null }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-number-safe.spec.ts", + 'for (const key in { [1]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol.spec.ts", + 'for (const key in { [Symbol()]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol-member.spec.ts", + 'for (const key in { [Symbol.iterator]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-bound.spec.ts", + 'const key = Symbol(); for (const item in { [key]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-call.spec.ts", + 'for (const key in { [getKey()]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol-method.spec.ts", + 'for (const key in { [Symbol.iterator]() {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol-getter.spec.ts", + 'for (const key in { get [Symbol.iterator]() { return 1 } }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol-setter.spec.ts", + 'for (const key in { set [Symbol.iterator](value) {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-dynamic-method.spec.ts", + 'for (const key in { [dynamicKey]() {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-dynamic-getter.spec.ts", + 'for (const key in { get [dynamicKey]() { return 1 } }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-dynamic-setter.spec.ts", + 'for (const key in { set [dynamicKey](value) {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-static-method-safe.spec.ts", + 'for (const key in { ["x"]() {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-static-getter-safe.spec.ts", + 'for (const key in { get ["x"]() { return 1 } }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-static-setter-safe.spec.ts", + 'for (const key in { set ["x"](value) {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "proto-mixed-safe.spec.ts", + 'for (const key in { __proto__: null, x: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "literal-loop-safe.spec.ts", + 'for (const value of [1]) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "concrete-spread-array-safe.spec.ts", + 'for (const value of [...[], 1]) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "concrete-spread-object-safe.spec.ts", + 'for (const key in { ...{}, x: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "do-loop-safe.spec.ts", + 'do { await bootstrapE2ePage(page); } while (condition); await page.goto("/");', + ], + ]; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} async function unsafe(target) { await (target.evaluate)(() => localStorage.getItem("x")); } test("x", async ({ page, context }) => { ${body} });`, + ); + assert.deepEqual( + runCheck(root) + .map((entry) => entry.split(":")[0]) + .sort(), + [ + "computed-bound.spec.ts", + "computed-call.spec.ts", + "computed-dynamic-getter.spec.ts", + "computed-dynamic-method.spec.ts", + "computed-dynamic-setter.spec.ts", + "computed-symbol-getter.spec.ts", + "computed-symbol-member.spec.ts", + "computed-symbol-method.spec.ts", + "computed-symbol-setter.spec.ts", + "computed-symbol.spec.ts", + "created-member.spec.ts", + "dynamic-for-of.spec.ts", + "dynamic-while.spec.ts", + "helper-member.spec.ts", + "member-callee.spec.ts", + "member-receiver.spec.ts", + "member-storage.spec.ts", + "proto-identifier.spec.ts", + "proto-string.spec.ts", + "spread-array.spec.ts", + "spread-object.spec.ts", + "temporal-loop.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: applies receiver-aware hooks and catch checks in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-hook-catch-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "hook-created-unsafe.spec.ts", + 'test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); test("x", async ({ context }) => { const second = await context.newPage(); await second.goto("/"); });', + ], + [ + "hook-fixture-safe.spec.ts", + 'test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); test("x", async ({ page }) => { await page.title(); });', + ], + [ + "hook-created-safe.spec.ts", + 'test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); test("x", async ({ context }) => { const second = await context.newPage(); await bootstrapE2ePage(second); await second.goto("/"); });', + ], + [ + "catch-unsafe.spec.ts", + 'test("x", async ({ page }) => { try { await page.title(); } catch { await page.goto("/"); } await bootstrapE2ePage(page); });', + ], + [ + "catch-safe.spec.ts", + 'test("x", async ({ page }) => { await bootstrapE2ePage(page); try { await page.title(); } catch { await page.goto("/"); } });', + ], + [ + "catch-bootstrap.spec.ts", + 'test("x", async ({ page }) => { try { await page.title(); } catch { await bootstrapE2ePage(page); } await page.goto("/"); });', + ], + ]; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} ${body}`, + ); + assert.deepEqual( + runCheck(root) + .map((entry) => entry.split(":")[0]) + .sort(), + [ + "catch-bootstrap.spec.ts", + "catch-unsafe.spec.ts", + "hook-created-unsafe.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: applies unawaited and element-access checks in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-unawaited-element-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "unawaited-storage.spec.ts", + 'async function read(target) { await target.evaluate(() => localStorage.getItem("x")); } test("x", async ({ page }) => { read(page); await bootstrapE2ePage(page); });', + ], + [ + "unawaited-goto.spec.ts", + 'async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { go(page); await bootstrapE2ePage(page); });', + ], + [ + "unawaited-safe.spec.ts", + 'async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { setup(page); await bootstrapE2ePage(page); });', + ], + [ + "element-goto.spec.ts", + 'test("x", async ({ page }) => { await page["goto"]("/"); await bootstrapE2ePage(page); });', + ], + [ + "element-created.spec.ts", + 'test("x", async ({ context }) => { const second = await context["newPage"](); await second.goto("/"); });', + ], + [ + "element-safe.spec.ts", + 'test("x", async ({ page }) => { await bootstrapE2ePage(page); await page["goto"]("/"); });', + ], + ]; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} ${body}`, + ); + assert.deepEqual( + runCheck(root) + .map((x) => x.split(":")[0]) + .sort(), + [ + "element-created.spec.ts", + "element-goto.spec.ts", + "unawaited-goto.spec.ts", + "unawaited-storage.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: applies recursion and unmapped guards in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-recursion-unmapped-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "direct.spec.ts", + 'async function setup(target){await bootstrapE2ePage(target);await setup(target);} test("x",async({page})=>{await setup(page);});', + ], + [ + "mutual.spec.ts", + 'async function a(target){await bootstrapE2ePage(target);await b(target);} async function b(target){await a(target);} test("x",async({page})=>{await a(page);});', + ], + [ + "as.spec.ts", + 'async function go(target){await target.goto("/");} test("x",async({page})=>{go(page as any);await bootstrapE2ePage(page);});', + ], + [ + "paren.spec.ts", + 'async function go(target){await target.goto("/");} test("x",async({page})=>{go((page));await bootstrapE2ePage(page);});', + ], + [ + "property.spec.ts", + 'async function go(target){await target.goto("/");} test("x",async(fixtures)=>{go(fixtures.page);const {page}=fixtures;await bootstrapE2ePage(page);});', + ], + [ + "safe.spec.ts", + 'async function inner(target){await bootstrapE2ePage(target);} test("x",async({page})=>{await inner(page);});', + ], + ]; + for (const [n, b] of cases) + fs.writeFileSync(path.join(root, "tests/e2e", n), `${imports} ${b}`); + assert.equal(runCheck(root).length, 5); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: applies local-identifier mapping guards in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-local-map-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "alias.spec.ts", + 'async function inner(target){await target.goto("/");} async function outer(target){const alias=target;await inner(alias);} test("x",async({page})=>{await outer(page);await bootstrapE2ePage(page);});', + ], + [ + "direct.spec.ts", + 'async function inner(target){await target.goto("/");} async function outer(target){await inner(target);} test("x",async({page})=>{await outer(page);await bootstrapE2ePage(page);});', + ], + [ + "setup.spec.ts", + 'async function inner(target){await target.addInitScript(()=>{});} async function outer(target){const alias=target;await inner(alias);} test("x",async({page})=>{await outer(page);await bootstrapE2ePage(page);});', + ], + ]; + for (const [n, b] of cases) + fs.writeFileSync(path.join(root, "tests/e2e", n), `${imports} ${b}`); + assert.equal(runCheck(root).length, 2); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: ignores destructuring property keys with different bound locals`, () => { + accepts( + spec( + "const { test: localTest, bootstrapE2ePage: localBootstrap } = helpers; void localTest; void localBootstrap; await bootstrapE2ePage(page);", + ), + ); + }); + + for (const [name, source] of [ + [ + ".ts helper suffix", + 'import { test, bootstrapE2ePage } from "../helpers/test.ts"; test("x", async () => {});', + ], + [ + "import alias", + 'import { test as e2eTest, bootstrapE2ePage } from "../helpers/test"; e2eTest("x", async () => { await bootstrapE2ePage(); });', + ], + [ + "re-export", + 'import { test, bootstrapE2ePage } from "./reexport"; test("x", async () => { await bootstrapE2ePage(); });', + ], + [ + "dynamic import", + 'const { test, bootstrapE2ePage } = await import("../helpers/test"); test("x", async () => { await bootstrapE2ePage(); });', + ], + [ + "dead-code call", + `${imports}\nasync function never(page) { await bootstrapE2ePage(page); } test("x", async () => {});`, + ], + [ + "comment", + `${imports}\n// await bootstrapE2ePage(page)\ntest("x", async () => {});`, + ], + [ + "string", + `${imports}\nconst hint = "await bootstrapE2ePage(page)"; test("x", async () => {});`, + ], + [ + "mixed safe and unsafe tests", + `${imports}\ntest("safe", async ({ page }) => { await bootstrapE2ePage(page); }); test("unsafe", async ({ page }) => { await page.title(); });`, + ], + ["missing bootstrap receiver", spec("await bootstrapE2ePage();")], + ["non-identifier bootstrap receiver", spec('await bootstrapE2ePage("/");')], + ["if-wrapped call", spec("if (true) { await bootstrapE2ePage(page); }")], + [ + "catch-only call", + spec( + "try { await page.title(); } catch { await bootstrapE2ePage(page); }", + ), + ], + [ + "zero-iteration false loop", + spec("for (; false;) { await bootstrapE2ePage(page); }"), + ], + [ + "zero-iteration empty iterable", + spec("for (const value of []) { await bootstrapE2ePage(page); }"), + ], + [ + "uncalled nested function", + spec("async function setup() { await bootstrapE2ePage(page); }"), + ], + [ + "unawaited helper", + `${imports}\nasync function open(page) { await bootstrapE2ePage(page); }\ntest("x", async ({ page }) => { open(page); });`, + ], + [ + "fake test.extend", + `${imports}\ntest.extend("x", async ({ page }) => { await bootstrapE2ePage(page); });`, + ], + [ + "misplaced describe hook", + `${imports}\ntest.describe("inside", () => { test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); test("covered", async () => {}); }); test("outside", async () => {});`, + ], + [ + "shadowed bootstrap", + `${imports}\ntest("x", async ({ page }) => { const bootstrapE2ePage = async () => {}; await bootstrapE2ePage(page); });`, + ], + [ + "object shorthand binding shadow", + spec( + "const { bootstrapE2ePage } = helpers; await bootstrapE2ePage(page);", + ), + ], + [ + "nested object alias binding shadow", + spec( + "const { bootstrap: { run: bootstrapE2ePage } } = helpers; await bootstrapE2ePage(page);", + ), + ], + [ + "nested array default binding shadow", + spec("const [[test = fallback]] = values; await bootstrapE2ePage(page);"), + ], + [ + "object rest binding shadow", + spec( + "const { ignored, ...bootstrapE2ePage } = helpers; await bootstrapE2ePage(page);", + ), + ], + [ + "destructured function parameter shadow", + spec( + "function configure({ nested: [bootstrapE2ePage] }) {} await bootstrapE2ePage(page);", + ), + ], + [ + "destructured arrow parameter shadow", + spec( + "const configure = ([{ test }]) => {}; await bootstrapE2ePage(page);", + ), + ], + [ + "destructured method parameter shadow", + spec( + "class Harness { configure({ helper: { bootstrapE2ePage = fallback } }) {} } await bootstrapE2ePage(page);", + ), + ], + [ + "parenthesized false condition", + spec("while (((false))) { await bootstrapE2ePage(page); }"), + ], + [ + "parenthesized empty array iterable", + spec("for (const value of (([]))) { await bootstrapE2ePage(page); }"), + ], + [ + "parenthesized empty string iterable", + spec('for (const value of ((""))) { await bootstrapE2ePage(page); }'), + ], + [ + "parenthesized empty object for-in", + spec("for (const key in (({}))) { await bootstrapE2ePage(page); }"), + ], + ]) { + test(`${project}: rejects ${name}`, () => rejects(source)); + } + + test(`${project}: classifies non-browser harnesses structurally`, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-bootstrap-")); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + fs.writeFileSync( + path.join(root, "tests/e2e/agents-everywhere.live.spec.ts"), + 'import { test } from "../helpers/test"; test("relay", async () => {});', + ); + fs.writeFileSync( + path.join(root, "tests/e2e/renamed-cli-harness.spec.ts"), + 'import { test } from "../helpers/test"; test("relay", async () => {});', + ); + fs.writeFileSync( + path.join(root, "tests/e2e/agents-everywhere.live.perf.ts"), + 'import { test } from "../helpers/test"; test("browser", async ({ page }) => { await page.title(); });', + ); + assert.deepEqual(runCheck(root), [ + "agents-everywhere.live.perf.ts: must directly import unaliased test and bootstrapE2ePage from the canonical tests/helpers/test module", + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: discovers nested specs with a canonical relative import`, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-bootstrap-")); + try { + fs.mkdirSync(path.join(root, "tests/e2e/nested"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + fs.writeFileSync( + path.join(root, "tests/e2e/nested/example.spec.ts"), + 'import { test, bootstrapE2ePage } from "../../helpers/test"; test("x", async ({ page }) => { await bootstrapE2ePage(page); });', + ); + assert.deepEqual(runCheck(root), []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +for (const [project, checkerPath] of projects) { + const { checkSource } = await import(checkerPath); + test(`${project}: traverses canonical parameterized registration containers only`, () => { + assert.equal( + checkSource(`${imports} + for (const name of ["one", "two"]) test(name, async ({ page }) => { await bootstrapE2ePage(page); }); + ["three"].forEach((name) => test(name, async ({ page }) => { await bootstrapE2ePage(page); })); + ["four"].map((name) => test(name, async ({ page }) => { await bootstrapE2ePage(page); }));`), + undefined, + ); + assert.notEqual( + checkSource(`${imports} + function registerLater() { test("hidden", async ({ page }) => { await bootstrapE2ePage(page); }); } + test("unsafe", async ({ page }) => { await page.title(); });`), + undefined, + ); + }); +} diff --git a/desktop/tests/e2e/active-turn-resilience.spec.ts b/desktop/tests/e2e/active-turn-resilience.spec.ts index c8506309362..1e9c0614732 100644 --- a/desktop/tests/e2e/active-turn-resilience.spec.ts +++ b/desktop/tests/e2e/active-turn-resilience.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -35,7 +35,7 @@ async function waitForBridge(page: import("@playwright/test").Page) { } async function openAgentsView(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForBridge(page); await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("unified-agents-groups")).toBeVisible({ diff --git a/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts b/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts index 4ed0a211e54..614302a846d 100644 --- a/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts +++ b/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -17,7 +17,7 @@ async function openActivityFromChannel( channelTestId: string, channelTitle: string, ) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId(channelTestId).click(); await expect(page.getByTestId("chat-title")).toHaveText(channelTitle); @@ -118,7 +118,8 @@ test.describe("activity panel scope label", () => { ], }); - await page.goto( + await bootstrapE2ePage( + page, `/#/channels/${AGENTS_CHANNEL_ID}?agentSession=${AGENT_PUBKEY}`, { waitUntil: "domcontentloaded" }, ); diff --git a/desktop/tests/e2e/add-community-screenshots.spec.ts b/desktop/tests/e2e/add-community-screenshots.spec.ts index 389e182e74f..823e3d15f32 100644 --- a/desktop/tests/e2e/add-community-screenshots.spec.ts +++ b/desktop/tests/e2e/add-community-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { test } from "@playwright/test"; +import { test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -41,7 +41,7 @@ test.beforeEach(async ({ page }) => { skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-rail-add").click(); }); diff --git a/desktop/tests/e2e/agent-access-warning.spec.ts b/desktop/tests/e2e/agent-access-warning.spec.ts index adb9c6d58ab..8cabd49d4d3 100644 --- a/desktop/tests/e2e/agent-access-warning.spec.ts +++ b/desktop/tests/e2e/agent-access-warning.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -50,7 +50,7 @@ test("open agent access explains the available access before save", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("channel-members-trigger").click(); const accessBadge = page.getByTestId( @@ -181,7 +181,7 @@ test("full agent editor tightens the exact sidebar agent instance", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("channel-members-trigger").click(); const accessBadge = page.getByTestId( @@ -272,7 +272,7 @@ test("a provider-backed agent's warning names the server, not this computer", as }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openAgentAccessDialog(page, agent.pubkey); await page.getByTestId("agent-respond-to-select").selectOption("anyone"); @@ -299,7 +299,7 @@ test("persona-backed edit warns before saving open access", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page.getByRole("button", { name: "Tyler Agent agent profile" }).click(); await page.getByTestId("user-profile-edit-agent").click(); diff --git a/desktop/tests/e2e/agent-error-state-screenshots.spec.ts b/desktop/tests/e2e/agent-error-state-screenshots.spec.ts index a55823c62ab..27087b39db1 100644 --- a/desktop/tests/e2e/agent-error-state-screenshots.spec.ts +++ b/desktop/tests/e2e/agent-error-state-screenshots.spec.ts @@ -13,7 +13,7 @@ * which ARE reachable in the current build. */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -39,7 +39,7 @@ const GENERIC_ERROR_AGENT = { }; async function gotoAgentsView(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("open-agents-view")).toBeVisible({ timeout: 10_000, }); diff --git a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts index de94bb230f7..3a7868ae406 100644 --- a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts +++ b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts @@ -9,7 +9,7 @@ * the old "Running agents keep their current settings…" text is gone. */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -29,7 +29,7 @@ const CASCADE_AGENT_B_PUBKEY = "bb".repeat(32); * Navigate to the Agents view and wait for its unified list to mount. */ async function openAgentsView(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("unified-agents-groups")).toBeVisible({ timeout: 10_000, @@ -37,7 +37,7 @@ async function openAgentsView(page: import("@playwright/test").Page) { } async function openAiDefaultsSettings(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await expect(page.getByTestId("settings-view")).toBeVisible(); diff --git a/desktop/tests/e2e/agent-numeric-tuning.spec.ts b/desktop/tests/e2e/agent-numeric-tuning.spec.ts index dc70c8b12e8..5676b68678d 100644 --- a/desktop/tests/e2e/agent-numeric-tuning.spec.ts +++ b/desktop/tests/e2e/agent-numeric-tuning.spec.ts @@ -15,13 +15,13 @@ * visible as generic rows (never the "unsupported" empty state). */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; // ── Helpers ──────────────────────────────────────────────────────────────── async function openAiDefaultsSettings(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await expect(page.getByTestId("settings-view")).toBeVisible(); @@ -38,7 +38,7 @@ async function openEditAgentDialog( page: import("@playwright/test").Page, agentName: string, ) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); const agentButton = page.getByRole("button", { @@ -195,7 +195,7 @@ test("goose_per_agent_advanced_max_tokens_shows_inherited_global_placeholder", a ).toBeDisabled({ timeout: 5_000 }); // Step 3: navigate back and open the per-agent edit dialog for the Goose - // agent. We use the app's Back link rather than page.goto("/") to preserve + // agent. We use the app's Back link rather than bootstrapE2ePage(page, "/") to preserve // the in-memory mock state (page.goto causes a full reload that resets it). await page.getByRole("button", { name: "Back to app" }).click(); await page.getByTestId("open-agents-view").click(); diff --git a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts index cb63361d51e..dd39b16b9c2 100644 --- a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts +++ b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts @@ -16,7 +16,7 @@ * Previously, the seeding effect bailed in edit mode, leaving the runtime * empty and the model dropdown silently blank. */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -29,7 +29,7 @@ const SHOTS = "test-results/screenshots-dialogs"; * direct `/settings` request. */ async function openAiDefaultsSettings(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await expect(page.getByTestId("settings-view")).toBeVisible(); @@ -156,7 +156,7 @@ test.describe("agent provider dropdown screenshots", () => { ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("agents-library-personas")).toBeVisible({ timeout: 10_000, @@ -229,7 +229,7 @@ test.describe("agent provider dropdown screenshots", () => { ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page .getByRole("button", { name: "Open actions for Codex Definition" }) diff --git a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts index 75e400d4a59..8ab877aef5b 100644 --- a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts +++ b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -15,7 +15,7 @@ const EDIT_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey; * the "New agent" menu item, then fill a placeholder name. */ async function openCreateDialog(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); await page.locator("#persona-display-name").fill("Test Agent"); @@ -85,7 +85,7 @@ async function openEditDialog( page: import("@playwright/test").Page, agentName: string, ) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); const agentButton = page.getByRole("button", { diff --git a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts index 9ce80593f10..513e927c08b 100644 --- a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts +++ b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts @@ -5,7 +5,7 @@ * when an .agent.json or .agent.png attachment is detected, and the full * Add agent → preview → confirm flow. */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; type CommandLogEntry = { command: string; payload: unknown }; @@ -107,7 +107,7 @@ async function seedAndSendSnapshot( ? { snapshotFetchError: opts.snapshotFetchError } : {}), }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // JSON snapshots can still arrive from saved files or older clients, even // though new in-app shares always encode PNG. Seed one directly so this diff --git a/desktop/tests/e2e/agents-everywhere.live.spec.ts b/desktop/tests/e2e/agents-everywhere.live.spec.ts index e1d82525b4f..5a480fe43b0 100644 --- a/desktop/tests/e2e/agents-everywhere.live.spec.ts +++ b/desktop/tests/e2e/agents-everywhere.live.spec.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { expect, test } from "@playwright/test"; +import { expect, test } from "../helpers/test"; import { TwoRelayHarness, type RelaySpec } from "./helpers/twoRelayHarness"; diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index b81c5889f3a..99a871ac0dc 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { hexToBytes } from "@noble/hashes/utils.js"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; @@ -61,7 +61,7 @@ async function gotoApp(page: import("@playwright/test").Page) { let lastError: unknown = null; for (const attempt of [0, 1]) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForInvokeBridge(page); try { diff --git a/desktop/tests/e2e/animated-avatar.spec.ts b/desktop/tests/e2e/animated-avatar.spec.ts index c738726f86e..906cd7ace56 100644 --- a/desktop/tests/e2e/animated-avatar.spec.ts +++ b/desktop/tests/e2e/animated-avatar.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { installFakeCamera } from "../helpers/fakeCamera"; @@ -24,7 +24,7 @@ async function openAnimatedTab( await installMockBridge(page, { uploadDescriptors: [UPLOADED_PNG_DESCRIPTOR], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); await page.getByRole("tab", { name: "Animated" }).click(); diff --git a/desktop/tests/e2e/appearance-previews.spec.ts b/desktop/tests/e2e/appearance-previews.spec.ts index a03a51bd82b..2635dcf15b9 100644 --- a/desktop/tests/e2e/appearance-previews.spec.ts +++ b/desktop/tests/e2e/appearance-previews.spec.ts @@ -1,4 +1,5 @@ -import { expect, test, type Locator, type Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; +import { bootstrapE2ePage, expect, test } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -36,7 +37,7 @@ async function openAppearance( }, ); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await page.getByTestId("settings-nav-appearance").click(); diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index f1b618b0d64..21ac00d98c9 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; @@ -91,7 +91,7 @@ test.beforeEach(async ({ page }) => { test("selected Inbox and Agents rows keep their highlight without bold text", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const inbox = page .getByTestId("sidebar-primary-menu") @@ -108,7 +108,7 @@ test("selected Inbox and Agents rows keep their highlight without bold text", as test("primary navigation rows share the same inactive emphasis", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const primaryMenu = page.getByTestId("sidebar-primary-menu"); @@ -140,7 +140,7 @@ test("primary navigation rows share the same inactive emphasis", async ({ }); test("hovering a channel keeps its text color", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const channel = page.getByTestId("channel-engineering"); const initialColor = await channel.evaluate( (element) => getComputedStyle(element).color, @@ -156,7 +156,7 @@ test("direct-message rows become prominent only when unread", async ({ await page.addInitScript(() => { window.localStorage.setItem("buzz-theme", "buzz-dark"); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const directMessage = page.getByTestId("channel-alice-tyler"); await directMessage.click(); @@ -182,7 +182,7 @@ test("direct-message rows become prominent only when unread", async ({ test("light mode reserves full opacity for unread text and avatars", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const directMessage = page.getByTestId("channel-alice-tyler"); await directMessage.click(); @@ -226,7 +226,7 @@ test("dark mode keeps selected labels regular and channel-level unread labels bo await page.addInitScript(() => { window.localStorage.setItem("buzz-theme", "buzz-dark"); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.locator("html")).toHaveClass(/dark/); const inbox = page @@ -283,7 +283,7 @@ test("offscreen top-level unread shows the primary sidebar arrow", async ({ page, }) => { await page.setViewportSize({ width: 1280, height: 360 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await waitForMockLiveSubscription(page, "random"); await page.getByTestId("channel-general").click(); @@ -332,7 +332,7 @@ test("offscreen top-level unread shows the primary sidebar arrow", async ({ test("offscreen unread DM shows the primary sidebar arrow", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await waitForMockLiveSubscription(page, "alice-tyler"); await page.getByTestId("channel-general").click(); @@ -363,7 +363,7 @@ test("offscreen unread DM shows the primary sidebar arrow", async ({ test("regular message bolds inactive channel without numeric badge", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "random"); @@ -405,7 +405,7 @@ test("regular message bolds inactive channel without numeric badge", async ({ test("top-level @mention bolds the channel without a row badge", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "random"); @@ -437,7 +437,7 @@ test("top-level @mention bolds the channel without a row badge", async ({ }); test("numeric badge increments for DM message", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "alice-tyler"); @@ -458,7 +458,7 @@ test("numeric badge increments for DM message", async ({ page }) => { test("interested thread reply shows the channel preview dot without incrementing Inbox", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "random"); @@ -499,7 +499,7 @@ test("interested thread reply shows the channel preview dot without incrementing test("broadcast reply bolds the channel without a thread dot", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "random"); @@ -533,7 +533,7 @@ test("broadcast reply bolds the channel without a thread dot", async ({ test("mark-as-read via context menu clears channel unread indicator", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "random"); @@ -573,7 +573,7 @@ test("mark-as-read via context menu clears channel unread indicator", async ({ }); test("mark-as-unread via context menu bolds the channel", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -595,7 +595,7 @@ test("mark-as-unread via context menu bolds the channel", async ({ page }) => { test("marking a message unread bolds its channel after leaving", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await waitForMockLiveSubscription(page, "random"); @@ -645,7 +645,7 @@ test("marking a message unread bolds its channel after leaving", async ({ test("remote read-state rollback is ignored while local mark-unread still increments badge", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/boot-splash.spec.ts b/desktop/tests/e2e/boot-splash.spec.ts index 053ee84c724..6601a2e0d6e 100644 --- a/desktop/tests/e2e/boot-splash.spec.ts +++ b/desktop/tests/e2e/boot-splash.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; // Cold-boot splash hold: on a real boot the community resolves in well under @@ -23,7 +23,7 @@ test("boot splash overlay holds with a flapping bee, then dismisses", async ({ bootSplashHoldMs: 1_500, }; }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const overlay = page.getByTestId("boot-splash-overlay"); await expect(overlay).toBeVisible(); @@ -50,7 +50,7 @@ test("boot splash overlay is skipped when the hold is zero (e2e default)", async page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await expect(page.getByTestId("boot-splash-overlay")).toHaveCount(0); diff --git a/desktop/tests/e2e/bootstrap.spec.ts b/desktop/tests/e2e/bootstrap.spec.ts new file mode 100644 index 00000000000..206d6595a38 --- /dev/null +++ b/desktop/tests/e2e/bootstrap.spec.ts @@ -0,0 +1,20 @@ +import { expect, test, bootstrapE2ePage } from "../helpers/test"; +import { E2E_APP_ORIGIN } from "../helpers/bootstrap"; + +test.beforeEach(async ({ page }) => { + await bootstrapE2ePage(page); +}); + +test("starts each test at the app origin after explicit bootstrap", async ({ + page, +}) => { + expect(new URL(page.url()).origin).toBe(E2E_APP_ORIGIN); +}); + +test("reports a failed initial navigation at its source", async ({ page }) => { + await expect( + bootstrapE2ePage(page, { expectedOrigin: "http://127.0.0.1:1" }), + ).rejects.toThrow( + /E2E app navigation did not commit to http:\/\/127\.0\.0\.1:1 \(current URL: http:\/\/127\.0\.0\.1:4173\/\).*Run pnpm test:e2e:smoke or run pnpm build:e2e and check the preview server/, + ); +}); diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts index 408fa851d96..b1b720e0730 100644 --- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts +++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -51,7 +51,7 @@ async function seedIconChannelSection(page: Page) { } async function openChannel(page: Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); @@ -465,7 +465,7 @@ test("custom section icon and name align with channel columns", async ({ async function openAppearance(page: Page, mode: "system" | "light" | "dark") { // Settings renders at the AppShell level; open it via the profile card // button, then select the Appearance section. - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await page.getByTestId("settings-nav-appearance").click(); @@ -1171,7 +1171,7 @@ test("appearance picker — dark tab (Buzz Dark)", async ({ page }) => { test("settings nav uses Buzz active pill + hover (light)", async ({ page }) => { await seedTheme(page, "buzz"); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); const sidebar = page.getByTestId("settings-sidebar"); @@ -1202,7 +1202,7 @@ test("settings nav uses Buzz active pill + hover (light)", async ({ page }) => { test("settings nav uses Buzz active pill + hover (dark)", async ({ page }) => { await seedTheme(page, "buzz-dark"); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); const sidebar = page.getByTestId("settings-sidebar"); @@ -1431,7 +1431,7 @@ test("settings content uses the same inset surface as the main app", async ({ }) => { await seedTheme(page, "buzz"); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); const searchBox = await page.getByTestId("open-search").boundingBox(); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); diff --git a/desktop/tests/e2e/channel-activity-popover.spec.ts b/desktop/tests/e2e/channel-activity-popover.spec.ts index a62d064d769..edb79a4312a 100644 --- a/desktop/tests/e2e/channel-activity-popover.spec.ts +++ b/desktop/tests/e2e/channel-activity-popover.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -159,7 +159,7 @@ async function seedChannelActivity( includeAgent = true, }: { extraThreadCount?: number; includeAgent?: boolean } = {}, ) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -496,7 +496,7 @@ test.describe("channel activity hover preview", () => { "older-inbox-thread-for-hover", "second-inbox-thread-for-hover", ]; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByRole("button", { name: "Inbox", exact: true }).click(); @@ -639,7 +639,7 @@ test.describe("channel activity hover preview", () => { test("reading a grouped Inbox thread preserves an unrelated manual unread", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -701,7 +701,7 @@ test.describe("channel activity hover preview", () => { test("preserves Inbox ownership while another top-level row remains unread", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByRole("button", { name: "Inbox", exact: true }).click(); @@ -753,7 +753,7 @@ test.describe("channel activity hover preview", () => { test("surfaces future replies after the user reacts to a thread root", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); diff --git a/desktop/tests/e2e/channel-add-screenshots.spec.ts b/desktop/tests/e2e/channel-add-screenshots.spec.ts index 7cb98b90c21..be92070f414 100644 --- a/desktop/tests/e2e/channel-add-screenshots.spec.ts +++ b/desktop/tests/e2e/channel-add-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { test } from "@playwright/test"; +import { test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, openChannelBrowser } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -10,7 +10,7 @@ test.beforeEach(async ({ page }) => { }); test("capture: add-channel default state", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelBrowser(page); await page.getByTestId("channel-browser-dialog").waitFor(); await waitForAnimations(page); @@ -18,7 +18,7 @@ test("capture: add-channel default state", async ({ page }) => { }); test("capture: create row on partial match", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("desig"); await page.getByTestId("channel-browser-create-row").waitFor(); @@ -27,7 +27,7 @@ test("capture: create row on partial match", async ({ page }) => { }); test("capture: create row on no match", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("release-notes"); await page.getByTestId("channel-browser-create-row").waitFor(); @@ -36,7 +36,7 @@ test("capture: create row on no match", async ({ page }) => { }); test("capture: prefilled create form", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("release-notes"); await page.getByTestId("channel-browser-create-row").click(); diff --git a/desktop/tests/e2e/channel-browser.spec.ts b/desktop/tests/e2e/channel-browser.spec.ts index 03f3fce3867..d8e5b70b47e 100644 --- a/desktop/tests/e2e/channel-browser.spec.ts +++ b/desktop/tests/e2e/channel-browser.spec.ts @@ -1,5 +1,6 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; +import { E2E_APP_ORIGIN } from "../helpers/bootstrap"; import { installMockBridge, openChannelBrowser } from "../helpers/bridge"; const MOCK_PUBKEY = "deadbeef".repeat(8); @@ -7,13 +8,18 @@ const CUSTOM_SECTION = { id: "sec-projects", name: "Projects", order: 0 }; async function seedCustomSection(page: Page) { await page.addInitScript( - ({ pubkey, section }) => { + ({ expectedOrigin, pubkey, section }) => { + if (location.origin !== expectedOrigin) return; window.localStorage.setItem( `buzz-channel-sections.v1:${pubkey}`, JSON.stringify({ version: 1, sections: [section], assignments: {} }), ); }, - { pubkey: MOCK_PUBKEY, section: CUSTOM_SECTION }, + { + expectedOrigin: E2E_APP_ORIGIN, + pubkey: MOCK_PUBKEY, + section: CUSTOM_SECTION, + }, ); } @@ -24,10 +30,17 @@ test.beforeEach(async ({ page }, testInfo) => { ? { createChannelErrors: ["Create failed"] } : undefined, ); + if ( + testInfo.title.includes("custom section") || + testInfo.title.includes("canceling section create") || + testInfo.title.includes("failed section create") + ) { + await seedCustomSection(page); + } + await bootstrapE2ePage(page); }); test("keyboard shortcut opens the channel browser dialog", async ({ page }) => { - await page.goto("/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); const isMacBrowser = await page.evaluate(() => @@ -53,8 +66,6 @@ test("keyboard shortcut opens the channel browser dialog", async ({ page }) => { }); test("channel browser shows channels not yet joined", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); await expect( @@ -72,8 +83,6 @@ test("channel browser shows channels not yet joined", async ({ page }) => { test("channel browser sorts alphabetically or by member count", async ({ page, }) => { - await page.goto("/"); - await openChannelBrowser(page); const rows = page.locator('[data-testid^="browse-channel-"]'); @@ -112,8 +121,6 @@ test("channel browser sorts alphabetically or by member count", async ({ }); test("channel browser sorts by recent activity", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); const rows = page.locator('[data-testid^="browse-channel-"]'); @@ -139,8 +146,6 @@ test("channel browser sorts by recent activity", async ({ page }) => { }); test("channel browser search filters by name", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -152,8 +157,6 @@ test("channel browser search filters by name", async ({ page }) => { }); test("channel browser search filters by description", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("pipeline"); @@ -165,8 +168,6 @@ test("channel browser search filters by description", async ({ page }) => { test("channel browser shows no results for unmatched search", async ({ page, }) => { - await page.goto("/"); - await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("zzz-nonexistent"); @@ -174,8 +175,6 @@ test("channel browser shows no results for unmatched search", async ({ }); test("channel browser fuzzy-matches a subsequence", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); // "engr" is not a substring of "engineering", but it is an in-order // subsequence — plain includes() would miss it, fuzzy matching finds it. @@ -186,8 +185,6 @@ test("channel browser fuzzy-matches a subsequence", async ({ page }) => { }); test("channel browser matches a scattered subsequence", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); // "sls" is neither a substring nor a prefix of "sales" — it only matches as // an in-order subsequence (s·a·l·e·s). Proves fuzzy matching end-to-end. @@ -198,8 +195,6 @@ test("channel browser matches a scattered subsequence", async ({ page }) => { }); test("channel browser ranks the best match first", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); // "gen" is a prefix of "general" (strong match) but only a substring of // "agents" and a subsequence of "engineering" (weaker). The prefix match @@ -216,7 +211,6 @@ test("channel browser ranks the best match first", async ({ page }) => { test("sidebar add-channel button creates without treating the click as a callback", async ({ page, }) => { - await page.goto("/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); await page.getByTestId("section-actions-channels-quick-create").click(); @@ -234,9 +228,6 @@ test("sidebar add-channel button creates without treating the click as a callbac test("custom section add button creates directly into that section", async ({ page, }) => { - await seedCustomSection(page); - await page.goto("/"); - const addButton = page.getByTestId( `section-actions-${CUSTOM_SECTION.id}-quick-create`, ); @@ -260,9 +251,6 @@ test("custom section add button creates directly into that section", async ({ test("canceling section create does not affect the next global create", async ({ page, }) => { - await seedCustomSection(page); - await page.goto("/"); - await page .getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`) .click(); @@ -289,9 +277,6 @@ test("canceling section create does not affect the next global create", async ({ test("failed section create retry still assigns to the section", async ({ page, }) => { - await seedCustomSection(page); - await page.goto("/"); - await page .getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`) .click(); @@ -309,8 +294,6 @@ test("failed section create retry still assigns to the section", async ({ }); test("create affordance is visible on open before typing", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); // The create row is present from the get-go so it's clear you can browse OR @@ -323,8 +306,6 @@ test("create affordance is visible on open before typing", async ({ page }) => { test("typing a partial match surfaces a persistent create row", async ({ page, }) => { - await page.goto("/"); - await openChannelBrowser(page); // "desig" matches "design" by substring but is not an exact channel name, // so both the matching channel AND the create row are shown. @@ -337,8 +318,6 @@ test("typing a partial match surfaces a persistent create row", async ({ }); test("exact name match hides the create row", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("general"); @@ -349,8 +328,6 @@ test("exact name match hides the create row", async ({ page }) => { test("no-match search pins a create row above the empty state", async ({ page, }) => { - await page.goto("/"); - await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("zzz-nonexistent"); @@ -361,8 +338,6 @@ test("no-match search pins a create row above the empty state", async ({ }); test("create row leads to the prefilled create form", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("desig"); await page.getByTestId("channel-browser-create-row").click(); @@ -380,8 +355,6 @@ test("creating from the browser adds the channel to the sidebar", async ({ }) => { const channelName = `browse-created-${Date.now()}`; - await page.goto("/"); - await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill(channelName); await page.getByTestId("channel-browser-create-row").click(); @@ -399,8 +372,6 @@ test("creating from the browser adds the channel to the sidebar", async ({ test("Enter with no matches jumps to create", async ({ page }) => { const channelName = `enter-created-${Date.now()}`; - await page.goto("/"); - await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill(channelName); await page.keyboard.press("Enter"); @@ -413,8 +384,6 @@ test("Enter with no matches jumps to create", async ({ page }) => { test("arrow keys reach the pinned create row and Enter activates it", async ({ page, }) => { - await page.goto("/"); - await openChannelBrowser(page); // "desig" keeps a channel match (#design) AND the create row visible, so the // create row is not the only actionable item — it must be reachable by @@ -436,8 +405,6 @@ test("arrow keys reach the pinned create row and Enter activates it", async ({ test("Enter selects a channel when create row is not highlighted", async ({ page, }) => { - await page.goto("/"); - await openChannelBrowser(page); // With the create row present but NOT highlighted, Enter should still select // the first channel match rather than jumping to create. @@ -453,8 +420,6 @@ test("Enter selects a channel when create row is not highlighted", async ({ test("joining a channel from browser adds it to the sidebar", async ({ page, }) => { - await page.goto("/"); - // Verify "design" is not in the sidebar const streamList = page.getByTestId("stream-list"); await expect(streamList).not.toContainText("design"); @@ -479,8 +444,6 @@ test("joining a channel from browser adds it to the sidebar", async ({ test("clicking a joined channel in browser navigates to it", async ({ page, }) => { - await page.goto("/"); - await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -495,8 +458,6 @@ test("clicking a joined channel in browser navigates to it", async ({ test("channel browser does not show DM or private channels", async ({ page, }) => { - await page.goto("/"); - await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -509,8 +470,6 @@ test("channel browser does not show DM or private channels", async ({ }); test("channel browser closes on escape", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -519,8 +478,6 @@ test("channel browser closes on escape", async ({ page }) => { }); test("keyboard navigation works in channel browser", async ({ page }) => { - await page.goto("/"); - await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -536,8 +493,6 @@ test("keyboard navigation works in channel browser", async ({ page }) => { }); test("sidebar only shows channels the user has joined", async ({ page }) => { - await page.goto("/"); - const streamList = page.getByTestId("stream-list"); // Channels the mock user IS a member of diff --git a/desktop/tests/e2e/channel-composer-overflow.spec.ts b/desktop/tests/e2e/channel-composer-overflow.spec.ts index 6ba13532a9d..3e98bc9fe1c 100644 --- a/desktop/tests/e2e/channel-composer-overflow.spec.ts +++ b/desktop/tests/e2e/channel-composer-overflow.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -129,7 +129,7 @@ test.describe("composer overlays mask scrolled content", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId(`channel-${CHANNEL}`).click(); await expect(page.getByTestId("message-timeline")).toBeVisible(); await waitForMockLiveSubscription(page, CHANNEL); @@ -168,7 +168,7 @@ test.describe("composer overlays mask scrolled content", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId(`channel-${CHANNEL}`).click(); await waitForMockLiveSubscription(page, CHANNEL); await waitForMockLiveSubscription(page, CHANNEL, TYPING_KIND); @@ -203,7 +203,7 @@ test.describe("composer overlays mask scrolled content", () => { test("reduced motion removes dock geometry transitions", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId(`channel-${CHANNEL}`).click(); const dock = page @@ -216,7 +216,7 @@ test.describe("composer overlays mask scrolled content", () => { test("bottom-pinned messages clear a growing composer", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId(`channel-${CHANNEL}`).click(); await expect(page.getByTestId("message-timeline")).toBeVisible(); await waitForMockLiveSubscription(page, CHANNEL); @@ -281,7 +281,7 @@ test.describe("composer overlays mask scrolled content", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId(`channel-${CHANNEL}`).click(); await expect(page.getByTestId("message-timeline")).toBeVisible(); await waitForMockLiveSubscription(page, CHANNEL); diff --git a/desktop/tests/e2e/channel-controls.spec.ts b/desktop/tests/e2e/channel-controls.spec.ts index 093b572353f..767e138c14f 100644 --- a/desktop/tests/e2e/channel-controls.spec.ts +++ b/desktop/tests/e2e/channel-controls.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -6,7 +6,7 @@ import { installMockBridge } from "../helpers/bridge"; // visibility + ephemeral controls are editable. Visibility is a deferred // draft: selecting a value only updates local state; it persists on Save. async function openManagementSheet(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("channel-management-trigger").click(); diff --git a/desktop/tests/e2e/channel-dense-second-reach.spec.ts b/desktop/tests/e2e/channel-dense-second-reach.spec.ts index 34999be5076..37b487edc6e 100644 --- a/desktop/tests/e2e/channel-dense-second-reach.spec.ts +++ b/desktop/tests/e2e/channel-dense-second-reach.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -30,7 +30,7 @@ test("dense single second beyond one window page is fully reachable via composit }, testInfo) => { testInfo.setTimeout(90_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); diff --git a/desktop/tests/e2e/channel-mute.spec.ts b/desktop/tests/e2e/channel-mute.spec.ts index 4d181e0033e..25f0116107c 100644 --- a/desktop/tests/e2e/channel-mute.spec.ts +++ b/desktop/tests/e2e/channel-mute.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; @@ -51,7 +51,7 @@ async function waitForMockLiveSubscription( test.describe("channel muting", () => { test("01 — context menu shows Mute channel", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -75,7 +75,7 @@ test.describe("channel muting", () => { await seedMuteState(page, ENGINEERING_CHANNEL_ID); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -95,7 +95,7 @@ test.describe("channel muting", () => { await seedMuteState(page, ENGINEERING_CHANNEL_ID); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.locator("html")).toHaveClass(/dark/); await page.getByTestId("channel-random").click(); @@ -117,7 +117,7 @@ test.describe("channel muting", () => { await seedMuteState(page, ENGINEERING_CHANNEL_ID); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-engineering").click(); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); await waitForMockLiveSubscription(page, "engineering"); @@ -164,7 +164,7 @@ test.describe("channel muting", () => { await seedMuteState(page, ENGINEERING_CHANNEL_ID); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -185,7 +185,7 @@ test.describe("channel muting", () => { await seedMuteState(page, ENGINEERING_CHANNEL_ID); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-engineering").click(); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); diff --git a/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts b/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts index 901d1477a76..2103bd39c8c 100644 --- a/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts +++ b/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -45,7 +45,7 @@ test.describe("channel shared header backdrop", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId(`channel-${CHANNEL_NAME}`).click(); await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL_NAME); await waitForMockLiveSubscription(page, CHANNEL_NAME); diff --git a/desktop/tests/e2e/channel-sort.spec.ts b/desktop/tests/e2e/channel-sort.spec.ts index c267a03b321..f51d04b5dda 100644 --- a/desktop/tests/e2e/channel-sort.spec.ts +++ b/desktop/tests/e2e/channel-sort.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import type { Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; @@ -23,7 +23,7 @@ function seedSortState(page: Page, groups: Record) { } async function openApp(page: Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); } diff --git a/desktop/tests/e2e/channel-star.spec.ts b/desktop/tests/e2e/channel-star.spec.ts index 280fa6383bf..c009c9695ed 100644 --- a/desktop/tests/e2e/channel-star.spec.ts +++ b/desktop/tests/e2e/channel-star.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -28,7 +28,7 @@ function seedStarState( test.describe("channel starring", () => { test("01 — context menu shows Star channel", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -49,7 +49,7 @@ test.describe("channel starring", () => { await seedStarState(page, ENGINEERING_CHANNEL_ID); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -64,7 +64,7 @@ test.describe("channel starring", () => { await seedStarState(page, ENGINEERING_CHANNEL_ID); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -90,7 +90,7 @@ test.describe("channel starring", () => { await seedStarState(page, ENGINEERING_CHANNEL_ID); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/channel-window-mock-paging.spec.ts b/desktop/tests/e2e/channel-window-mock-paging.spec.ts index 5c7b5da7632..572ea71869a 100644 --- a/desktop/tests/e2e/channel-window-mock-paging.spec.ts +++ b/desktop/tests/e2e/channel-window-mock-paging.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse"; import type { RelayEvent } from "@/shared/api/types"; @@ -42,7 +42,7 @@ test("mock-mode channel window pages parse with no dup or loss across page-1/pag page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -103,7 +103,7 @@ test("mock-mode channel window includes summaries and the two-hop aux closure", page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 8a32a436c32..5a8140a449c 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { KIND_HUDDLE_ENDED, @@ -500,7 +500,7 @@ test.beforeEach(async ({ page }, testInfo) => { }); test("sidebar shows all channel types", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); await expect(page.getByTestId("sidebar-agents-count")).toHaveCount(0); @@ -546,7 +546,7 @@ test("shows cached profile labels while relay profiles revalidate", { { alicePubkey: TEST_IDENTITIES.alice.pubkey }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const aliceMessage = page @@ -561,7 +561,7 @@ test("shows cached profile labels while relay profiles revalidate", { test("shows presence in sidebar, DM header, and member list", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("sidebar-profile-card")).toBeVisible(); await expect(page.getByTestId("self-presence-badge")).toHaveAttribute( @@ -586,7 +586,7 @@ test("shows presence in sidebar, DM header, and member list", async ({ }); test("start a new direct message from the sidebar", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await expect(page.getByTestId("new-message-page")).toBeVisible(); @@ -641,7 +641,7 @@ test("start a new direct message from the sidebar", async ({ page }) => { test("keeps typing focus while arrow keys traverse and select DM recipients", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); const search = page.getByTestId("new-dm-search"); @@ -698,7 +698,7 @@ test("keeps typing focus while arrow keys traverse and select DM recipients", as test("sends the first message from the new direct message composer", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await page.getByTestId("new-dm-search").fill("charlie"); @@ -719,7 +719,7 @@ test("creates the DM before preparing a persona mention", async ({ page }) => { activePersonaIds: ["builtin:fizz"], createManagedAgentDelayMs: 1_000, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await page.getByTestId("new-dm-search").fill("charlie"); @@ -818,7 +818,7 @@ test("routes an agent mention from an existing DM to the expanded conversation", activePersonaIds: ["builtin:fizz"], createManagedAgentDelayMs: 100, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const sourceDm = page.getByTestId("channel-alice-tyler"); const sourceDmId = await sourceDm.getAttribute("data-channel-id"); @@ -890,7 +890,7 @@ test("routes a managed relay-agent mention from an existing DM to the expanded c }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const sourceDm = page.getByTestId("channel-alice-tyler"); const sourceDmId = await sourceDm.getAttribute("data-channel-id"); @@ -942,7 +942,7 @@ test("does not reroute an expanded DM after the user navigates away", async ({ activePersonaIds: ["builtin:fizz"], sendMessageDelayMs: 1_000, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); @@ -974,7 +974,7 @@ test("does not reroute an expanded DM after the channel pane unmounts", async ({ activePersonaIds: ["builtin:fizz"], openDmDelayMs: 1_000, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); @@ -1010,7 +1010,7 @@ test("drops an expanded DM after the first message fails", async ({ page }) => { createManagedAgentDelayMs: 100, sendMessageErrors: [sendError], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await page.getByTestId("new-dm-search").fill("charlie"); @@ -1092,7 +1092,7 @@ test("drops an expanded DM after agent startup fails", async ({ page }) => { activePersonaIds: ["builtin:fizz"], startManagedAgentErrors: [startError], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await page.getByTestId("new-dm-search").fill("charlie"); @@ -1153,7 +1153,7 @@ test("drops an expanded DM after agent startup fails", async ({ page }) => { }); test("closes direct message results while opening", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate(() => { const testWindow = window as Window & { __BUZZ_E2E__?: { mock?: { openDmDelayMs?: number } }; @@ -1186,7 +1186,7 @@ test("does not reopen a direct message after leaving the composer", async ({ page, }) => { await installMockBridge(page, { openDmDelayMs: 1_000 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await page.getByTestId("new-dm-search").fill("charlie"); @@ -1214,7 +1214,7 @@ test("does not reopen a direct message after leaving the composer", async ({ test("opens a sent direct message without waiting for a channel-list refresh", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await page.getByTestId("new-dm-search").fill("charlie"); @@ -1257,7 +1257,7 @@ test("opens a sent direct message without waiting for a channel-list refresh", a test("shows capped participant stack in group direct message header", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await expect(page.getByTestId("new-message-page")).toBeVisible(); @@ -1343,7 +1343,7 @@ test("shows capped participant stack in group direct message header", async ({ test("create stream with name and description", async ({ page }) => { const channelName = `my-new-stream-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page @@ -1384,7 +1384,7 @@ test("channel name values stay primary after their inputs blur", async ({ } } - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); const createDialog = page.getByTestId("create-channel-dialog"); await expectPrimaryValueAfterBlur( @@ -1442,7 +1442,7 @@ test("create channel template selector matches the lifecycle controls", async ({ ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); const templateControl = page.getByTestId("create-channel-template"); @@ -1475,7 +1475,7 @@ test("create channel exposes templates when the library is empty", async ({ page, }) => { await installMockBridge(page, { channelTemplates: [] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); const typeContainer = page.getByTestId( @@ -1522,7 +1522,7 @@ test("create ephemeral stream shows sidebar and header affordances", async ({ }) => { const channelName = `ephemeral-stream-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page @@ -1589,7 +1589,7 @@ test("ephemeral countdown refreshes when switching channels after a clock jump", const shiftedTime = new Date("2026-04-15T02:00:00.000Z"); await page.clock.setFixedTime(initialTime); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); for (const channelName of [firstChannelName, secondChannelName]) { await openCreateChannelDialog(page); @@ -1620,7 +1620,7 @@ test("archived channels stay out of all sidebar sections", async ({ page }) => { const archivedStreamName = `archived-stream-${Date.now()}`; const archivedForumName = `archived-forum-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction(() => { return Boolean( ( @@ -1693,7 +1693,7 @@ test("archived channels stay out of all sidebar sections", async ({ page }) => { test("create stream with special characters", async ({ page }) => { const channelName = `dev ops-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page @@ -1706,7 +1706,7 @@ test("create stream with special characters", async ({ page }) => { }); test("switch between streams", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1719,7 +1719,7 @@ test("switch between streams", async ({ page }) => { }); test("switch between channel types", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Stream await page.getByTestId("channel-general").click(); @@ -1735,7 +1735,7 @@ test("switch between channel types", async ({ page }) => { }); test("empty channel shows intro actions", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -1782,7 +1782,7 @@ test("short channel with messages shows intro actions on open", async ({ const channelName = `short-intro-${Date.now()}`; const message = "Only message in a short channel"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page.getByTestId("create-channel-submit").click(); @@ -1818,7 +1818,7 @@ test("scrollable channel with recent messages hides intro actions until top", as (_, index) => `Scrollable channel message ${index + 1}`, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page.getByTestId("create-channel-submit").click(); @@ -1859,7 +1859,7 @@ test("scrollable channel with recent messages hides intro actions until top", as }); test("channel with messages shows content", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1883,7 +1883,7 @@ test("channel with messages shows content", async ({ page }) => { test("channel date divider keeps the date sticky while the separator rule scrolls", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-engineering").click(); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); @@ -2040,7 +2040,7 @@ test("channel date divider keeps the date sticky while the separator rule scroll test("shows and clears activity indicators for active channel agents", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -2126,7 +2126,7 @@ test("members sidebar exposes view-activity for a viewer-owned relay agent", asy }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "agents"); @@ -2145,7 +2145,7 @@ test("members sidebar exposes view-activity for a viewer-owned relay agent", asy test("profile renders live activity for a viewer-owned relay agent", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "agents"); await page @@ -2221,7 +2221,7 @@ test("profile renders live activity for a viewer-owned relay agent", async ({ test("profile activity carousel switches channels via progress dots", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "agents"); await page @@ -2295,7 +2295,7 @@ test("profile activity carousel switches channels via progress dots", async ({ test("typing indicator shows avatars and maintains stable name order", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -2361,7 +2361,7 @@ test("typing indicator shows avatars and maintains stable name order", async ({ test("sidebar shows unread indicator for newly active channels", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); await waitForMockLiveSubscription(page, "random"); @@ -2395,7 +2395,7 @@ test("sidebar shows unread indicator for newly active channels", async ({ }); test("sidebar shows unread indicator for new forum posts", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0); await waitForMockLiveSubscription(page, "watercooler"); @@ -2425,7 +2425,7 @@ test("sidebar shows unread indicator for new forum posts", async ({ page }) => { }); test("sidebar clears unread indicator after opening a DM", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0); await waitForMockLiveSubscription(page, "alice-tyler"); @@ -2460,7 +2460,7 @@ test("sidebar clears unread indicator after opening a DM", async ({ page }) => { }); test("sidebar persists after channel switch", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); @@ -2482,7 +2482,7 @@ test("manage channel updates details", async ({ page }) => { const newName = `release-hub-${stamp}`; const newDescription = `Release coordination ${stamp}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelManagement(page, "general"); await openChannelEditDialog(page); const editDialog = page.getByRole("dialog", { @@ -2543,7 +2543,7 @@ test("manage channel shows member avatars and owner-only row controls", async ({ page, }) => { await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelManagement(page, "general"); await expect( @@ -2712,7 +2712,7 @@ test("manage channel shows member avatars and owner-only row controls", async ({ test("direct message settings omit the channel name and empty actions card", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelManagement(page, "alice-tyler"); await expect(page.getByTestId("channel-management-hero")).toBeVisible(); @@ -2732,7 +2732,7 @@ test("direct message settings omit the channel name and empty actions card", asy test("channel settings only prompt editors to add an empty description", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_MUTATE_CHANNEL__ === "function" && @@ -2781,7 +2781,7 @@ test("channel settings only prompt editors to add an empty description", async ( test("manage channel updates visibility and ephemeral lifecycle independently", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelManagement(page, "general"); await openChannelEditDialog(page); @@ -2909,7 +2909,7 @@ test("manage channel updates visibility and ephemeral lifecycle independently", test("manage channel places canvas between channel info and actions", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelManagement(page, "general"); const sheet = page.getByTestId("channel-management-sheet"); @@ -2964,7 +2964,7 @@ test("channel settings hides workflows and skips its query when the experiment i window.localStorage.setItem(key, JSON.stringify(overrides)); }, FEATURE_OVERRIDES_STORAGE_KEY); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelManagement(page, "general"); await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); @@ -2987,7 +2987,7 @@ test("channel settings hides workflows and skips its query when the experiment i test("channel settings opens and creates channel workflows over the channel", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await invokeMockCommand(page, "create_workflow", { channelId: GENERAL_CHANNEL_ID, yamlDefinition: @@ -3087,15 +3087,11 @@ test("channel settings opens and creates channel workflows over the channel", as await expect(sheet.getByTestId("channel-workflows-new")).toBeVisible(); }); -async function seedHomeInboxMention( +async function seedHomeInboxMentionAfterNavigation( page: import("@playwright/test").Page, itemId: string, tags?: string[][], - { navigate = true }: { navigate?: boolean } = {}, ) { - if (navigate) { - await page.goto("/"); - } await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction( () => @@ -3146,8 +3142,17 @@ async function seedHomeInboxMention( await page.getByTestId(`home-inbox-item-${itemId}`).click(); } +async function bootstrapAndSeedHomeInboxMention( + page: import("@playwright/test").Page, + itemId: string, + tags?: string[][], +) { + await bootstrapE2ePage(page, "/"); + await seedHomeInboxMentionAfterNavigation(page, itemId, tags); +} + test("Inbox All excludes generic channel traffic", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction(() => { const win = window as MockFeedWindow; return typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"; @@ -3209,7 +3214,7 @@ test("Inbox type labels keep the same height with and without a channel chip", a const mentionId = "inbox-type-label-mention"; const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction(() => { const win = window as MockFeedWindow; @@ -3334,7 +3339,7 @@ test("Inbox All never lists drafts and unread-only hides reminders", async ({ draftStoreKey: `buzz-drafts.v2:ws://localhost:3000:${MOCK_IDENTITY_PUBKEY}`, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction(() => { const win = window as MockFeedWindow; return typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"; @@ -3432,7 +3437,7 @@ test("Inbox merges a due reminder into its represented conversation", async ({ }) => { const messageId = "inbox-reminder-merge-message"; const reminderId = "inbox-reminder-merge"; - await seedHomeInboxMention(page, messageId); + await bootstrapAndSeedHomeInboxMention(page, messageId); await page.evaluate( async ({ @@ -3492,7 +3497,7 @@ test("Inbox merges a due reminder into its represented conversation", async ({ test("Inbox All keeps its filter when opening a due reminder", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox")).toBeVisible(); const reminderId = "inbox-stable-reminder"; @@ -3542,7 +3547,7 @@ test("Inbox All keeps its filter when opening a due reminder", async ({ }); test("Inbox reminder rows and detail identify DM context", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox")).toBeVisible(); const reminderId = "inbox-dm-reminder"; @@ -3604,7 +3609,10 @@ test("Inbox reminder rows and detail identify DM context", async ({ page }) => { test("Inbox detail title and source action navigate to the conversation", async ({ page, }) => { - await seedHomeInboxMention(page, "mock-feed-home-channel-navigate"); + await bootstrapAndSeedHomeInboxMention( + page, + "mock-feed-home-channel-navigate", + ); const detail = page.getByTestId("home-inbox-detail"); await expect(detail.getByRole("heading")).toHaveText("Message in #general"); @@ -3626,11 +3634,15 @@ test("home inbox thread reply mention carries threadRootId to the channel", asyn page, }) => { const rootEventId = "mock-feed-home-thread-root"; - await seedHomeInboxMention(page, "mock-feed-home-thread-navigate", [ - ["e", rootEventId, "", "root"], - ["e", "mock-feed-home-thread-parent", "", "reply"], - ["p", TEST_IDENTITIES.tyler.pubkey], - ]); + await bootstrapAndSeedHomeInboxMention( + page, + "mock-feed-home-thread-navigate", + [ + ["e", rootEventId, "", "root"], + ["e", "mock-feed-home-thread-parent", "", "reply"], + ["p", TEST_IDENTITIES.tyler.pubkey], + ], + ); const detail = page.getByTestId("home-inbox-detail"); await expect(detail.getByRole("heading")).toHaveText("Thread in #general"); @@ -3651,7 +3663,7 @@ test("Inbox filter changes preserve valid detail and directly select a replaceme }) => { const threadItemId = "inbox-filter-thread"; const actionItemId = "inbox-filter-action"; - await seedHomeInboxMention(page, threadItemId, [ + await bootstrapAndSeedHomeInboxMention(page, threadItemId, [ ["e", "inbox-filter-root", "", "root"], ["e", "inbox-filter-parent", "", "reply"], ["p", TEST_IDENTITIES.tyler.pubkey], @@ -3707,7 +3719,7 @@ test("Inbox filter changes preserve valid detail and directly select a replaceme test("Inbox keeps the unread boundary for replies from multiple agents", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction(() => { const win = window as MockFeedWindow; @@ -3800,7 +3812,7 @@ test("home inbox groups consecutive DMs and opens the full conversation", async const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; const dmIds = ["inbox-dm-first", "inbox-dm-second", "inbox-dm-third"]; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction(() => { const win = window as MockFeedWindow; @@ -3879,7 +3891,7 @@ test("home inbox groups consecutive DMs and opens the full conversation", async test("home inbox manage affordance opens management without leaving home", async ({ page, }) => { - await seedHomeInboxMention(page, "mock-feed-home-channel-panel"); + await bootstrapAndSeedHomeInboxMention(page, "mock-feed-home-channel-panel"); await page .getByTestId("home-inbox-detail") @@ -3927,7 +3939,7 @@ test("home inbox manage affordance opens management without leaving home", async test("home channel settings keeps agent lifecycle actions scoped to the active community", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const agentPubkey = await addGenericAgent( page, "general", @@ -3944,11 +3956,9 @@ test("home channel settings keeps agent lifecycle actions scoped to the active c ); await page.getByRole("button", { exact: true, name: "Inbox" }).click(); - await seedHomeInboxMention( + await seedHomeInboxMentionAfterNavigation( page, "mock-feed-home-agent-lifecycle", - undefined, - { navigate: false }, ); await page .getByTestId("home-inbox-detail") @@ -3977,7 +3987,7 @@ test("home channel settings keeps agent lifecycle actions scoped to the active c }); test("members sidebar virtualizes large channel rosters", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const channelId = await page .getByTestId("channel-random") .getAttribute("data-channel-id"); @@ -4021,7 +4031,7 @@ test("members sidebar can invite relay-authorized agents", async ({ page }) => { }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "general"); await page.getByTestId("channel-management-search-users").fill("quinn"); @@ -4044,7 +4054,7 @@ test("members sidebar hides relay agents that are not authorized", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "general"); await page.getByTestId("channel-management-search-users").fill("quinn"); @@ -4066,7 +4076,7 @@ test("members sidebar can invite and remove managed agents", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "general"); const initialMemberCount = await readMembersTriggerCount(page); await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveCount( @@ -4124,7 +4134,7 @@ test("members sidebar pages add-member search beyond the first 50 people", async })); await installMockBridge(page, { searchProfiles }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "general"); await page.getByTestId("channel-management-search-users").fill("Alex"); @@ -4161,7 +4171,7 @@ test("shows loading skeletons without stale recipient results", async ({ relayAgents: [], userSearchDelayMs: 1_000, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); const search = page.getByTestId("new-dm-search"); @@ -4193,7 +4203,7 @@ test("new DM picker pages people search beyond the first 50 results", async ({ })); await installMockBridge(page, { searchProfiles }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await expect(page.getByTestId("new-message-page")).toBeVisible(); await page.getByTestId("new-dm-search").fill("Alex"); @@ -4230,7 +4240,7 @@ test("member people search starts at two characters", async ({ page }) => { searchProfiles: [{ pubkey: jmPubkey, displayName: "jm" }], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "general"); await page.getByTestId("channel-management-search-users").fill("j"); await expect( @@ -4262,7 +4272,7 @@ test("member people search starts at two characters", async ({ page }) => { }); test("members modal does not show direct pubkey entry", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "general"); await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveCount( @@ -4277,7 +4287,7 @@ test("members modal does not show direct pubkey entry", async ({ page }) => { }); test("channel header omits the add agent action", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -4292,7 +4302,7 @@ test("channel header omits the add agent action", async ({ page }) => { test("huddle rollback end event clears the active header action", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await waitForMockLiveSubscription(page, "random", KIND_HUDDLE_STARTED); @@ -4342,7 +4352,7 @@ test("huddle rollback end event clears the active header action", async ({ }); test("channel header actions show tooltips", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -4396,7 +4406,7 @@ test("members sidebar retains distinct same-persona managed agents", async ({ ], userSearchDelayMs: 1_000, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "general"); await page.getByTestId("channel-management-search-users").fill("pi"); @@ -4422,7 +4432,7 @@ test("private-channel members can add people and managed agents without admin", }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // secret-projects is a private (non-DM) channel where the current user is a // plain member. Active members may add ordinary members and bots; only // elevated-role grants and role changes require owner/admin authority. @@ -4454,7 +4464,7 @@ test("open-channel members can add people and managed agents without admin", asy }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // random is open and the current user is a plain member there, so the // owner/admin requirement must not leak outside private channels. await openMembersSidebar(page, "random"); @@ -4487,7 +4497,7 @@ test("removing a channel-scoped agent preserves the managed agent record", async }) => { const agentName = `cleanup-agent-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const agentPubkey = await addGenericAgent(page, "general", agentName); await page.getByTestId("channel-general").click(); @@ -4510,7 +4520,7 @@ test("members sidebar can stop and start a managed bot in this community", async }) => { const agentName = `sidebar-agent-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const agentPubkey = await addGenericAgent(page, "general", agentName); const baselineCommands = await readCommandLog(page); const baselineLegacyStartCount = commandCount( @@ -4586,7 +4596,7 @@ test("stopping a managed bot in one community leaves its other communities runni { pubkey: agentPubkey, relayUrl: otherRelayUrl }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openMembersSidebar(page, "general"); const agentStatus = page.getByTestId( @@ -4639,7 +4649,7 @@ test("members sidebar omits bulk controls for managed bots", async ({ const firstAgentName = `sidebar-remove-a-${Date.now()}`; const secondAgentName = `sidebar-remove-b-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const firstAgentPubkey = await addGenericAgent( page, "general", @@ -4686,7 +4696,7 @@ test("removing a multi-channel managed bot preserves its record after removal fr const agentName = `multi-channel-agent-${Date.now()}`; const secondChannelName = `multi-home-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const agentPubkey = await addGenericAgent(page, "general", agentName); await openCreateChannelDialog(page); @@ -4761,7 +4771,7 @@ test("bulk remove stays hidden when row-level remove is not allowed", async ({ const alicePubkey = "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Join the "design" channel (unjoined by default) via the channel browser. // The user becomes a regular member — not admin/owner. @@ -4791,7 +4801,7 @@ test("bulk remove stays hidden when row-level remove is not allowed", async ({ }); test("open channel management supports join and leave", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Navigate to "design" (an unjoined channel) via the channel browser await openChannelBrowser(page); @@ -4833,7 +4843,7 @@ test("open channel management supports join and leave", async ({ page }) => { }); test("manage channel can archive and unarchive a stream", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelManagement(page, "general"); await page.getByTestId("channel-management-archive").click(); @@ -4872,7 +4882,7 @@ test("manage channel can archive and unarchive a stream", async ({ page }) => { test("manage channel can delete an owned stream", async ({ page }) => { const channelName = `delete-me-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page.getByTestId("create-channel-submit").click(); @@ -4893,7 +4903,7 @@ test("manage channel can delete an owned stream", async ({ page }) => { test("canceling channel deletion keeps the owned stream", async ({ page }) => { const channelName = `keep-me-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page.getByTestId("create-channel-submit").click(); diff --git a/desktop/tests/e2e/cold-switch-longtask.perf.ts b/desktop/tests/e2e/cold-switch-longtask.perf.ts index c563b129461..fc15500d721 100644 --- a/desktop/tests/e2e/cold-switch-longtask.perf.ts +++ b/desktop/tests/e2e/cold-switch-longtask.perf.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -57,7 +57,7 @@ test("MEASURE: cold-switch longtask cost into deep-history at the 300 ceiling", }) => { test.setTimeout(120_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 57b35db386e..50942d43333 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; @@ -72,7 +72,7 @@ test.describe("community rail", () => { JSON.stringify({ workspaceRail: false }), ); }, FEATURE_OVERRIDES_STORAGE_KEY); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const rail = page.getByTestId("community-rail"); await expect(rail).toBeVisible(); @@ -165,7 +165,7 @@ test.describe("community rail", () => { }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const communityButton = page.getByTestId( `community-rail-button-${COMMUNITY_A.id}`, @@ -205,7 +205,7 @@ test.describe("community rail", () => { { skipCommunitySeed: true }, ); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page .getByTestId(`community-rail-button-${COMMUNITY_A.id}`) @@ -252,7 +252,7 @@ test.describe("community rail", () => { { skipCommunitySeed: true }, ); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page .getByTestId(`community-rail-button-${COMMUNITY_A.id}`) @@ -281,7 +281,7 @@ test.describe("community rail", () => { { skipCommunitySeed: true }, ); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("sidebar-profile-avatar-button").click(); const communityTrigger = page.getByTestId("community-switcher"); @@ -420,7 +420,7 @@ test.describe("community rail", () => { { skipCommunitySeed: true }, ); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("sidebar-profile-avatar-button").click(); await page.getByTestId("community-switcher").click(); @@ -461,7 +461,7 @@ test.describe("community rail", () => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); @@ -503,7 +503,7 @@ test.describe("community rail", () => { { skipCommunitySeed: true }, ); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); @@ -563,7 +563,7 @@ test.describe("community rail", () => { { skipCommunitySeed: true }, ); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); @@ -622,7 +622,7 @@ test.describe("community rail", () => { { skipCommunitySeed: true }, ); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); @@ -669,7 +669,7 @@ test.describe("community rail", () => { }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page).toHaveURL(/#\/channels\//); @@ -701,7 +701,7 @@ test.describe("community rail", () => { }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); const rememberedChannelId = await page.evaluate( ({ communityId, sourceSnapshotKey, targetSnapshotKey }) => { @@ -771,7 +771,7 @@ test.describe("community rail", () => { ); }, COMMUNITY_B.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const sourceSnapshotKey = snapshotKey(COMMUNITY_A.relayUrl); const targetSnapshotKey = snapshotKey(COMMUNITY_B.relayUrl); await expect @@ -824,7 +824,7 @@ test.describe("community rail", () => { }), ); }, COMMUNITY_B.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); const sourceSnapshotKey = snapshotKey(COMMUNITY_A.relayUrl); await expect @@ -1059,7 +1059,7 @@ test.describe("community rail", () => { ); }, COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page).not.toHaveURL(/#\/channels\//); }); @@ -1069,7 +1069,7 @@ test.describe("community rail", () => { }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); await page.getByTestId("channel-random").click(); @@ -1104,7 +1104,7 @@ test.describe("community rail", () => { { skipCommunitySeed: true }, ); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Cold boot still uses the full splash. await expect(page.getByTestId("app-loading-gate")).toBeVisible(); @@ -1141,7 +1141,7 @@ test.describe("community rail", () => { skipCommunitySeed: true, }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect .poll(() => @@ -1179,7 +1179,7 @@ test.describe("community rail", () => { autoConnectDefaultRelay: true, skipCommunitySeed: true, }); - await relaunchPage.goto("/"); + await bootstrapE2ePage(relaunchPage, "/"); await expect( relaunchPage.getByText("Join or create a community"), ).toBeVisible(); @@ -1209,7 +1209,7 @@ test.describe("community rail", () => { { skipCommunitySeed: true }, ); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("sidebar-profile-avatar-button").click(); await page.getByTestId("community-switcher").click(); @@ -1237,7 +1237,7 @@ test.describe("community rail", () => { await page.setViewportSize({ width: 740, height: 516 }); await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page .getByRole("button", { name: "Toggle Sidebar", exact: true }) @@ -1263,7 +1263,7 @@ test.describe("community rail", () => { }, THEME_STORAGE_KEY); await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // The channel sidebar still renders; the rail is omitted (a rail of one // adds nothing). @@ -1295,7 +1295,7 @@ test.describe("community rail", () => { }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const rail = page.getByTestId("community-rail"); await expect(rail).toBeVisible(); @@ -1333,7 +1333,7 @@ test.describe("community rail", () => { }); await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // The first community button must start below the traffic-light band // (native controls sit around y<=31 with trafficLightPosition y:24). @@ -1409,7 +1409,7 @@ test.describe("community rail", () => { }, { list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`); const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); @@ -1481,7 +1481,7 @@ test.describe("community rail", () => { }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`); const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); diff --git a/desktop/tests/e2e/composer-image-draw.spec.ts b/desktop/tests/e2e/composer-image-draw.spec.ts index 9b64deed83a..1ff27a8014c 100644 --- a/desktop/tests/e2e/composer-image-draw.spec.ts +++ b/desktop/tests/e2e/composer-image-draw.spec.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Page, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -70,7 +70,7 @@ test.beforeEach(async ({ page }) => { }); test("image annotation overlay and editor controls", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByRole("button", { name: "Attach file" }).click(); @@ -97,7 +97,7 @@ test("image annotation overlay and editor controls", async ({ page }) => { test("draw on an uploaded image, save replaces it, revert restores in place", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -178,7 +178,7 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as }); test("spoiler marking survives drawing on the attachment", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/composer-link-shortcut.spec.ts b/desktop/tests/e2e/composer-link-shortcut.spec.ts index 43a6646f4c1..ab432beae32 100644 --- a/desktop/tests/e2e/composer-link-shortcut.spec.ts +++ b/desktop/tests/e2e/composer-link-shortcut.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import type { Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; @@ -13,7 +13,7 @@ import { installMockBridge } from "../helpers/bridge"; // - With focus outside the composer → quick search, unchanged. async function openGeneral(page: Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); } diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index 202ec968698..9078ba61182 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -1,9 +1,15 @@ -import { expect, test, type Locator, type Page } from "@playwright/test"; +import { + expect, + test, + type Locator, + type Page, + bootstrapE2ePage, +} from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; async function openGeneral(page: Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); } diff --git a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts index d6cd05eb0f3..aa2f0af76bf 100644 --- a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts +++ b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; /** @@ -39,7 +39,7 @@ async function expectTooltipDismissesOnLeave( test("composer toolbar tooltip dismisses when cursor leaves the trigger", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -53,7 +53,7 @@ test("composer toolbar tooltip dismisses when cursor leaves the trigger", async test("adjacent composer tooltips each require a fresh dwell", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -73,7 +73,7 @@ test("adjacent composer tooltips each require a fresh dwell", async ({ test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -93,7 +93,7 @@ test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", test("emoji picker tooltip dismisses when cursor leaves the trigger", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/config-bridge-screenshots.spec.ts b/desktop/tests/e2e/config-bridge-screenshots.spec.ts index 3e800e72b09..642d543e100 100644 --- a/desktop/tests/e2e/config-bridge-screenshots.spec.ts +++ b/desktop/tests/e2e/config-bridge-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -113,7 +113,7 @@ async function openAgentProfileFromChannel( tab?: "Info" | "Runtime"; } = {}, ) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForInvokeBridge(page); await activatePersonas(page); @@ -326,7 +326,7 @@ test.describe("config bridge screenshots", () => { }, ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForInvokeBridge(page); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); diff --git a/desktop/tests/e2e/core-memory-screenshots.spec.ts b/desktop/tests/e2e/core-memory-screenshots.spec.ts index 15286859678..20a6ab092aa 100644 --- a/desktop/tests/e2e/core-memory-screenshots.spec.ts +++ b/desktop/tests/e2e/core-memory-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -40,7 +40,7 @@ async function openObserverFeedPanel( page: import("@playwright/test").Page, agentPubkey: string, ) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForSeedHook(page); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); diff --git a/desktop/tests/e2e/custom-emoji-ui.spec.ts b/desktop/tests/e2e/custom-emoji-ui.spec.ts index d3c89fce5bb..ee97c841ed0 100644 --- a/desktop/tests/e2e/custom-emoji-ui.spec.ts +++ b/desktop/tests/e2e/custom-emoji-ui.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -21,7 +21,7 @@ test.beforeEach(async ({ page }) => { }); test("composer renders a custom emoji inline", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -34,7 +34,7 @@ test("composer renders a custom emoji inline", async ({ page }) => { test("settings card splits My emoji from read-only Community emoji", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await expect(page.getByTestId("settings-view")).toBeVisible(); @@ -60,7 +60,7 @@ test("settings card splits My emoji from read-only Community emoji", async ({ test("message list renders inline and emoji-only messages with Slack-style emoji sizing", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 16ca3900ece..32077ea82c5 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -44,7 +44,7 @@ async function waitForMockLiveSubscription( } async function openGeneral(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -316,7 +316,7 @@ test("emoji picker keeps Frequently used live within the app session", async ({ window.localStorage.setItem("emoji-mart.frequently", "{}"); window.localStorage.removeItem("emoji-mart.last"); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); diff --git a/desktop/tests/e2e/deep-link-invite.spec.ts b/desktop/tests/e2e/deep-link-invite.spec.ts index 42c4f8ea9f3..17ed424d42d 100644 --- a/desktop/tests/e2e/deep-link-invite.spec.ts +++ b/desktop/tests/e2e/deep-link-invite.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { seedActiveIdentity } from "../helpers/onboarding"; @@ -55,7 +55,7 @@ test("join deep link is acknowledged without claiming before setup", async ({ { pendingCommunityDeepLinks: [PENDING_JOIN_LINK] }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const gate = page.getByTestId("pending-invite-gate"); await expect(gate).toBeVisible(); @@ -86,7 +86,7 @@ test("connect deep link shows a static acknowledgment during setup", async ({ { pendingCommunityDeepLinks: [PENDING_CONNECT_LINK] }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const gate = page.getByTestId("pending-invite-gate"); await expect(gate).toBeVisible(); @@ -124,7 +124,7 @@ test("add-community deep link starts onboarding when no community is configured" }, { skipCommunitySeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("community-onboarding-flow")).toBeVisible(); await expect( @@ -159,7 +159,7 @@ test("add-community deep link skips profile step when identity has an existing k { pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK] }, { skipCommunitySeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Onboarding flow must disappear — the skip cleared the transaction. await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0); @@ -176,7 +176,7 @@ test("add-community deep link opens one editable prefill and acknowledges the qu { pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK] }, { seedPreviewFeatures: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect( page.getByRole("heading", { name: "Join an existing community" }), @@ -222,7 +222,7 @@ test("queued add-community links open and acknowledge one at a time", async ({ }, { seedPreviewFeatures: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const communityInput = page.getByLabel("Community URL or invite link"); await expect(communityInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl); @@ -300,7 +300,7 @@ test("deleted public starter channels do not strand community onboarding", async { ensureStarterChannelsErrors: [starterError] }, { relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Take me to Buzz" }).click(); @@ -355,7 +355,7 @@ test("required Welcome creation failure keeps community onboarding open", async { createChannelErrors: [welcomeError] }, { relayWsUrl: COMMUNITY_RELAY_URL, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Take me to Buzz" }).click(); @@ -401,7 +401,7 @@ test("persisted deep-link invite hands off to Joining after machine onboarding", skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Machine onboarding is complete, so the transaction owns the screen. await expect(page.getByTestId("community-onboarding-flow")).toBeVisible(); diff --git a/desktop/tests/e2e/dm-double-notification.spec.ts b/desktop/tests/e2e/dm-double-notification.spec.ts index 0678fff9131..4b7896e7c85 100644 --- a/desktop/tests/e2e/dm-double-notification.spec.ts +++ b/desktop/tests/e2e/dm-double-notification.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { finalizeEvent } from "nostr-tools/pure"; import { hexToBytes } from "@noble/hashes/utils.js"; @@ -82,7 +82,7 @@ test("an incoming DM produces exactly one desktop notification", async ({ (response.request().postData() ?? "").includes('"#p"'), { timeout: 15_000 }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Wait until the DM channel is loaded — live subscriptions (channel + home // feed mention) are established once the channel list resolves. diff --git a/desktop/tests/e2e/dm-history-live-regression.spec.ts b/desktop/tests/e2e/dm-history-live-regression.spec.ts index c8891d354da..5171dcc2e93 100644 --- a/desktop/tests/e2e/dm-history-live-regression.spec.ts +++ b/desktop/tests/e2e/dm-history-live-regression.spec.ts @@ -1,4 +1,10 @@ -import { expect, test, type Page, type Route } from "@playwright/test"; +import { + expect, + test, + type Page, + type Route, + bootstrapE2ePage, +} from "../helpers/test"; import { hexToBytes } from "@noble/hashes/utils.js"; import { finalizeEvent, type VerifiedEvent } from "nostr-tools/pure"; @@ -132,7 +138,7 @@ test("existing DM history remains when the first live DM reaches a pageless wind const historyIds = history.map((event) => event.id); await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); await expect.poll(() => timelineIds(page)).toEqual(historyIds); diff --git a/desktop/tests/e2e/dm-new-message-screenshots.spec.ts b/desktop/tests/e2e/dm-new-message-screenshots.spec.ts index a2b7d479407..af6501b0aac 100644 --- a/desktop/tests/e2e/dm-new-message-screenshots.spec.ts +++ b/desktop/tests/e2e/dm-new-message-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { @@ -15,7 +15,7 @@ test("captures the new-message loading skeleton", async ({ page }) => { relayAgents: [], userSearchDelayMs: 10_000, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); const search = page.getByTestId("new-dm-search"); @@ -32,7 +32,7 @@ test("captures the new-message loading skeleton", async ({ page }) => { test("captures selected recipients with the picker open", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); for (const identity of [TEST_IDENTITIES.charlie, TEST_IDENTITIES.bob]) { @@ -54,7 +54,7 @@ test("captures selected recipients with the picker open", async ({ page }) => { test("captures the enabled first-message composer", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await page.getByTestId("new-dm-search").fill("charlie"); diff --git a/desktop/tests/e2e/doctor-cta-screenshots.spec.ts b/desktop/tests/e2e/doctor-cta-screenshots.spec.ts index cde18b7ff0c..9c68a584934 100644 --- a/desktop/tests/e2e/doctor-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/doctor-cta-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -139,7 +139,7 @@ test.describe("doctor CTA nudge card screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [ { @@ -182,7 +182,7 @@ test.describe("doctor CTA nudge card screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [ { @@ -225,7 +225,7 @@ test.describe("doctor CTA nudge card screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [ { @@ -273,7 +273,7 @@ test.describe("doctor CTA nudge card screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); const content = makeNudgeSentinel(AGENT_NAME, AGENT_PUBKEY, [ { diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 05584d77126..3b8359b1ecd 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -125,7 +125,7 @@ test.describe("Doctor panel state screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const runtimeList = page.getByTestId("doctor-runtime-list"); @@ -243,7 +243,7 @@ test.describe("Doctor panel state screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-claude"); @@ -286,7 +286,7 @@ test.describe("Doctor panel state screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-codex"); @@ -331,7 +331,7 @@ test.describe("Doctor panel state screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-claude"); @@ -379,7 +379,7 @@ test.describe("Doctor panel state screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); // Node-gated entries never get a Your-harnesses row (and thus never an @@ -481,7 +481,7 @@ test.describe("Doctor panel state screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-codex"); @@ -563,7 +563,7 @@ test.describe("Doctor panel state screenshots", () => { }, }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const installButton = page.getByTestId("doctor-runtime-install-codex"); @@ -630,7 +630,7 @@ test.describe("Doctor panel state screenshots", () => { }, }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-codex"); @@ -683,7 +683,7 @@ test.describe("Doctor panel state screenshots", () => { }, }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-claude"); @@ -716,7 +716,7 @@ test.describe("Doctor panel state screenshots", () => { }, }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); await expect(page.getByTestId("doctor-runtime-error-codex")).toContainText( @@ -750,7 +750,7 @@ test.describe("Doctor panel state screenshots", () => { connectAcpRuntimeError: "The browser could not be opened.", }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); await page.getByTestId("doctor-runtime-menu-codex").click(); @@ -786,7 +786,7 @@ test.describe("Doctor panel state screenshots", () => { connectAcpRuntimeResult: { launched: true }, }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); await page.getByTestId("doctor-runtime-menu-codex").click(); @@ -815,7 +815,7 @@ test.describe("Doctor panel state screenshots", () => { installAcpRuntimeDelayMs: 250, }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); await expect(page.getByTestId("doctor-runtime-status-codex")).toHaveText( @@ -924,7 +924,7 @@ test.describe("Doctor panel state screenshots", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const claudeRow = page.getByTestId("doctor-runtime-claude"); @@ -1010,7 +1010,7 @@ test.describe("Doctor panel state screenshots", () => { }, }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); const row = page.getByTestId("doctor-runtime-codex"); diff --git a/desktop/tests/e2e/drafts-all-fix-screenshots.spec.ts b/desktop/tests/e2e/drafts-all-fix-screenshots.spec.ts index 82a7df1cc10..ce9dbf9dd08 100644 --- a/desktop/tests/e2e/drafts-all-fix-screenshots.spec.ts +++ b/desktop/tests/e2e/drafts-all-fix-screenshots.spec.ts @@ -1,6 +1,7 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; +import { E2E_APP_ORIGIN } from "../helpers/bootstrap"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; const SHOTS = "test-results/drafts-all-fix"; @@ -18,34 +19,39 @@ test("Inbox All hides drafts while the Drafts filter keeps them", async ({ page, }) => { const draftKey = `channel:${GENERAL_CHANNEL_ID}`; - await page.addInitScript( - ({ draftStoreKey, draftStorageKey, channelId }) => { - const timestamp = new Date().toISOString(); - window.localStorage.setItem( - draftStoreKey, - JSON.stringify({ - [draftStorageKey]: { - channelId, - content: "Draft that must stay out of the All view", - createdAt: timestamp, - pendingImeta: [], - selectionEnd: 41, - selectionStart: 41, - spoileredAttachmentUrls: [], - status: "active", - updatedAt: timestamp, - }, - }), + await bootstrapE2ePage(page, { + beforeNavigate: async () => { + await page.addInitScript( + ({ expectedOrigin, draftStoreKey, draftStorageKey, channelId }) => { + if (location.origin !== expectedOrigin) return; + const timestamp = new Date().toISOString(); + window.localStorage.setItem( + draftStoreKey, + JSON.stringify({ + [draftStorageKey]: { + channelId, + content: "Draft that must stay out of the All view", + createdAt: timestamp, + pendingImeta: [], + selectionEnd: 41, + selectionStart: 41, + spoileredAttachmentUrls: [], + status: "active", + updatedAt: timestamp, + }, + }), + ); + }, + { + channelId: GENERAL_CHANNEL_ID, + expectedOrigin: E2E_APP_ORIGIN, + draftStorageKey: draftKey, + draftStoreKey: `buzz-drafts.v2:ws://localhost:3000:${MOCK_IDENTITY_PUBKEY}`, + }, ); + await installMockBridge(page); }, - { - channelId: GENERAL_CHANNEL_ID, - draftStorageKey: draftKey, - draftStoreKey: `buzz-drafts.v2:ws://localhost:3000:${MOCK_IDENTITY_PUBKEY}`, - }, - ); - await installMockBridge(page); - await page.goto("/"); + }); await page.waitForFunction(() => { const win = window as MockFeedWindow; return typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"; diff --git a/desktop/tests/e2e/drafts-screenshots.spec.ts b/desktop/tests/e2e/drafts-screenshots.spec.ts index a3fb2ce1952..7386290c6b5 100644 --- a/desktop/tests/e2e/drafts-screenshots.spec.ts +++ b/desktop/tests/e2e/drafts-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -107,7 +107,7 @@ async function seedDraftStore( /** Navigate to `/`, wait for inbox, then select the Drafts filter. */ async function openDraftsPanel(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("inbox-filter-trigger").click(); @@ -354,7 +354,7 @@ test.describe("drafts screenshots", () => { await patchCommunityPubkey(page); await seedDraftStore(page, ACTIVE_DRAFTS); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000, }); diff --git a/desktop/tests/e2e/edit-agent-run-on.spec.ts b/desktop/tests/e2e/edit-agent-run-on.spec.ts index 569c735eecb..4862bf39573 100644 --- a/desktop/tests/e2e/edit-agent-run-on.spec.ts +++ b/desktop/tests/e2e/edit-agent-run-on.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -28,7 +28,7 @@ async function openEditDialog( page: import("@playwright/test").Page, agentName: string, ) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page .getByRole("button", { name: `${agentName} agent profile` }) diff --git a/desktop/tests/e2e/edit-agent.spec.ts b/desktop/tests/e2e/edit-agent.spec.ts index e7fbe3d7bdc..448dd66acc5 100644 --- a/desktop/tests/e2e/edit-agent.spec.ts +++ b/desktop/tests/e2e/edit-agent.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -42,7 +42,7 @@ const PERSONA_ID = "persona-edit-e2e"; * only mount path. */ async function openEditDialog(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); const agentButton = page.getByRole("button", { @@ -86,7 +86,7 @@ test.describe("agent definition dialog", () => { ownerOnlyAccessBuild: true, bakedBuildEnv: BAKED_DEFAULTS, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); @@ -381,7 +381,7 @@ test.describe("edit agent dialog", () => { ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); // Persona-linked agents render grouped under the persona's card name. diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts index 2d8149966ff..5b0e3a67dc0 100644 --- a/desktop/tests/e2e/empty-edit-delete.spec.ts +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -44,7 +44,7 @@ async function submitEmptyEdit( test.beforeEach(async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); }); diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index a120e436da7..6f652446117 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -51,7 +51,7 @@ test("agent-style message with bare buzz:// links renders entity cards without s ); await installMockBridge(page); await page.setViewportSize({ width: 900, height: 800 }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("channel-general").click(); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", @@ -134,7 +134,7 @@ test("desktop composer shows entity card and send is not blocked by missing snap }) => { await installMockBridge(page); await page.setViewportSize({ width: 900, height: 800 }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("channel-general").click(); const repoLink = `buzz://repo?owner=${ALICE_PUBKEY}&d=relay-tools`; @@ -215,7 +215,7 @@ test("reopening the same entity link reapplies its workspace state", async ({ }, ); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("open-projects-view")).toBeVisible(); const repoLink = `buzz://repo?owner=${DEFAULT_MOCK_PUBKEY}&d=buzz&tab=prs`; const prLink = `buzz://pr?id=${PR_ID}&owner=${DEFAULT_MOCK_PUBKEY}&d=buzz`; @@ -289,7 +289,7 @@ test("cold-start entity links drain after the React listener mounts", async ({ pendingEntityDeepLinks: [{ id: "cold-start-project", href }], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect( page.getByRole("tab", { name: "Review", exact: true }), diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index d5680b6c90d..f5aff3f6844 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import type { Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; @@ -91,7 +91,7 @@ async function uploadCommandCount(page: Page) { test("picker survives cancel, same-file retry, and multiple selection", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const attach = page.getByRole("button", { name: "Attach file" }); @@ -125,7 +125,7 @@ test("picker survives cancel, same-file retry, and multiple selection", async ({ test("photos upload before Send without a queued spoiler control", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate(() => { const e2e = ( window as Window & { @@ -165,7 +165,7 @@ test("photos upload before Send without a queued spoiler control", async ({ test("opening edit during an immediate photo upload preserves the draft", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate(() => { const e2e = ( window as Window & { @@ -199,7 +199,7 @@ test("opening edit during an immediate photo upload preserves the draft", async }); test("upload a file and see a FileCard in the timeline", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -240,7 +240,7 @@ test("upload a file and see a FileCard in the timeline", async ({ page }) => { test("sends immediately and keeps upload progress across channels", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate(() => { const e2e = ( window as Window & { @@ -286,7 +286,7 @@ test("sends immediately and keeps upload progress across channels", async ({ test("shows upload feedback before transferring a large file", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate(() => { const e2e = ( window as Window & { @@ -373,7 +373,7 @@ test("shows upload feedback before transferring a large file", async ({ test("canceling a background upload prevents the message from publishing", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate(() => { const e2e = ( window as Window & { @@ -395,7 +395,7 @@ test("canceling a background upload prevents the message from publishing", async test("upload progress floats above the dock and lifts Jump to latest", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate(() => { const e2e = ( window as Window & { @@ -450,7 +450,7 @@ test("upload progress floats above the dock and lifts Jump to latest", async ({ test("dropping a file on the channel column attaches it to the composer", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -524,7 +524,7 @@ for (const theme of ["buzz", "buzz-dark", "github-light", "github-dark"]) { test(`drop prompt has accessible text contrast in ${theme}`, async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate((selectedTheme) => { window.localStorage.setItem("buzz-theme", selectedTheme); }, theme); @@ -583,7 +583,7 @@ test("forum posts emit a FileCard for generic attachments, not a broken image", // and lost its label. The fix routes forum/notes posts through the same // `buildOutgoingMessage` builder as chat. This test would fail (no FileCard) // if ForumComposer ever drifts back to hand-building media markdown. - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // "watercooler" is a seeded forum the mock identity is a member of. await page.getByTestId("channel-watercooler").click(); @@ -620,7 +620,7 @@ test("a queued attachment can be removed without a mouse", async ({ page }) => { // Regression: the queued remove badge is revealed on hover, but hiding it // with `display: none` made it unfocusable, leaving keyboard-only users no // way to drop a queued video before sending. - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await chooseLargeVideo(page); @@ -662,7 +662,7 @@ test("an uploaded attachment's remove button is named and keyboard-operable", as }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const composer = page.getByTestId("message-composer"); @@ -686,7 +686,7 @@ test("a non-media attachment's remove button is named and keyboard-operable", as }) => { // The file-card branch renders its own remove badge, so it needs the same // accessible name as the image and queued ones. - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await chooseQuarterlyReport(page); diff --git a/desktop/tests/e2e/foreground-responsiveness-regression.spec.ts b/desktop/tests/e2e/foreground-responsiveness-regression.spec.ts index db2ea01d6a4..09543e5aa7a 100644 --- a/desktop/tests/e2e/foreground-responsiveness-regression.spec.ts +++ b/desktop/tests/e2e/foreground-responsiveness-regression.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installRelayBridge } from "../helpers/bridge"; import { assertRelaySeeded } from "../helpers/seed"; @@ -75,7 +75,7 @@ test("foreground paints and handles a sidebar action before resume fetches", asy ); }); await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index fef87bb8b20..a19b9934ca2 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -11,7 +11,7 @@ const SHOTS = "test-results/global-agent-config"; * `/settings` directly returns a 404 before the client router can start. */ async function openAiDefaultsSettings(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await expect(page.getByTestId("settings-view")).toBeVisible(); @@ -29,7 +29,7 @@ async function openAiDefaultsSettings(page: import("@playwright/test").Page) { * "New agent" menu item, then fill a placeholder name. */ async function openCreateDialog(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); await page.locator("#persona-display-name").fill("Test Agent"); @@ -704,7 +704,7 @@ test.describe("global agent config screenshots", () => { test("create with a missing name has no footer message", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); @@ -840,7 +840,7 @@ test.describe("global agent config screenshots", () => { }); // Agents view → persona-grouped agent card → Edit quick action. - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); const agentButton = page.getByRole("button", { name: "Codex Editor agent profile", @@ -931,7 +931,7 @@ test.describe("global agent config screenshots", () => { }); // Agents view → persona-grouped agent card → Edit quick action. - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); const agentButton = page.getByRole("button", { name: "Legacy Editor agent profile", diff --git a/desktop/tests/e2e/harness-catalog-screenshots.spec.ts b/desktop/tests/e2e/harness-catalog-screenshots.spec.ts index c5613f41f2a..46946e9d25f 100644 --- a/desktop/tests/e2e/harness-catalog-screenshots.spec.ts +++ b/desktop/tests/e2e/harness-catalog-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; @@ -140,7 +140,7 @@ test("after: consolidated harnesses panel + catalog dialog", async ({ test.setTimeout(60_000); await page.setViewportSize({ width: 1280, height: 2400 }); await installMockBridge(page, { acpRuntimesCatalog: CATALOG }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "agents"); await expect(page.getByTestId("settings-harnesses")).toBeVisible({ timeout: 10_000, diff --git a/desktop/tests/e2e/harness-management.spec.ts b/desktop/tests/e2e/harness-management.spec.ts index c5b441ad21d..4b072f688c0 100644 --- a/desktop/tests/e2e/harness-management.spec.ts +++ b/desktop/tests/e2e/harness-management.spec.ts @@ -18,7 +18,7 @@ * - Preset rows render bundled logos, never initials * - Onboarding navigate: setup-page "More harnesses" click → Settings → Agents (F8) */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { passThroughBackupStep } from "../helpers/onboarding"; @@ -130,7 +130,7 @@ function makeCustomEntry( * before the client router starts. */ async function openHarnessSettings(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await expect(page.getByTestId("settings-view")).toBeVisible(); @@ -667,7 +667,7 @@ test("onboarding setup More-harnesses click navigates to Settings → Agents", a ); window.localStorage.setItem("buzz-active-community-id", communityId); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Reach setup by creating a new identity key and continuing past the // created-key page without opening the optional backup options. diff --git a/desktop/tests/e2e/home-collapsed-top-chrome.spec.ts b/desktop/tests/e2e/home-collapsed-top-chrome.spec.ts index 44566b4e609..d37e23847d8 100644 --- a/desktop/tests/e2e/home-collapsed-top-chrome.spec.ts +++ b/desktop/tests/e2e/home-collapsed-top-chrome.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -8,7 +8,7 @@ test.describe("home inbox chrome", () => { test.beforeEach(async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); }); diff --git a/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts index 3ec6dc01c38..a6564cea5d5 100644 --- a/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -27,7 +27,7 @@ test.beforeEach(async ({ page }) => { }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "hosted-communities"); }); diff --git a/desktop/tests/e2e/huddle-transcription.spec.ts b/desktop/tests/e2e/huddle-transcription.spec.ts index 7c56648654a..933c8c73cdb 100644 --- a/desktop/tests/e2e/huddle-transcription.spec.ts +++ b/desktop/tests/e2e/huddle-transcription.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { KIND_HUDDLE_ENDED, @@ -122,7 +122,7 @@ test("keeps the drawer open until the huddle is expanded", async ({ page }) => { }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const gradientUnderlay = page.locator(".buzz-theme-gradient-underlay"); const openGradient = await gradientUnderlay.evaluate( @@ -288,7 +288,7 @@ test("floats the in-app huddle tray over the glass background", async ({ }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const root = page.locator("html"); const shell = page.locator('.buzz-huddle-shell[data-huddle-window="false"]'); @@ -360,7 +360,7 @@ test("keeps the popped-out huddle dock full-width over glass", async ({ }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const root = page.locator("html"); const shell = page.locator('.buzz-huddle-shell[data-huddle-window="true"]'); @@ -392,7 +392,7 @@ test("shows speaker identity on every huddle chat message", async ({ }, mock: { threadRepliesDelayMs: 1_500 }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect .poll(() => @@ -488,7 +488,7 @@ test("ignores persisted community onboarding in the huddle room", async ({ }); for (let load = 0; load < 2; load += 1) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("huddle-transcript-intro")).toBeVisible(); await expect( page.getByText("Setting up your community", { exact: true }), @@ -534,7 +534,7 @@ test("keeps main-app shortcuts from navigating the huddle room", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page).toHaveURL( new RegExp(`/channels/${HUDDLE_CHANNEL_ID.replaceAll("-", "\\-")}`), @@ -592,7 +592,7 @@ test("speaks the first eligible agent reply with its participant identity", asyn ttsEnabled: true, }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await waitForMockLiveSubscription(page, "huddle"); await page.evaluate((agentPubkey) => { @@ -637,7 +637,7 @@ test("animates the responding agent with the shared speaker ring", async ({ ], }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const agentAvatar = page .getByTestId("huddle-participant-strip") @@ -685,7 +685,7 @@ test("stops the speaking agent from the huddle controls", async ({ page }) => { ], }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const roomMicButton = page.getByRole("button", { name: "Microphone unavailable", }); @@ -777,7 +777,7 @@ test("assigns distinct agent voices and exposes compact per-agent controls", asy ttsEnabled: true, }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const voiceMenus = page.getByTestId("huddle-agent-voice-menu-trigger"); await expect(voiceMenus).toHaveCount(2); @@ -865,7 +865,7 @@ test("adds channel-mentioned agents to the live huddle roster", async ({ ], }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const participantTiles = page.getByTestId("huddle-participant-tile"); await expect(participantTiles).toHaveCount(2); @@ -917,7 +917,7 @@ test("does not enroll available agents when sending an ordinary message", async }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const participantTiles = page.getByTestId("huddle-participant-tile"); await expect(participantTiles).toHaveCount(1); @@ -952,7 +952,7 @@ test("keeps the colored startup surface while huddle controls connect", async ({ members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }], }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("huddle-starting-view")).toBeVisible(); await expect( @@ -982,7 +982,7 @@ test("returns the companion transcript to the same channel in the main app", asy transcriptionEnabled: true, }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.locator(".buzz-huddle-shell")).toHaveAttribute( "data-huddle-open", @@ -1049,7 +1049,7 @@ test("returns to the parent channel when leaving a huddle channel in view", asyn members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }], }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.locator(".buzz-huddle-shell")).toHaveAttribute( "data-huddle-open", @@ -1087,7 +1087,7 @@ test("keeps the huddle avatar strip compact and exposes the full roster", async members, }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const participantStrip = page.getByTestId("huddle-participant-strip"); const participantTrigger = page.getByRole("button", { @@ -1123,7 +1123,7 @@ test("removes an agent from its menu without showing an extra participant contro ], }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect( page.getByRole("button", { name: "Manage huddle participants" }), @@ -1166,7 +1166,7 @@ test("keeps a newer huddle event over a delayed hydration snapshot", async ({ }, huddleStateReadDelayMs: 250, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect .poll(() => page.evaluate(() => @@ -1210,7 +1210,7 @@ test("keeps a starting huddle in the drawer after its companion closes", async ( openHuddleWindowDelayMs: 500, startHuddleReturnDelayMs: 1_500, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await page.getByTestId("channel-start-huddle-trigger").click(); @@ -1279,7 +1279,7 @@ test("starts muted with Push to Talk while preserving manual microphone control" openHuddleWindowDelayMs: 10_000, startHuddleReturnDelayMs: 1_500, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await page.getByTestId("channel-start-huddle-trigger").click(); @@ -1390,7 +1390,7 @@ test("toggles the current channel huddle with Control+Shift+Space", async ({ await installMockBridge(page, { openHuddleWindowDelayMs: 10_000, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("channel-start-huddle-trigger")).toBeEnabled(); const pressHuddleShortcut = () => @@ -1449,7 +1449,7 @@ test("starts an agent DM huddle and hides its backing channel after it ends", as openHuddleWindowDelayMs: 500, startHuddleReturnDelayMs: 1_500, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); await expect @@ -1651,7 +1651,7 @@ test("closes add-agent dialog when parent membership is already satisfied", asyn }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Add agent to huddle" }).click(); const dialog = page.getByRole("dialog", { name: "Add agents" }); @@ -1687,7 +1687,7 @@ test("starts an available stopped agent before adding it to the huddle", async ( }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Add agent to huddle" }).click(); const dialog = page.getByRole("dialog", { name: "Add agents" }); @@ -1738,7 +1738,7 @@ test("stops an agent started solely for a failed huddle add", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Add agent to huddle" }).click(); const dialog = page.getByRole("dialog", { name: "Add agents" }); @@ -1786,7 +1786,7 @@ test("does not deploy a provider agent when its huddle add fails", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Add agent to huddle" }).click(); const dialog = page.getByRole("dialog", { name: "Add agents" }); diff --git a/desktop/tests/e2e/human-edit-agent-content.spec.ts b/desktop/tests/e2e/human-edit-agent-content.spec.ts index 0994fce23c3..319bd70122a 100644 --- a/desktop/tests/e2e/human-edit-agent-content.spec.ts +++ b/desktop/tests/e2e/human-edit-agent-content.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -84,7 +84,7 @@ test("owner can edit their owned agent's message", async ({ page }) => { const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`; const editedContent = `Edited by owner ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -119,7 +119,7 @@ test("owner can edit their owned agent's message", async ({ page }) => { test("owner can delete their owned agent's message", async ({ page }) => { const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -149,7 +149,7 @@ test("owner does NOT see Edit or Delete for an unowned agent's message", async ( // Charlie is in mockAgentPubkeys but ownerPubkey is NOT the mock identity. const charlieMessageId = "mock-agents-charlie"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -180,7 +180,7 @@ test("owner can delete their owned agent's message from the thread panel", async // id: `mock-agents-managed-${pubkey.slice(0, 8)}` const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -239,7 +239,7 @@ test("owner can edit channel name via owned-agent-owner path", async ({ }) => { const newChannelName = `renamed-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Add OwnedBot as an owner-role member of #random. // In #random the mock identity is only a plain member — selfMember.role !== "owner". @@ -275,7 +275,7 @@ test("owner can edit channel name via owned-agent-owner path", async ({ test("owner does NOT see channel Edit button when no owned agent is a channel owner", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // #random has alice as owner; mock identity is a plain member. // OwnedBot is NOT added as owner in this test — Edit must not appear. diff --git a/desktop/tests/e2e/identity-archive-hide.spec.ts b/desktop/tests/e2e/identity-archive-hide.spec.ts index 39410e2631f..eba72571adb 100644 --- a/desktop/tests/e2e/identity-archive-hide.spec.ts +++ b/desktop/tests/e2e/identity-archive-hide.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, openNewMessagePage } from "../helpers/bridge"; @@ -24,7 +24,7 @@ test.describe("NIP-IA hide archived from discovery", () => { page, }) => { await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("channel-members-trigger").click(); @@ -51,7 +51,7 @@ test.describe("NIP-IA hide archived from discovery", () => { page, }) => { await installMockBridge(page, { archivedIdentities: [] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("channel-members-trigger").click(); await expect(page.getByTestId("members-sidebar")).toBeVisible(); @@ -62,7 +62,7 @@ test.describe("NIP-IA hide archived from discovery", () => { page, }) => { await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -88,7 +88,7 @@ test.describe("NIP-IA hide archived from discovery", () => { page, }) => { await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await expect(page.getByTestId("new-message-page")).toBeVisible(); @@ -110,7 +110,7 @@ test.describe("NIP-IA hide archived from discovery", () => { // Alice has a seeded message in #general from the prior PR's e2e setup // (see e2eBridge.ts seed). Archiving her must not remove that message. await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -128,7 +128,7 @@ test.describe("NIP-IA hide archived from discovery", () => { // self-exemption is what enforces this — without it, the user would lose // their own seat in the members sidebar. await installMockBridge(page, { archivedIdentities: [SELF_PUBKEY] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("channel-members-trigger").click(); await expect(page.getByTestId("members-sidebar")).toBeVisible(); @@ -151,7 +151,7 @@ test.describe("NIP-IA hide archived from discovery", () => { // self from their own autocomplete — that would be the shadowban NIP-IA // exists to prevent. await installMockBridge(page, { archivedIdentities: [SELF_PUBKEY] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -174,7 +174,7 @@ test.describe("NIP-IA hide archived from discovery", () => { page, }) => { await installMockBridge(page, { archivedIdentities: [BOB_PUBKEY] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await page.getByTestId("channel-members-trigger").click(); await expect(page.getByTestId("members-sidebar")).toBeVisible(); @@ -191,7 +191,7 @@ test.describe("NIP-IA hide archived from discovery", () => { // companion that makes the test above non-vacuous (proves he was dropped // by the archive filter, not by member-exclusion or a broken search). await installMockBridge(page, { archivedIdentities: [] }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await page.getByTestId("channel-members-trigger").click(); await expect(page.getByTestId("members-sidebar")).toBeVisible(); diff --git a/desktop/tests/e2e/identity-archive.spec.ts b/desktop/tests/e2e/identity-archive.spec.ts index dfbe2ff2690..0ccd22f5391 100644 --- a/desktop/tests/e2e/identity-archive.spec.ts +++ b/desktop/tests/e2e/identity-archive.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -14,7 +14,7 @@ const ALICE_PUBKEY = "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; async function openSelfProfile(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); // First seed message in #general is from the active identity. @@ -24,7 +24,7 @@ async function openSelfProfile(page: import("@playwright/test").Page) { } async function openAliceProfile(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); // Second seed message in #general is from Alice. Her display name "alice" diff --git a/desktop/tests/e2e/identity-key-help.spec.ts b/desktop/tests/e2e/identity-key-help.spec.ts index d204d556041..40f2a234487 100644 --- a/desktop/tests/e2e/identity-key-help.spec.ts +++ b/desktop/tests/e2e/identity-key-help.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -10,7 +10,7 @@ test("identity key help explains the first-run choice", async ({ page }) => { skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const trigger = page.getByTestId("identity-key-help-trigger"); // No initial opacity-0 assertion: on a slow runner the 2s reveal timer can @@ -65,7 +65,7 @@ test("identity key help stays readable when the app resolves dark mode", async ( skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Fresh profiles follow the system scheme, so the emulated dark scheme is // the first-run repro: the app resolves the dark theme while onboarding. diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index 2ab41cb52d3..59ab7a339ea 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -1,5 +1,5 @@ import { hexToBytes } from "@noble/hashes/utils.js"; -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { nsecEncode } from "nostr-tools/nip19"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -12,7 +12,7 @@ test("normal first launch uses the already-persisted identity", async ({ skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const gate = page.getByTestId("machine-onboarding-gate"); await expect(gate).toBeVisible(); @@ -58,7 +58,7 @@ test("lost boot opens onboarding gate directly on the key-import page", async ({ { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); await expect( @@ -78,7 +78,7 @@ test("lost boot keeps the pairing-code action stable while generating", async ({ { identityLost: true, pairingStartDelayMs: 2_500 }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("nostr-import-phone-link").click(); const copyButton = page.getByTestId("copy-identity-recovery-code"); @@ -105,7 +105,7 @@ test("lost boot offers phone recovery with a single-use QR", async ({ { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible(); @@ -165,7 +165,7 @@ test("phone recovery uses the desktop pairing card semantics", async ({ { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("nostr-import-phone-link").click(); const card = page.getByTestId("identity-recovery-pairing"); @@ -235,7 +235,7 @@ test("canceling recovery uses the standard pairing cancellation state", async ({ { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); @@ -271,7 +271,7 @@ test("phone recovery continues to harness setup without creating or restarting", { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); @@ -298,7 +298,7 @@ test("recovery turns relay failures into actionable copy", async ({ page }) => { { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); @@ -326,7 +326,7 @@ test("desktop refreshes recovery codes before the relay expires them", async ({ { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); @@ -352,7 +352,7 @@ test("importing a key from lost mode shows the relaunch-required screen", async { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect( page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); @@ -373,7 +373,7 @@ test("start-new-identity from lost mode persists the ephemeral key after confirm { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect( page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); @@ -406,7 +406,7 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as { identityLost: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect( page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); @@ -429,7 +429,7 @@ test("locked boot shows the keyring-locked screen without the onboarding gate or { identityLocked: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("keyring-locked")).toBeVisible(); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); @@ -446,7 +446,7 @@ test("locked boot can re-import a key and requires relaunch", async ({ { identityLocked: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("keyring-locked")).toBeVisible(); page.on("dialog", (dialog) => dialog.accept()); @@ -471,7 +471,7 @@ test("locked screen relaunch button records the process-restart invoke", async ( { identityLocked: true }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("keyring-locked")).toBeVisible(); await page.getByTestId("relaunch-app").click(); diff --git a/desktop/tests/e2e/image-attachment-gallery.spec.ts b/desktop/tests/e2e/image-attachment-gallery.spec.ts index 8c360236550..5bff135101c 100644 --- a/desktop/tests/e2e/image-attachment-gallery.spec.ts +++ b/desktop/tests/e2e/image-attachment-gallery.spec.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Page, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -118,7 +118,7 @@ test.beforeEach(async ({ page }) => { }); test("image bundle lightbox navigates as a gallery", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -259,7 +259,7 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => { test("hidden spoiler images are excluded from gallery navigation until revealed", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -352,7 +352,7 @@ test("message images load a thumbnail before requesting the original", async ({ await route.fulfill({ body: svg("#4aa3df"), contentType: "image/svg+xml" }); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await waitForMockLiveSubscription(page, "general"); await page.evaluate( @@ -414,7 +414,7 @@ test("gallery items without imeta dimensions keep their thumbnail aspect ratio", page, }) => { await installNoDimImageRoutes(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -471,7 +471,7 @@ test("forum markdown images use the markdown root as their gallery scope", async page, }) => { await installNoDimImageRoutes(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect .poll(() => { return page.evaluate(() => { @@ -557,7 +557,7 @@ test("multi-image mosaics keep a fixed width and grow by rows", async ({ page, }) => { await installNoDimImageRoutes(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -617,7 +617,7 @@ test("multi-image mosaics keep a fixed width and grow by rows", async ({ test("image mosaic screenshot", async ({ page }) => { await installNoDimImageRoutes(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -662,7 +662,7 @@ test("image mosaic screenshot", async ({ page }) => { test("mosaic image context menu is portaled outside the clipped gallery", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -704,7 +704,7 @@ test("mosaic image context menu is portaled outside the clipped gallery", async test("lightbox image context menu stays inside the dialog focus scope", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -746,7 +746,7 @@ test("lightbox image context menu stays inside the dialog focus scope", async ({ test("right-click image shows Copy image and invokes copy command", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/inbox-edit.spec.ts b/desktop/tests/e2e/inbox-edit.spec.ts index 69b094821e5..d1cfe0de6f5 100644 --- a/desktop/tests/e2e/inbox-edit.spec.ts +++ b/desktop/tests/e2e/inbox-edit.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -188,7 +188,7 @@ test("editing an immediate attachment reply preserves its media tags", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction( () => @@ -326,7 +326,7 @@ test("Inbox offers Edit and Delete actions only for manageable messages", async page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction( () => @@ -458,7 +458,7 @@ test("cancelling explicit Inbox deletion preserves the message and selection", a page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); const { detail, rootRow } = await seedEmptyDeleteThread(page); const selectedReplyRow = detail.locator( @@ -492,7 +492,7 @@ test("explicit Inbox deletion targets the chosen non-selected message", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); const { detail, rootRow } = await seedEmptyDeleteThread(page); const selectedReplyRow = detail.locator( @@ -526,7 +526,7 @@ test("empty edit confirms deletion of the edited non-selected Inbox row", async page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); const { detail, rootRow } = await seedEmptyDeleteThread(page); const selectedReplyRow = detail.locator( @@ -561,7 +561,7 @@ test("cancelling an empty Inbox edit deletion preserves content and edit mode", page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); const { detail, rootRow } = await seedEmptyDeleteThread(page); const editsBefore = await editCommandCount(page); @@ -601,7 +601,7 @@ test("cold Inbox open drops an edit that was itself deleted", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction(() => { const win = window as MockWindow; diff --git a/desktop/tests/e2e/inbox-live-update.spec.ts b/desktop/tests/e2e/inbox-live-update.spec.ts index 94e0b7b54a1..56234b77d95 100644 --- a/desktop/tests/e2e/inbox-live-update.spec.ts +++ b/desktop/tests/e2e/inbox-live-update.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import type { RelayEvent } from "../../src/shared/api/types"; @@ -282,7 +282,7 @@ test.describe("inbox stable-conversation regressions", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(getListPane(page)).toBeVisible(); await waitForBridgeReady(page); await installScrollSpy(page); @@ -403,7 +403,7 @@ test.describe("inbox stable-conversation regressions", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(getListPane(page)).toBeVisible(); await waitForBridgeReady(page); await installScrollSpy(page); @@ -539,7 +539,7 @@ test.describe("inbox stable-conversation regressions", () => { // representative row, selected-message highlight, and parentEventId. await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(getListPane(page)).toBeVisible(); await waitForBridgeReady(page); @@ -752,7 +752,7 @@ test.describe("inbox stable-conversation regressions", () => { // message highlight, parentEventId — same shape as the primary cold test. await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(getListPane(page)).toBeVisible(); await waitForBridgeReady(page); @@ -951,7 +951,7 @@ test.describe("inbox stable-conversation regressions", () => { // the newest message). await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(getListPane(page)).toBeVisible(); await waitForBridgeReady(page); await installScrollSpy(page); @@ -1130,7 +1130,7 @@ test.describe("inbox stable-conversation regressions", () => { // - Assert selected message stays in viewport; spy count stays 1. await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(getListPane(page)).toBeVisible(); await waitForBridgeReady(page); await installScrollSpy(page); @@ -1340,7 +1340,7 @@ test.describe("inbox stable-conversation regressions", () => { // - Assert selected message stays in viewport; spy count stays 1. await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(getListPane(page)).toBeVisible(); await waitForBridgeReady(page); await installScrollSpy(page); diff --git a/desktop/tests/e2e/inbox-reactions.spec.ts b/desktop/tests/e2e/inbox-reactions.spec.ts index 1b7451cbcc2..6407499ee49 100644 --- a/desktop/tests/e2e/inbox-reactions.spec.ts +++ b/desktop/tests/e2e/inbox-reactions.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import type { RelayEvent } from "../../src/shared/api/types"; @@ -37,7 +37,7 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction(() => { const win = window as MockWindow; diff --git a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts index 8f1f074e277..a6c91ee6dbb 100644 --- a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts +++ b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts @@ -10,7 +10,8 @@ * tests/e2e/inbox-refactor-screenshots.spec.ts * Output: test-results/inbox-refactor/ */ -import { expect, test, type Page } from "@playwright/test"; +import { type Page } from "@playwright/test"; +import { bootstrapE2ePage, expect, test } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -213,7 +214,7 @@ test.describe("inbox refactor screenshots", () => { await seedTwoDrafts(page); await installMockBridge(page, { mode: "mock" }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000, }); @@ -234,7 +235,7 @@ test.describe("inbox refactor screenshots", () => { }) => { await installMockBridge(page, { mode: "mock" }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000, }); @@ -261,7 +262,7 @@ test.describe("inbox refactor screenshots", () => { }) => { await installMockBridge(page, { mode: "mock" }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForMockFeedHelpers(page); const dmIds = ["shot-dm-first", "shot-dm-second", "shot-dm-third"]; @@ -331,7 +332,7 @@ test.describe("inbox refactor screenshots", () => { await seedConversationPreferences(page, "default", "comfortable"); await installMockBridge(page, { mode: "mock" }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForMockFeedHelpers(page); const replyIds = [ diff --git a/desktop/tests/e2e/inline-custom-harness.spec.ts b/desktop/tests/e2e/inline-custom-harness.spec.ts index 1ef7e67bb79..98797b15254 100644 --- a/desktop/tests/e2e/inline-custom-harness.spec.ts +++ b/desktop/tests/e2e/inline-custom-harness.spec.ts @@ -14,7 +14,7 @@ * selection untouched. The three surfaces share the same routing, so those * checks are not repeated on every one. */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -47,7 +47,7 @@ async function registerHarness(page: Page) { /** Open the create-agent dialog (AgentDefinitionDialog, create mode). */ async function openCreateDialog(page: Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); const dialog = page.getByTestId("persona-dialog"); @@ -58,7 +58,7 @@ async function openCreateDialog(page: Page) { /** Open the edit dialog for a saved definition (same dialog, edit mode). */ async function openDefinitionEditDialog(page: Page, name: string) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("agents-library-personas")).toBeVisible({ timeout: 10_000, @@ -159,7 +159,7 @@ test.describe("inline add custom harness", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-agents-view").click(); await page .getByRole("button", { name: "Instance Agent agent profile" }) diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index c207fcef062..fc2c9a99a41 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Browser } from "@playwright/test"; +import { expect, test, type Browser, bootstrapE2ePage } from "../helpers/test"; import { installRelayBridge, @@ -206,7 +206,7 @@ test("create channel and verify in sidebar", async ({ page }) => { const channelName = `integration-e2e-${Date.now()}`; await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page.getByTestId("create-channel-submit").click(); @@ -230,13 +230,13 @@ test("two users see the same channel", async ({ await installRelayBridge(pageOne, "tyler"); await installRelayBridge(pageTwo, "alice"); - await pageOne.goto("/"); + await bootstrapE2ePage(pageOne, "/"); await openCreateChannelDialog(pageOne); await pageOne.getByTestId("create-channel-name").fill(channelName); await pageOne.getByTestId("create-channel-submit").click(); await expect(pageOne.getByTestId("stream-list")).toContainText(channelName); - await pageTwo.goto("/"); + await bootstrapE2ePage(pageTwo, "/"); await openChannelBrowser(pageTwo); await expect(pageTwo.getByTestId("channel-browser-dialog")).toBeVisible(); await pageTwo @@ -265,8 +265,8 @@ test("message delivery across users", async ({ await installRelayBridge(pageOne, "tyler"); await installRelayBridge(pageTwo, "alice"); - await pageOne.goto("/"); - await pageTwo.goto("/"); + await bootstrapE2ePage(pageOne, "/"); + await bootstrapE2ePage(pageTwo, "/"); await pageOne.getByTestId("channel-general").click(); await pageTwo.getByTestId("channel-general").click(); @@ -300,8 +300,8 @@ test("live mentions refetch the home feed without waiting for polling", async ({ await installRelayBridge(targetPage, "tyler"); await installRelayBridge(senderPage, "alice"); - await targetPage.goto("/"); - await senderPage.goto("/"); + await bootstrapE2ePage(targetPage, "/"); + await bootstrapE2ePage(senderPage, "/"); await assertDesktopNotificationsEnabled(targetPage); await targetPage.getByTestId("channel-general").click(); @@ -361,8 +361,8 @@ test("live forum mentions refetch the home feed without waiting for polling", as await installRelayBridge(targetPage, "tyler"); await installRelayBridge(senderPage, "alice"); - await targetPage.goto("/"); - await senderPage.goto("/"); + await bootstrapE2ePage(targetPage, "/"); + await bootstrapE2ePage(senderPage, "/"); await assertDesktopNotificationsEnabled(targetPage); await targetPage.getByTestId("channel-general").click(); @@ -405,7 +405,7 @@ test("live forum mentions refetch the home feed without waiting for polling", as test("DM channel appears in sidebar", async ({ page }) => { await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("dm-list")).toContainText("alice-tyler"); }); @@ -414,7 +414,7 @@ test("send message to DM", async ({ page }) => { const message = `DM message ${Date.now()}`; await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); @@ -426,7 +426,7 @@ test("send message to DM", async ({ page }) => { test("forum channel appears in sidebar", async ({ page }) => { await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("forum-list")).toContainText("watercooler"); }); @@ -436,7 +436,7 @@ test("create channel with description", async ({ page }) => { const description = `Description for ${channelName}`; await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await createStream(page, channelName, description); await expect(page.getByTestId("chat-title")).toHaveAttribute( @@ -451,7 +451,7 @@ test("multiple channels independent", async ({ page }) => { const messageA = `Message in A ${Date.now()}`; await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelA); @@ -488,7 +488,7 @@ test("manage sheet updates channel details through the relay", async ({ const updatedDescription = `Updated description ${stamp}`; await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await createStream(page, initialName, initialDescription); await openChannelManagement(page); @@ -549,7 +549,7 @@ test("manage sheet archive and unarchive survives a reload through the relay", a const channelName = `archive-integration-${Date.now()}`; await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await createStream(page, channelName, "Archive integration channel"); await openChannelManagement(page); diff --git a/desktop/tests/e2e/invite-link-copy.spec.ts b/desktop/tests/e2e/invite-link-copy.spec.ts index 353793ec4ad..e14c982cea5 100644 --- a/desktop/tests/e2e/invite-link-copy.spec.ts +++ b/desktop/tests/e2e/invite-link-copy.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; @@ -30,7 +30,7 @@ test.beforeEach(async ({ page }) => { test("copies a freshly minted invite link from the link field", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "community-members"); await expect(page.getByTestId("settings-community-members")).toBeVisible(); @@ -68,7 +68,7 @@ test("copies a freshly minted invite link from the link field", async ({ }); test("sets a selected invite-use limit", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "community-members"); await page.getByTestId("community-invite-dialog-trigger").click(); @@ -112,7 +112,7 @@ test("retries a failed invite-link generation", async ({ page }) => { }); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "community-members"); await page.getByTestId("community-invite-dialog-trigger").click(); @@ -136,7 +136,7 @@ test("retries a failed invite-link generation", async ({ page }) => { test("reopens with the default expiry before generating a new link", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "community-members"); await page.getByTestId("community-invite-dialog-trigger").click(); diff --git a/desktop/tests/e2e/invites-settings-screenshots.spec.ts b/desktop/tests/e2e/invites-settings-screenshots.spec.ts index 5421fa64282..c2523bc54c4 100644 --- a/desktop/tests/e2e/invites-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/invites-settings-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; @@ -32,7 +32,7 @@ test.beforeEach(async ({ page }, testInfo) => { status: 200, }); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "community-members"); }); diff --git a/desktop/tests/e2e/key-import-reveal.spec.ts b/desktop/tests/e2e/key-import-reveal.spec.ts index cd05f71e768..4f387dddb64 100644 --- a/desktop/tests/e2e/key-import-reveal.spec.ts +++ b/desktop/tests/e2e/key-import-reveal.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -16,7 +16,7 @@ test("key import masks the key with a reveal toggle", async ({ page }) => { skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Use an existing key" }).click(); const input = page.getByTestId("nostr-import-nsec-input"); diff --git a/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts b/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts index d63be27471b..792b459099d 100644 --- a/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts +++ b/desktop/tests/e2e/live-broadcast-reply-timeline.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -101,7 +101,7 @@ test("a live broadcast depth-1 reply enters the authoritative channel window sto page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); diff --git a/desktop/tests/e2e/local-archive-screenshots.spec.ts b/desktop/tests/e2e/local-archive-screenshots.spec.ts index 3cf4a515826..183a4059f60 100644 --- a/desktop/tests/e2e/local-archive-screenshots.spec.ts +++ b/desktop/tests/e2e/local-archive-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -9,7 +9,7 @@ const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; // Navigate to the Local Archive settings panel. async function openLocalArchiveSettings(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await expect(page.getByTestId("settings-view")).toBeVisible(); diff --git a/desktop/tests/e2e/markdown-parse-cache.spec.ts b/desktop/tests/e2e/markdown-parse-cache.spec.ts index 6fdb1f43150..ec35a0517f7 100644 --- a/desktop/tests/e2e/markdown-parse-cache.spec.ts +++ b/desktop/tests/e2e/markdown-parse-cache.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -35,7 +35,7 @@ test("warm channel switches trigger zero fresh markdown parses", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_MD_PARSE_COUNT__ === "function", ); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 820b1f2ab14..c5c94f45411 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, @@ -276,7 +276,7 @@ test("@ trigger prioritizes channel members before runnable personas and other m }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -349,7 +349,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.evaluate( async ({ channelId, pubkey }) => { @@ -429,7 +429,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as test("relay-only shared agents emit an outbound mention tag when selected", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -479,7 +479,7 @@ test("thread autocomplete keeps multiple long names readable in a narrow panel", await page.addInitScript(() => { window.sessionStorage.setItem("buzz.desktop.thread-panel-width", "300"); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.setViewportSize({ width: 760, height: 640 }); @@ -528,7 +528,7 @@ test("blocks non-participant persona mentions in DM threads", async ({ await installMockBridge(page, { activePersonaIds: ["builtin:fizz"], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-bob-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); await waitForMockLiveSubscription(page, "bob-tyler"); @@ -588,7 +588,7 @@ test("defers agent mentions until DM members finish loading", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); await waitForMockLiveSubscription(page, "alice-tyler"); @@ -650,7 +650,7 @@ test("autocomplete filters managed-agent suggestions as user types", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -673,7 +673,7 @@ test("autocomplete searches global non-member people from the first typed charac }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -695,7 +695,7 @@ test("mention autocomplete caps global people search at 50 results", async ({ })); await installMockBridge(page, { searchProfiles }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -729,7 +729,7 @@ test("mention autocomplete caps global people search at 50 results", async ({ test("selecting a person mention inserts @Name into input", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -761,7 +761,7 @@ test("selecting a person mention inserts @Name into input", async ({ test("channel references keep caret movement through the channel name", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -804,7 +804,7 @@ test("selecting a managed agent mention inserts @Name into input", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -830,7 +830,7 @@ test("selecting a persona mention creates a channel agent before sending", async await installMockBridge(page, { activePersonaIds: ["builtin:fizz"], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -919,7 +919,7 @@ test("selecting a persona mention reuses an existing persona agent", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -983,7 +983,7 @@ test("managed relay-profile agents with member roles use the agent composer styl }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -1022,7 +1022,7 @@ test("other-owned agents without a shared channel are hidden from mentions", asy ], userSearchDelayMs: 1_000, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1038,7 +1038,7 @@ test("stale channel-member agents absent from managed and relay directories stay page, }) => { await installMockBridge(page, { userSearchDelayMs: 1_000 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1068,7 +1068,7 @@ test("managed relay agents are visible in channel mentions regardless of relay p }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1083,7 +1083,7 @@ test("managed relay agents are visible in channel mentions regardless of relay p test("relay-only shared agents stay hidden from DM mentions", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); @@ -1096,7 +1096,7 @@ test("cached relay-agent suggestions are removed when channel authorization disa page, }) => { await installMockBridge(page, { userSearchDelayMs: 10_000 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1143,7 +1143,7 @@ test("relay-only shared agents appear in forum mentions", async ({ page }) => { }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-watercooler").click(); await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); await page.getByRole("button", { name: "Start a new post..." }).click(); @@ -1180,7 +1180,7 @@ test("forum sends revalidate relay-agent authorization before signing", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-watercooler").click(); await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); await page.getByRole("button", { name: "Start a new post..." }).click(); @@ -1237,7 +1237,7 @@ test("relay-only allowlisted agents are visible in channel mentions", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1264,7 +1264,7 @@ test("relay-agent directory errors fail closed and recover after a fresh fetch", }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill("@quinn"); @@ -1302,7 +1302,7 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.evaluate( async ({ channelId, pubkey }) => { @@ -1367,7 +1367,7 @@ test("managed agents keep their p tag when relay discovery fails before send", a }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); @@ -1406,7 +1406,7 @@ test("targeted revocation before send causes no agent side effects", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.evaluate( async ({ channelId, pubkey }) => { @@ -1479,7 +1479,7 @@ test("selected relay agents revoked after the invite prompt cause no side effect }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill("@quinn"); @@ -1536,7 +1536,7 @@ test("selected relay agents revoked during send emit no p tag", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill("@quinn"); @@ -1590,7 +1590,7 @@ test("owner-only builds admit cross-owner relay agents authorized by allowlist", }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.evaluate( async ({ channelId, pubkey }) => { @@ -1646,7 +1646,7 @@ test("owner-only builds show verified same-owner relay agents", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill("@quinn"); @@ -1667,7 +1667,7 @@ test("relay-only allowlisted agents stay hidden outside their channel", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1699,7 +1699,7 @@ test("owner-only builds admit cross-owner relay agents authorized for anyone", a }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1722,7 +1722,7 @@ test("relay-only excluded agents stay hidden from channel mentions", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1746,7 +1746,7 @@ test("shared agents wait for initial directory authorization", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1771,7 +1771,7 @@ test("mentioning an in-channel stopped managed agent starts it before sending", }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1821,7 +1821,7 @@ test("mentioning an in-channel provider managed agent deploys it before sending" }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1865,7 +1865,7 @@ test("mentioning a non-member managed agent adds and starts it before sending", }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1926,7 +1926,7 @@ test("mentioning a non-member provider managed agent deploys it before sending", }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1987,7 +1987,7 @@ test("system add rows use plain names while remove rows retain agent mention sty }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND); @@ -2056,7 +2056,7 @@ test("groups contiguous arrival activity with hidden names in the standard toolt await installMockBridge(page, { searchProfiles: [actor, ...targets], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND); @@ -2131,7 +2131,7 @@ test("groups contiguous arrival activity with hidden names in the standard toolt }); test("system agent profile exposes owned agent actions", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND); @@ -2176,7 +2176,7 @@ test("system agent profile exposes owned agent actions", async ({ page }) => { }); test("system agent activity avatar stack is decorative", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND); @@ -2212,7 +2212,7 @@ test("system agent activity avatar stack is decorative", async ({ page }) => { test("membership activity folds a member joining then leaving", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND); @@ -2262,7 +2262,7 @@ test("profile-only agent author hides actions without agent access", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -2291,7 +2291,7 @@ test("profile-only agent author hides actions without agent access", async ({ test("system member-joined rows render the joined person as a plain profile name", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND); @@ -2334,7 +2334,7 @@ test("selecting a managed non-member agent from a DM inserts @Name into input", }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-bob-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); @@ -2354,7 +2354,7 @@ test("selecting a managed non-member agent from a DM inserts @Name into input", test("global non-member people can be selected from channel mentions", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2382,7 +2382,7 @@ test("duplicate global people with the same visible identity collapse in channel }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2396,7 +2396,7 @@ test("duplicate global people with the same visible identity collapse in channel test("sent non-member person mention uses the normal mention style", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-bob-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); @@ -2429,7 +2429,7 @@ test("sent managed non-member agent mention uses the agent mention style", async }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-bob-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); @@ -2454,7 +2454,7 @@ test("sent managed non-member agent mention uses the agent mention style", async test("mention button opens autocomplete and inserts a selected member", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2477,7 +2477,7 @@ test("inserting a mention preserves Shift+Enter newlines (regression: bug #2)", // break to a single space. After the fix, autocomplete uses a // native ProseMirror `tr.insertText` transaction and the line // breaks survive. - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2498,7 +2498,7 @@ test("inserting a mention preserves Shift+Enter newlines (regression: bug #2)", }); test("keyboard navigation selects mention with Enter", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2516,7 +2516,7 @@ test("keyboard navigation selects mention with Enter", async ({ page }) => { }); test("Escape dismisses autocomplete dropdown", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2534,7 +2534,7 @@ test("Escape dismisses autocomplete dropdown", async ({ page }) => { test("mention text is highlighted in sent messages", async ({ page }) => { const suffix = ` check this out ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2557,7 +2557,7 @@ test("mention text is highlighted in sent messages", async ({ page }) => { }); test("clicking author name opens user profile panel", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2577,7 +2577,7 @@ test("clicking author name opens user profile panel", async ({ page }) => { test("hovering avatar opens popover, clicking opens profile panel", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2600,7 +2600,7 @@ test("hovering avatar opens popover, clicking opens profile panel", async ({ test("clicking a mention chip in the timeline opens the profile panel", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -2628,7 +2628,7 @@ test("mention text matching the kind-0 name alias resolves and opens the profile // bob's mock profile has display_name "bob" and kind-0 name "bobby". A // message that says "@bobby" (how agents/CLI resolve mentions at send time) // must still render a clickable chip bound to bob's pubkey. - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -2653,7 +2653,7 @@ test("mention text matching the kind-0 name alias resolves and opens the profile test("clicking a mention chip in a forum post opens the profile panel", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Seed the forum post before entering the channel — forum views load from // the mock store on fetch, so no live subscription is needed. await page.getByTestId("channel-general").click(); @@ -2690,7 +2690,7 @@ test("agent profile popover shows its owner", async ({ page }) => { }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -2730,7 +2730,7 @@ test("agent profile popover labels an agent owned by the viewer as you", async ( }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -2770,7 +2770,7 @@ test("agent profile popover falls back to the owner's pubkey", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -2798,7 +2798,7 @@ test("agent profile popover falls back to the owner's pubkey", async ({ }); test("human profile popover does not show an owner", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -2835,7 +2835,7 @@ test("owned bot profile exposes message and huddle actions", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -2883,7 +2883,7 @@ test("owned agent mention profile exposes message and huddle actions", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -2915,7 +2915,7 @@ test("profile popover wave sends a direct message for a human profile", async ({ }) => { await installMockBridge(page, { sendMessageDelayMs: 2_500 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -3009,7 +3009,7 @@ test("delayed inaccessible agent profile keeps all actions hidden", async ({ ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index c55b9db5d95..e54998cd34e 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; @@ -18,7 +18,7 @@ type E2eWindow = Window & { test("Share compute chooses a model before sharing", async ({ page }) => { const modelRef = "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M"; await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "compute"); const card = page.getByTestId("settings-mesh-share-compute"); @@ -102,7 +102,7 @@ test("a consuming client can switch to sharing its saved local model", async ({ window.localStorage.setItem("buzz.mesh-compute.share.model.v1", model); }, localModel); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // The mesh seed hook is installed when the mock bridge boots; calling it // before then silently no-ops (optional chaining) and the seed is lost. await page.waitForFunction( diff --git a/desktop/tests/e2e/message-feedback-snapshots.spec.ts b/desktop/tests/e2e/message-feedback-snapshots.spec.ts index 18776d7f256..b5a93d311b1 100644 --- a/desktop/tests/e2e/message-feedback-snapshots.spec.ts +++ b/desktop/tests/e2e/message-feedback-snapshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -31,7 +31,7 @@ test("pending continuation keeps Sending next to its timestamp", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -96,7 +96,7 @@ test("pending continuation keeps Sending next to its timestamp", async ({ test("profile hover uses the channel hover surface", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const profile = page.getByTestId("sidebar-profile-card"); const channel = page.getByTestId("channel-random"); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 9a93086cf87..4474107eea3 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; -import { expect, test, type Locator } from "@playwright/test"; +import { expect, test, type Locator, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -379,7 +379,7 @@ test.beforeEach(async ({ page }, testInfo) => { }); test("agent owner label identifies the agent and owner", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const aliceMessage = page @@ -401,7 +401,7 @@ test("agent owner label identifies the agent and owner", async ({ page }) => { test("send a message and see it in timeline", async ({ page }) => { const message = `Hello timeline ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -417,7 +417,7 @@ test("send a message and see it in timeline", async ({ page }) => { test("long autolink wraps without widening the timeline", async ({ page }) => { await page.setViewportSize({ width: 800, height: 600 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -458,7 +458,7 @@ test("markdown tables overflow wide content and fill the message when narrow", a page, }) => { await page.setViewportSize({ width: 900, height: 600 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.waitForFunction( @@ -531,7 +531,7 @@ test("sent link preview media uses the authenticated proxy in compact and rich c }), ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill(previewUrl); await waitForReadyComposerSnapshots(page); @@ -599,7 +599,7 @@ test("link preview style defaults to compact and Rich unfurls descriptions", asy }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?inline=1"; await page.setViewportSize({ width: 800, height: 900 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill(previewUrl); const composerPreview = page @@ -713,7 +713,7 @@ for (const [pasteShape, wrapUrl] of [ page, }) => { const previewUrl = `https://github.com/block/buzz/pull/3246?paste=${pasteShape}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.focus(); @@ -759,7 +759,7 @@ test("display-text link preview produces and sends its preview", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?display=text"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); @@ -801,7 +801,7 @@ test("rich link preview preserves description newlines after sending", async ({ await page.addInitScript(() => localStorage.setItem("buzz.appearance.linkPreviewStyle", "rich"), ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill(previewUrl); await waitForReadyComposerSnapshots(page); @@ -835,7 +835,7 @@ test("completed link previews normalize a trailing-fragment URL and still send", "https://x.com/tellaho/status/1884289176381841506", ]; const pastedText = previewUrls.join("\n"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.focus(); @@ -881,7 +881,7 @@ test("unresolvable preview disappears after the terminal miss", async ({ page, }) => { const previewUrl = "https://x.com/tellaho/status"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill(previewUrl); @@ -902,7 +902,7 @@ test("explicit cancellation suppresses a pending link preview and sends without page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?send=pending"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill(previewUrl); @@ -976,7 +976,7 @@ test("Enter during an in-flight snapshot upload hands off and sends once", async page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill(previewUrl); @@ -1016,7 +1016,7 @@ test("async metadata beyond old cutoff still produces preview image", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?slow=metadata"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill(previewUrl); @@ -1039,7 +1039,7 @@ test("async upload beyond metadata budget retains preview image", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?slow=image"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill(previewUrl); @@ -1070,7 +1070,7 @@ test("Skip wins the upload race and sends without preview", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?skip=1"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill(previewUrl); @@ -1102,7 +1102,7 @@ test("promoted link preview send clears Sending after REST publication", async ( page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?pending=preview"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill(previewUrl); @@ -1129,7 +1129,7 @@ test("settled-empty promoted link preview send uses REST and clears Sending afte page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?pending=empty"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill(previewUrl); @@ -1202,7 +1202,7 @@ test("draft auto-send promotes link preview preparation and sends exactly once", // Drive the real Drafts-panel "Send message" confirm flow. This does an // in-app client navigation to the channel with ?autoSend=, arming // the main composer's auto-submit effect — the exact production path. - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("inbox-filter-trigger").click(); await page.getByRole("menuitemradio", { name: "Drafts" }).click(); @@ -1245,7 +1245,7 @@ test("rapid Enter presses on a ready link preview send exactly once", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?rapid=1"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill(previewUrl); @@ -1282,7 +1282,7 @@ test("pasting a link and immediately pressing Enter prepares it after submit", a page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?fast=send"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); @@ -1306,7 +1306,7 @@ test("a snapshot media upload failure preserves a metadata-only preview", async page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?upload=fail"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); await input.fill(previewUrl); @@ -1337,7 +1337,7 @@ test("a snapshot media upload failure preserves a metadata-only preview", async test("editing a message excludes link previews entirely", async ({ page }) => { const message = `Edit-me ${Date.now()}`; const previewUrl = "https://github.com/block/buzz/pull/3246?edit=1"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const input = page.getByTestId("message-input"); @@ -1377,7 +1377,7 @@ test("hiding composer link previews suppresses the whole draft and emits the bla }) => { const firstUrl = "https://github.com/block/buzz/pull/3246?hide=all"; const secondUrl = "https://linear.app/acme/issue/ABC-123/hidden-too"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill(firstUrl); await expect(page.locator("[data-composer-link-previews]")).toBeVisible(); @@ -1414,7 +1414,7 @@ test("composer link preview embeds stay attachment-sized while loading and ready for (const width of [800, 420]) { await page.setViewportSize({ width: 800, height: 700 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.setViewportSize({ width, height: 700 }); await page @@ -1486,7 +1486,7 @@ test("compact link preview image geometry truncates long titles to one line", as }), ); await page.setViewportSize({ width: 800, height: 700 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill(previewUrl); await waitForReadyComposerSnapshots(page); @@ -1525,7 +1525,7 @@ test("composer no-image link embeds keep the attachment footprint", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill(previewUrl); const card = page @@ -1544,7 +1544,7 @@ test("mixed link preview image outcomes keep Compact and Rich fallbacks stable", }) => { const loadedUrl = "https://github.com/block/buzz/pull/4001"; const rateLimitedUrl = "https://github.com/block/buzz/pull/4002"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page .getByTestId("message-input") @@ -1596,7 +1596,7 @@ test("fragment link previews render a card per canonical URL", async ({ "https://github.com/block/buzz/pull/3767#pullrequestreview-4857569498"; const fragmentUrlB = "https://github.com/block/buzz/pull/3767#issuecomment-1"; const plainUrl = "https://github.com/block/buzz/pull/3867"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page .getByTestId("message-input") @@ -1625,7 +1625,7 @@ test("fragment link previews render a card per canonical URL", async ({ test("link preview browser image errors render a fallback", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page .getByTestId("message-input") @@ -1661,7 +1661,7 @@ test("supported Compact link previews keep the message link visible with square }) => { const previewUrl = "https://github.com/block/sprout/pull/1334"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1688,7 +1688,7 @@ test("send multiple messages in sequence", async ({ page }) => { const input = page.getByTestId("message-input"); const sendButton = page.getByTestId("send-message"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1713,7 +1713,7 @@ test("copy a rendered code block and paste it back as code", async ({ const code = "# not a heading\nconst answer = 42;\n indented();"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1759,7 +1759,7 @@ test("pasting a long copied code block scrolls composer to cursor", async ({ (_, index) => `const line${index} = ${index};`, ).join("\n"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1798,7 +1798,7 @@ test("code block shows language label when language is specified", async ({ origin: "http://127.0.0.1:4173", }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1820,7 +1820,7 @@ test("code block shows language label when language is specified", async ({ test("typing triple backticks and Enter creates a code block in composer", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1841,7 +1841,7 @@ test("message input clears after send", async ({ page }) => { const message = `Clear after send ${Date.now()}`; const input = page.getByTestId("message-input"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1856,7 +1856,7 @@ test("message input clears after send", async ({ page }) => { test("emoji picker inserts emoji into the draft and keeps focus in the composer", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1880,7 +1880,7 @@ test("emoji picker inserts emoji into the draft and keeps focus in the composer" }); test("empty message cannot be sent", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1892,7 +1892,7 @@ test("send message with Enter key", async ({ page }) => { const message = `Enter key send ${Date.now()}`; const input = page.getByTestId("message-input"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1905,7 +1905,7 @@ test("send message with Enter key", async ({ page }) => { test("messages persist across channel switches", async ({ page }) => { const message = `Persist across switch ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1925,7 +1925,7 @@ test("draft is preserved when switching channels", async ({ page }) => { const draft = `Unsent draft ${Date.now()}`; const input = page.getByTestId("message-input"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1948,7 +1948,7 @@ test("sending a message clears the draft", async ({ page }) => { const message = `Sent message ${Date.now()}`; const input = page.getByTestId("message-input"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1970,7 +1970,7 @@ test("different channels have independent messages", async ({ page }) => { const generalMessage = `General only ${ts}`; const randomMessage = `Random only ${ts}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-input").fill(generalMessage); @@ -2002,7 +2002,7 @@ test("different channels have independent messages", async ({ page }) => { }); test("day divider appears in timeline", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2021,7 +2021,7 @@ test("day divider appears in timeline", async ({ page }) => { test("send message to DM channel p-tags the recipient", async ({ page }) => { const message = `DM message ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); @@ -2093,7 +2093,7 @@ test("sends a thread message to its parent channel with a root-thread link", asy }), ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.waitForFunction( @@ -2333,7 +2333,7 @@ test("shows your avatar on your own message when profile avatar is set", async ( const avatarUrl = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E'; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); await page.getByTestId("profile-avatar-url").fill(avatarUrl); @@ -2367,7 +2367,7 @@ test("opens a single-level thread panel with inline expansion", async ({ (_, index) => `Thread filler reply ${index} ${timestamp}`, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( @@ -2651,7 +2651,7 @@ test("thread panel width uses session storage and reset handle", async ({ ); }, customWidthPx); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2708,7 +2708,7 @@ test("narrow thread view collapses channel header actions into a menu", async ({ }) => { await page.setViewportSize({ width: 980, height: 720 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0); @@ -2753,7 +2753,7 @@ test("narrow thread view collapses channel header actions into a menu", async ({ test("single-panel thread view hides channel actions", async ({ page }) => { await page.setViewportSize({ width: 860, height: 720 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0); @@ -2774,7 +2774,7 @@ test("single-panel thread view hides channel actions", async ({ page }) => { }); test("composer is focused after selecting a channel", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2789,7 +2789,7 @@ test("composer is focused after selecting a channel", async ({ page }) => { test("composer is focused after switching to a different channel", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2803,7 +2803,7 @@ test("composer is focused after switching to a different channel", async ({ test("thread composer is focused after clicking the reply icon", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2835,7 +2835,7 @@ test("thread refetch preserves a live reply and reaction received in flight", as page, }) => { await installMockBridge(page, { threadRepliesDelayMs: 800 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2892,7 +2892,7 @@ test("thread reply appears after relay closes and restores its live subscription page, }) => { await installMockBridge(page, { closeChannelLiveSubscriptionOnce: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2920,7 +2920,7 @@ test("thread reply appears after relay closes and restores its live subscription test("thread composer keeps focus after sending a thread reply", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2980,7 +2980,7 @@ test("ArrowUp in an empty composer edits your last message right after sending", const message = `Edit-last via arrow up ${Date.now()}`; const input = page.getByTestId("message-input"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -3009,7 +3009,7 @@ test("ArrowUp does not edit when the composer has draft text", async ({ const draft = `Half-typed draft ${Date.now()}`; const input = page.getByTestId("message-input"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -3033,7 +3033,7 @@ test("ArrowUp edits your last thread reply right after sending it", async ({ const seed = `Thread arrow-up seed ${Date.now()}`; const reply = `Thread reply to edit ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -3075,7 +3075,7 @@ test("action bar stays within the timeline when the thread panel is open", async // or the right-anchored action bar is pushed offscreen (regression: #1081 // fixed the wrap but rows still expanded to content min-width). await page.setViewportSize({ width: 1024, height: 800 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/mobile-pairing-qr.spec.ts b/desktop/tests/e2e/mobile-pairing-qr.spec.ts index 731cce17e56..a2743fee516 100644 --- a/desktop/tests/e2e/mobile-pairing-qr.spec.ts +++ b/desktop/tests/e2e/mobile-pairing-qr.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { mkdirSync } from "node:fs"; import { installMockBridge } from "../helpers/bridge"; @@ -38,7 +38,7 @@ async function emitPairingEvent(page: Page, event: string, payload?: unknown) { test("mobile pairing starts on demand and reveals the QR code", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await page.getByTestId("settings-nav-mobile").click(); @@ -231,7 +231,7 @@ test("mobile pairing starts on demand and reveals the QR code", async ({ test("pairing completion updates the final step and resets after leaving", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await page.getByTestId("settings-nav-mobile").click(); @@ -397,7 +397,7 @@ test("pairing completion updates the final step and resets after leaving", async }); test("late pairing events are ignored after canceling", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await page.getByTestId("settings-nav-mobile").click(); @@ -432,7 +432,7 @@ test("late pairing events are ignored after canceling", async ({ page }) => { test("step completion respects reduced motion", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await page.getByTestId("settings-nav-mobile").click(); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 4de2bb12d52..d0c2995754c 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; @@ -13,7 +13,7 @@ test.beforeEach(async ({ page }) => { }); async function navigateToWorkflows(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-workflows-view").click(); await expect(page).toHaveURL(/#\/workflows$/); await expect(page.getByTestId("workflows-view")).toBeVisible(); @@ -45,7 +45,7 @@ async function createWorkflow( } test("global back and forward move across channel routes", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -67,7 +67,7 @@ test("back/forward keyboard chords work while the composer has focus", async ({ const forwardChord = process.platform === "darwin" ? "Meta+]" : "Alt+ArrowRight"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -98,7 +98,8 @@ test("back/forward keyboard chords work while the composer has focus", async ({ test.fixme("direct forum thread links close back to the forum route", async ({ page, }) => { - await page.goto( + await bootstrapE2ePage( + page, `/#/channels/${WATERCOLOR_CHANNEL_ID}/posts/${FORUM_POST_ID}`, ); @@ -134,7 +135,7 @@ test("direct workflow detail links close back to workflows", async ({ expect(workflowId).toBeTruthy(); - await page.goto(`/#/workflows/${workflowId}`); + await bootstrapE2ePage(page, `/#/workflows/${workflowId}`); const dialog = page.getByRole("dialog", { name: "Edit workflow" }); await expect(dialog.getByText(workflowName, { exact: true })).toBeVisible(); @@ -151,7 +152,8 @@ test("direct workflow detail links close back to workflows", async ({ }); test("forum reply deep links survive reload", async ({ page }) => { - await page.goto( + await bootstrapE2ePage( + page, `/#/channels/${WATERCOLOR_CHANNEL_ID}/posts/${FORUM_POST_ID}?replyId=${FORUM_REPLY_ID}`, ); @@ -169,7 +171,7 @@ test("forum reply deep links survive reload", async ({ page }) => { }); test("back and forward restore open thread panels", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -199,7 +201,7 @@ test("back and forward restore open thread panels", async ({ page }) => { }); test("back undoes closing a thread panel", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -222,7 +224,7 @@ test("back undoes closing a thread panel", async ({ page }) => { }); test("open thread panels survive reload", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -247,7 +249,7 @@ test("open thread panels survive reload", async ({ page }) => { test("home inbox selection survives reload and back restores it", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const inboxList = page.getByTestId("home-inbox-list"); await expect(inboxList).toBeVisible(); @@ -284,7 +286,7 @@ test("home inbox selection survives reload and back restores it", async ({ test("settings is a route: section survives reload, closing returns to the previous panel state", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Open a channel with a thread panel so there's panel state to come back to. await page.getByTestId("channel-general").click(); @@ -319,7 +321,7 @@ test("settings is a route: section survives reload, closing returns to the previ test("settings shortcut returns without opening search dialog", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); const channelUrl = page.url(); @@ -359,7 +361,7 @@ test("settings shortcut returns without opening search dialog", async ({ test("mixed Buzz permalinks render as chips in the composer", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -414,7 +416,7 @@ test("mixed Buzz permalinks render as chips in the composer", async ({ test("message links to visible root messages open the thread panel", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( @@ -503,7 +505,8 @@ test("message links to visible root messages open the thread panel", async ({ test("message links reopen a closed thread when the same messageId is already in the URL", async ({ page, }) => { - await page.goto( + await bootstrapE2ePage( + page, "/#/channels/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50?messageId=mock-general-welcome", ); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -542,7 +545,8 @@ test("message links reopen a closed thread when the same messageId is already in }); test("message deep links survive reload", async ({ page }) => { - await page.goto( + await bootstrapE2ePage( + page, `/#/channels/${ENGINEERING_CHANNEL_ID}?messageId=mock-engineering-shipped`, ); @@ -574,7 +578,7 @@ test("cold-start channel deep link drains after the router mounts", async ({ ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); await expect(page).toHaveURL( @@ -612,7 +616,7 @@ test("cold-start message deep link preserves its thread target", async ({ ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); await expect(page).toHaveURL(/messageId=mock-forum-release-reply/); diff --git a/desktop/tests/e2e/needs-restart-screenshots.spec.ts b/desktop/tests/e2e/needs-restart-screenshots.spec.ts index 508a5d91e93..08a16475f8d 100644 --- a/desktop/tests/e2e/needs-restart-screenshots.spec.ts +++ b/desktop/tests/e2e/needs-restart-screenshots.spec.ts @@ -11,7 +11,7 @@ * - Generic rendering: unknown field ids, number/array values, masked values. */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -119,7 +119,7 @@ const START_AGENT = { }; async function gotoAgentsView(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("open-agents-view")).toBeVisible({ timeout: 10_000, }); diff --git a/desktop/tests/e2e/nostr-bind.spec.ts b/desktop/tests/e2e/nostr-bind.spec.ts index 17677ea46e1..26ff38aea2a 100644 --- a/desktop/tests/e2e/nostr-bind.spec.ts +++ b/desktop/tests/e2e/nostr-bind.spec.ts @@ -1,4 +1,10 @@ -import { expect, test, type Locator, type Page } from "@playwright/test"; +import { + expect, + test, + type Locator, + type Page, + bootstrapE2ePage, +} from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; type NostrBindPayload = { @@ -56,7 +62,7 @@ async function openNostrBind( mock?: Parameters[1], ) { await installMockBridge(page, mock); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof ( diff --git a/desktop/tests/e2e/observer-archive-policy.spec.ts b/desktop/tests/e2e/observer-archive-policy.spec.ts index ff4bbaa1503..d82ef69c7d3 100644 --- a/desktop/tests/e2e/observer-archive-policy.spec.ts +++ b/desktop/tests/e2e/observer-archive-policy.spec.ts @@ -1,9 +1,9 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; async function openLocalArchiveSettings(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-settings").click(); await page.getByTestId("profile-popover-settings").click(); await expect(page.getByTestId("settings-view")).toBeVisible(); @@ -153,7 +153,7 @@ test.describe("observer archive policy — reconciliation gate", () => { ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); // Wait for the channel list (proves AppShell mounted fully). await expect(page.getByTestId("channel-general")).toBeVisible({ @@ -232,7 +232,7 @@ test.describe("observer archive policy — reconciliation gate", () => { saveSubscriptions: [], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("channel-general")).toBeVisible({ timeout: 10_000, }); diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts index 44ff609c9ee..993081dd167 100644 --- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts +++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -34,7 +34,7 @@ async function openObserverFeedPanel( page: import("@playwright/test").Page, agentPubkey: string, ) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForSeedHook(page); await page.getByTestId("channel-agents").click(); diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 3a15d21767d..ff3f77174af 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { passThroughBackupStep } from "../helpers/onboarding"; @@ -88,7 +88,7 @@ test("setup shows all bundled harnesses as detected", async ({ page }) => { }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await expect(page.getByTestId("onboarding-runtime-claude")).toBeVisible(); @@ -134,7 +134,7 @@ test("setup distinguishes a missing CLI from an installed desktop app", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); const card = page.getByTestId("onboarding-runtime-codex"); @@ -157,7 +157,7 @@ test("ready state is detected and enables Next without persisting a default", as }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveText( @@ -186,7 +186,7 @@ test("setup shows runtime discovery loading before rendering harnesses", async ( }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await expect(page.getByTestId("onboarding-runtime-loading")).toBeVisible(); @@ -202,7 +202,7 @@ test("unknown authentication can be checked again", async ({ page }) => { { acpRuntimesCatalogSequence: [[unknown], [loggedIn]] }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); const checkAgain = page.getByRole("button", { @@ -228,7 +228,7 @@ test("auth discovery failure stays actionable without exposing internals", async }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); const card = page.getByTestId("onboarding-runtime-claude"); @@ -264,7 +264,7 @@ test("terminal launch failure keeps Sign in available", async ({ page }) => { }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); const card = page.getByTestId("onboarding-runtime-claude"); @@ -301,7 +301,7 @@ test("sign in stays pending until catalog detection confirms Ready", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); const signIn = page.getByRole("button", { name: "Sign in to Claude Code" }); @@ -361,7 +361,7 @@ test("failed install can be retried without shifting card content", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); const card = page.getByTestId("onboarding-runtime-claude"); @@ -411,7 +411,7 @@ test("install transitions through Sign in to Ready", async ({ page }) => { }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); const install = page.getByTestId("onboarding-runtime-install-claude"); @@ -447,7 +447,7 @@ test("defaults waits for baked configuration before rendering fields", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); @@ -475,7 +475,7 @@ test("defaults renders only fields supported by the selected harness", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); @@ -513,7 +513,7 @@ test("defaults hides model when optional harness has empty discovery", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); @@ -546,7 +546,7 @@ test("defaults keeps model control when optional harness discovery fails", async }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); @@ -580,7 +580,7 @@ test("defaults can be skipped while loading without persisting configuration", a }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); @@ -609,7 +609,7 @@ test("defaults stages auto-selection and edits without writing when skipped", as }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); @@ -657,7 +657,7 @@ test("Back preserves incomplete defaults draft without writing", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); await expect( @@ -720,7 +720,7 @@ test("defaults auto-selects the only ready visible harness", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); @@ -750,7 +750,7 @@ test("Next persists the latest staged harness choice", async ({ page }) => { }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); @@ -787,7 +787,7 @@ test("Next shows saving state and advances only after persistence", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); @@ -825,7 +825,7 @@ test("Next keeps the draft and retries after a save failure", async ({ }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("global-agent-default-harness")).toHaveText( @@ -869,7 +869,7 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); @@ -990,7 +990,7 @@ test("concurrent installs each keep their own state — one fails, one succeeds" }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); const claudeInstall = page.getByTestId("onboarding-runtime-install-claude"); @@ -1085,7 +1085,7 @@ test("Finish stays disabled until a provider-required harness is fully configure }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); @@ -1136,7 +1136,7 @@ test("baked build config keeps Finish enabled without manual provider setup", as }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); diff --git a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts index 38870199cf3..4ba8732122e 100644 --- a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts +++ b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -17,7 +17,7 @@ test("avatar step always shows Skip for now button without an error", async ({ }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -44,7 +44,7 @@ test("avatar step shares the profile emoji picker controls", async ({ }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -80,7 +80,7 @@ test("avatar step skip button completes community profile setup", async ({ }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -96,7 +96,7 @@ test("avatar Next button still requires an avatar to be chosen", async ({ }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -120,7 +120,7 @@ test("avatar Next button still requires an avatar to be chosen", async ({ test("normal profile setup keeps the existing identity", async ({ page }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("onboarding-page-1")).toBeVisible(); await expect(page.getByTestId("onboarding-import-key")).toHaveCount(0); @@ -137,7 +137,7 @@ test("Back from the community avatar step returns to profile", async ({ }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index 4b9040bd2b9..46d2b28acf7 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; import { @@ -12,7 +12,7 @@ async function enterMachineBackup(page: import("@playwright/test").Page) { skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Create a new identity key" }).click(); } @@ -358,7 +358,7 @@ test("reveal shows inline error when get_nsec fails and Next still advances", as { nsecError: "Keychain locked" }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Create a new identity key" }).click(); await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); @@ -379,7 +379,7 @@ test("reveal retry succeeds after initial failure", async ({ page }) => { { nsecErrors: ["Keychain locked", null] }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Create a new identity key" }).click(); await page.getByTestId("backup-key-reveal-toggle").click(); diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index 1a426438ea9..bcf1ee65cfe 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -22,7 +22,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const gate = page.getByTestId("machine-onboarding-gate"); await expect(gate).toBeVisible(); @@ -141,7 +141,7 @@ test("machine key import remains usable in a short viewport", async ({ skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Use an existing key" }).click(); const heading = page.getByRole("heading", { name: "Enter your private key" }); @@ -183,7 +183,7 @@ test("backup options keep one-column geometry on narrow windows", async ({ skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Create a new identity key" }).click(); await expect( page.getByRole("heading", { @@ -212,7 +212,7 @@ test("backup options keep one-column geometry on narrow windows", async ({ test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("onboarding-page-1")).toBeVisible(); await page.getByTestId("onboarding-display-name").fill("Ada Lovelace"); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index b085e7dab69..29ad6385630 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1,5 +1,5 @@ import { hexToBytes } from "@noble/hashes/utils.js"; -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { nsecEncode } from "nostr-tools/nip19"; import { @@ -694,7 +694,7 @@ test("completed users skip the loading gate while profile is still settling", as await installMockBridge(page, { profileReadDelayMs: 3_000, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); @@ -707,7 +707,7 @@ test("fresh existing-identity path leads with private-key recovery", async ({ skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Use an existing key" }).click(); await expect( @@ -798,7 +798,7 @@ test("first-launch key import continues to machine setup", async ({ page }) => { skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Use an existing key" }).click(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); @@ -821,7 +821,7 @@ test("key import locks host navigation and ignores rapid duplicate submits", asy skipOnboardingSeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Use an existing key" }).click(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); @@ -845,7 +845,7 @@ test("imported-key users can skip out of harness setup", async ({ page }) => { skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Use an existing key" }).click(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); @@ -869,7 +869,7 @@ test("first-launch encrypted backup import asks for a passphrase and continues", skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Use an existing key" }).click(); // Spec-vector blob the mock bridge accepts with the mock passphrase. @@ -923,7 +923,7 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ skipCommunitySeed: true, skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: "Use an existing key" }).click(); @@ -1046,7 +1046,7 @@ test("non-local runtime override keeps community selection without release flag" skipOnboardingSeed: true, skipCommunitySeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect( page.getByRole("button", { name: /Join a community/ }), @@ -1072,7 +1072,7 @@ test("non-local default auto-connects when the release flag is enabled", async ( skipOnboardingSeed: true, skipCommunitySeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectIncompleteOnboarding(page); await expect @@ -1112,7 +1112,7 @@ test("first-community choices route join, create, owner, and member intents", as skipOnboardingSeed: true, skipCommunitySeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect( page.getByRole("button", { name: /Join a community/ }), @@ -1199,7 +1199,7 @@ test("first-community owner can connect an existing hosted community", async ({ skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-choice-create").click(); await expect(page.getByText("North Star")).toBeVisible(); @@ -1257,7 +1257,7 @@ test("first-community owner can create and connect a hosted community", async ({ skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); @@ -1333,7 +1333,7 @@ test("hosted community address line stays within the card for a long name", asyn ); // The 800px app minimum is the worst case for the full-width address line. await page.setViewportSize({ width: 800, height: 720 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); @@ -1402,7 +1402,7 @@ test("first-community reports a created community without a relay address", asyn skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("textbox", { name: "Community name" }).fill("bee-lab"); @@ -1433,7 +1433,7 @@ test("first-community X cancels a pending sign-in", async ({ page }) => { skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); @@ -1475,7 +1475,7 @@ test("first-community owner can replace a mismatched account identity", async ({ skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-choice-create").click(); await expect( @@ -1525,7 +1525,7 @@ test("first-community explains when the local identity belongs to another accoun skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-choice-create").click(); await page @@ -1566,7 +1566,7 @@ test("back clears Builderlab auth before returning to first-community choices", skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("button", { name: "Back" }).click(); @@ -1613,7 +1613,7 @@ test("first-community shows the scenario cards for localhost", async ({ skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect( page.getByRole("button", { name: "Join default community" }), @@ -1673,7 +1673,7 @@ test("first-community direct join reaches profile", async ({ page }) => { skipOnboardingSeed: true, skipCommunitySeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: /Join a community/ }).click(); await page @@ -1748,7 +1748,7 @@ test("community onboarding reuses an existing relay profile", async ({ skipOnboardingSeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect .poll(() => @@ -1803,7 +1803,7 @@ test("first-community direct join cancel returns to request access", async ({ skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByRole("button", { name: /Join a community/ }).click(); await page @@ -1878,7 +1878,7 @@ test("canceling a join to an existing inactive community preserves it", async ({ skipCommunitySeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate((transactionStorageKey) => { const timestamp = new Date().toISOString(); @@ -1981,7 +1981,7 @@ test("connected first-community profile keeps Back bottom-left and balances the skipOnboardingSeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("community-onboarding-flow")).toBeVisible(); await expect( @@ -2361,7 +2361,7 @@ test("name-only community profile save preserves an existing avatar", async ({ relayWsUrl: "wss://default.example.com", skipOnboardingSeed: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const existingAvatarUrl = "https://mock.relay/media/existing-community-avatar.png"; @@ -2428,7 +2428,7 @@ test("pending avatar stays navigable, clears failures, and retries", async ({ skipOnboardingSeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-profile-name-key").fill("Tyler"); await uploadCommunityAvatar(page, "pending-community-avatar.png"); @@ -2554,7 +2554,7 @@ test("a pending avatar never becomes durable if propagation fails after onboardi skipOnboardingSeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-profile-name-key").fill("Tyler"); await uploadCommunityAvatar(page, "saved-pending-community-avatar.png"); @@ -2626,7 +2626,7 @@ test("a pending avatar becomes durable after onboarding unmounts once ready", as skipOnboardingSeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-profile-name-key").fill("Tyler"); await uploadCommunityAvatar(page, "ready-after-unmount-community-avatar.png"); @@ -2700,7 +2700,7 @@ test("a failed pending replacement leaves the confirmed avatar untouched", async skipOnboardingSeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await seedCurrentAvatar(page, existingAvatarUrl); await page.getByTestId("community-profile-name-key").fill("Tyler"); @@ -2768,7 +2768,7 @@ test("replacing a pending upload disposes its verifier and local preview", async skipOnboardingSeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-profile-name-key").fill("Tyler"); await uploadCommunityAvatar(page, "superseded-community-avatar.png"); @@ -2840,7 +2840,7 @@ test("membership denial on community profile save offers recovery", async ({ skipOnboardingSeed: true, }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("community-profile-name-key").fill("Kalvin"); await page.getByTestId("community-profile-next").click(); @@ -2872,7 +2872,7 @@ test("identity fallback text does not count as a real onboarding name", async ({ }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectIncompleteOnboarding(page); await expect(page.getByTestId("onboarding-next")).toBeDisabled(); @@ -2889,7 +2889,7 @@ test("first-run blank identity with no profile event sees onboarding", async ({ // Do NOT seed searchProfiles for tyler's pubkey, so ensureMockProfile // constructs a synthesised profile with has_profile_event: false. await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectIncompleteOnboarding(page); }); @@ -2916,7 +2916,7 @@ test("returning user with blank display name and real profile event skips onboar }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Profile event exists → onboarding is skipped, app renders. await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); @@ -2954,7 +2954,7 @@ test("no-event profile cached then reloaded still sees onboarding", async ({ ); // No profile event on the relay either — ensureMockProfile uses false. await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Cache seed must NOT promote hasProfileEvent to true. Onboarding shows. await expectIncompleteOnboarding(page); @@ -2965,7 +2965,7 @@ test("avatar step uses an add-image placeholder before an avatar is chosen", asy }) => { await seedActiveIdentity(page, BLANK_AVATAR_PLACEHOLDER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -2982,7 +2982,7 @@ test("avatar step reveals preset backgrounds after the first emoji pick", async }) => { await seedActiveIdentity(page, BLANK_AVATAR_EMOJI_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3008,7 +3008,7 @@ test("avatar step accepts an avatar URL before completing onboarding", async ({ }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3033,7 +3033,7 @@ test("failed avatar saves can continue without saving the avatar", async ({ }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, {}, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3088,7 +3088,7 @@ test("avatar upload rejects a file whose server-detected MIME is not an image", }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3125,7 +3125,7 @@ test("avatar upload accepts a file whose server-detected MIME is an image", asyn }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3148,7 +3148,7 @@ test("first-run onboarding keeps the shell hidden and lands on private Welcome a }) => { await seedActiveIdentity(page, FIRST_RUN_ALICE); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("onboarding-gate")).toBeVisible(); await expect(page.getByTestId("onboarding-page-1")).toBeVisible(); @@ -3182,7 +3182,7 @@ test("failed public starter channel setup does not show a retry toast", async ({ { ensureStarterChannelsErrors: [starterError, starterError] }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); @@ -3209,7 +3209,7 @@ test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => { }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); @@ -3234,7 +3234,7 @@ test("first-run onboarding lands before Welcome team bootstrap completes", async { createManagedAgentDelayMs: 1_000 }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); @@ -3268,7 +3268,7 @@ test("existing relay profile with display name auto-skips onboarding without loc }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); @@ -3281,7 +3281,7 @@ test("onboarding uses the existing identity when the community is already set up // this identity. Profile setup must not offer to create or replace it. await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("onboarding-display-name")).toHaveValue(""); await expect(page.getByTestId("onboarding-next")).toHaveText("Continue"); @@ -3295,7 +3295,7 @@ test("completed onboarding backfills missing starter channels", async ({ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await seedOnboardingCompletion(page, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); @@ -3310,7 +3310,7 @@ test("finishing onboarding creates starter channels and focuses welcome-everyone }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); @@ -3327,7 +3327,7 @@ test("welcome-everywhere banner: X dismiss removes the guidance surface", async }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); @@ -3353,7 +3353,7 @@ test("welcome-everywhere banner: dismiss persists after channel re-entry", async }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); @@ -3388,7 +3388,7 @@ test("initial profile read failures still hold incomplete users in onboarding", }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectIncompleteOnboarding(page); }); @@ -3404,7 +3404,7 @@ test("failed first profile saves can be skipped for the current session", async }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("onboarding-gate")).toBeVisible(); await expect(page.getByTestId("onboarding-display-name")).toHaveValue(""); @@ -3430,7 +3430,7 @@ test("generic relay save failures use the generic reconnect card", async ({ }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3458,7 +3458,7 @@ test("custom relay proxy sign-in failures use the generic reconnect card", async }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3482,7 +3482,7 @@ test("community access failures use the generic reconnect card", async ({ }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3505,7 +3505,7 @@ test("dismissed relay save failures reappear on retry", async ({ page }) => { }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3546,7 +3546,7 @@ test("existing relay profile with display name auto-completes onboarding", async }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); @@ -3564,7 +3564,7 @@ test("open relay skips membership gating during onboarding", async ({ }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3585,7 +3585,7 @@ test("membership denial can import a different invited key", async ({ }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3664,7 +3664,7 @@ test("same-relay identity replacement rebuilds the community boundary (A→B→A }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Seed a draft in tyler's identity-scoped bucket after boot, as if typed in // an earlier session. Written directly to localStorage (not through the @@ -3805,7 +3805,7 @@ test("same-relay identity replacement rebuilds the community boundary (A→B→A // reach general through the channel browser. await seedActiveIdentity(page, TEST_IDENTITIES.tyler); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -3830,7 +3830,7 @@ test("onboarding relay reconnect — click shows Connected then auto-dismisses", }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3875,7 +3875,7 @@ test("onboarding relay reconnect — connected without a prior click does not sh }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); @@ -3910,7 +3910,7 @@ test("membership denied shows all four affordances and change-community edits no }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Fill the display name and advance — membership check triggers denial. await page.getByTestId("onboarding-display-name").fill("Morty QA"); @@ -3986,7 +3986,7 @@ test("cancel from profile Back preserves drafts and denied Back returns to inter }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // --- Profile step: Back opens community-change overlay --- const nameInput = page.getByTestId("onboarding-display-name"); @@ -4030,7 +4030,7 @@ test("denied on relay A then paste relay B invite URL switches community to B", }, { skipOnboardingSeed: true }, ); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Record the initial relay URL (relay A). const initialRelayUrl = await page.evaluate(() => { diff --git a/desktop/tests/e2e/overscroll-boundary.spec.ts b/desktop/tests/e2e/overscroll-boundary.spec.ts index e7e561c1252..e8fa0bb99bb 100644 --- a/desktop/tests/e2e/overscroll-boundary.spec.ts +++ b/desktop/tests/e2e/overscroll-boundary.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -34,7 +34,7 @@ test.beforeEach(async ({ page }) => { test("locks viewport rubber-band outside conversation scrollers", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("message-timeline")).toBeVisible(); @@ -82,7 +82,7 @@ test("locks viewport rubber-band outside conversation scrollers", async ({ }); test("locks horizontal viewport pan everywhere", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("message-timeline")).toBeVisible(); diff --git a/desktop/tests/e2e/parity-ancestor-island.spec.ts b/desktop/tests/e2e/parity-ancestor-island.spec.ts index 07426fe5f07..4e1ed1ade7b 100644 --- a/desktop/tests/e2e/parity-ancestor-island.spec.ts +++ b/desktop/tests/e2e/parity-ancestor-island.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installRelayBridge } from "../helpers/bridge"; import { assertRelaySeeded } from "../helpers/seed"; @@ -66,7 +66,7 @@ test("live relay: an ancestor island does not strand the history frontier", asyn expect(expectedGap.size).toBe(GAP_COUNT); await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/perf/metrics.test.mjs b/desktop/tests/e2e/perf/metrics.test.mjs new file mode 100644 index 00000000000..74d5bc0918f --- /dev/null +++ b/desktop/tests/e2e/perf/metrics.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { measureAction } = await import("./metrics.ts"); + +test("measureAction keeps action between baseline metrics and settled observation", async () => { + const operations = []; + let metricsReads = 0; + const client = { + async send(method) { + operations.push(method); + if (method === "Performance.getMetrics") { + metricsReads += 1; + return { + metrics: [ + { name: "LayoutDuration", value: metricsReads }, + { name: "RecalcStyleDuration", value: 0 }, + { name: "LayoutCount", value: 0 }, + { name: "ScriptDuration", value: 0 }, + { name: "TaskDuration", value: 0 }, + ], + }; + } + return {}; + }, + async detach() { + operations.push("detach"); + }, + }; + const page = { + context: () => ({ newCDPSession: async () => client }), + async evaluate() { + operations.push("settle-render"); + }, + }; + + const measurement = await measureAction(page, async () => { + operations.push("bootstrap-navigation-first-render"); + return "result"; + }); + + assert.equal(measurement.result, "result"); + assert.equal(measurement.metrics.layoutMs, 1000); + assert.deepEqual(operations, [ + "Performance.enable", + "Performance.getMetrics", + "bootstrap-navigation-first-render", + "settle-render", + "Performance.getMetrics", + "Performance.disable", + "detach", + ]); +}); diff --git a/desktop/tests/e2e/perf/metrics.ts b/desktop/tests/e2e/perf/metrics.ts index 3c65fcde955..c8a300f8f6b 100644 --- a/desktop/tests/e2e/perf/metrics.ts +++ b/desktop/tests/e2e/perf/metrics.ts @@ -39,30 +39,60 @@ function delta(after: BrowserMetrics, before: BrowserMetrics): BrowserMetrics { }; } +export type ActiveActionMeasurement = { + before: BrowserMetrics; + client: CDPSession; + startedAt: number; +}; + +export async function beginActionMeasurement( + page: Page, +): Promise { + const client = await page.context().newCDPSession(page); + await client.send("Performance.enable"); + return { + before: await readBrowserMetrics(client), + client, + startedAt: performance.now(), + }; +} + +export async function finishActionMeasurement( + page: Page, + measurement: ActiveActionMeasurement, + result: T, +): Promise> { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + return { + metrics: delta( + await readBrowserMetrics(measurement.client), + measurement.before, + ), + result, + wallMs: performance.now() - measurement.startedAt, + }; +} + +export async function closeActionMeasurement( + measurement: ActiveActionMeasurement, +): Promise { + await measurement.client.send("Performance.disable"); + await measurement.client.detach(); +} + export async function measureAction( page: Page, action: () => Promise, ): Promise> { - const client = await page.context().newCDPSession(page); - await client.send("Performance.enable"); + const measurement = await beginActionMeasurement(page); try { - const before = await readBrowserMetrics(client); - const startedAt = performance.now(); - const result = await action(); - await page.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - ), - ); - const wallMs = performance.now() - startedAt; - return { - metrics: delta(await readBrowserMetrics(client), before), - result, - wallMs, - }; + return await finishActionMeasurement(page, measurement, await action()); } finally { - await client.send("Performance.disable"); - await client.detach(); + await closeActionMeasurement(measurement); } } diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 9636f2c7505..29c3bb9dd76 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -26,14 +26,15 @@ async function seedAudience(page: Page, pubkeys: string[], theme = "buzz") { } async function openGeneral(page: Page) { - await page.goto(`/#/channels/${CHANNEL_ID}`, { + await bootstrapE2ePage(page, `/#/channels/${CHANNEL_ID}`, { waitUntil: "domcontentloaded", }); await expect(page.getByTestId("chat-title")).toHaveText("general"); } async function openThread(page: Page, threadRootId = THREAD_ROOT_ID) { - await page.goto( + await bootstrapE2ePage( + page, `/#/channels/${CHANNEL_ID}?messageId=${threadRootId}&thread=${threadRootId}`, { waitUntil: "domcontentloaded" }, ); diff --git a/desktop/tests/e2e/persona-env-vars.spec.ts b/desktop/tests/e2e/persona-env-vars.spec.ts index 8827fd0bb8b..68c3d1ea2f6 100644 --- a/desktop/tests/e2e/persona-env-vars.spec.ts +++ b/desktop/tests/e2e/persona-env-vars.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -8,7 +8,7 @@ test.beforeEach(async ({ page }) => { }); async function gotoApp(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForInvokeBridge(page); await expect(page.getByTestId("open-agents-view")).toBeVisible({ timeout: 10_000, diff --git a/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts index 5afc5344baf..3709358ccd0 100644 --- a/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts +++ b/desktop/tests/e2e/persona-model-combobox-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -27,7 +27,7 @@ async function waitForInvokeBridge(page: import("@playwright/test").Page) { * Returns the dialog locator. */ async function openNewPersonaDialog(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForInvokeBridge(page); await page.getByTestId("open-agents-view").click(); diff --git a/desktop/tests/e2e/persona-sync.spec.ts b/desktop/tests/e2e/persona-sync.spec.ts index 84b24f7eb79..cfaa2fc85c9 100644 --- a/desktop/tests/e2e/persona-sync.spec.ts +++ b/desktop/tests/e2e/persona-sync.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { hexToBytes } from "@noble/hashes/utils.js"; import { finalizeEvent } from "nostr-tools/pure"; @@ -24,7 +24,7 @@ test.beforeEach(async ({ page }) => { }); async function gotoApp(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForInvokeBridge(page); await expect(page.getByTestId("open-agents-view")).toBeVisible({ timeout: 10_000, diff --git a/desktop/tests/e2e/profile-active-turn.spec.ts b/desktop/tests/e2e/profile-active-turn.spec.ts index db20847dd65..400ae2aa0f8 100644 --- a/desktop/tests/e2e/profile-active-turn.spec.ts +++ b/desktop/tests/e2e/profile-active-turn.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -38,7 +38,7 @@ async function waitForBridge(page: import("@playwright/test").Page) { } async function openAgentsChannel(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await waitForBridge(page); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts index edd66170da4..91b9f73f93d 100644 --- a/desktop/tests/e2e/profile-backup-settings.spec.ts +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { npubEncode } from "nostr-tools/nip19"; import { installMockBridge } from "../helpers/bridge"; @@ -28,7 +28,7 @@ async function openBackupSettings( mock?: Parameters[1], ) { await installMockBridge(page, mock); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await openIdentity(page); } diff --git a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts index 05a0f846a65..be7dbff9f1a 100644 --- a/desktop/tests/e2e/profile-custom-emoji-status.spec.ts +++ b/desktop/tests/e2e/profile-custom-emoji-status.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -24,7 +24,7 @@ test.beforeEach(async ({ page }) => { test("profile popover renders a custom emoji status as an image", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openProfilePopover(page); await page.getByTestId("profile-popover-set-status").click(); diff --git a/desktop/tests/e2e/profile-nsec-reveal.spec.ts b/desktop/tests/e2e/profile-nsec-reveal.spec.ts index 47e4c8a2ded..2291d871f0c 100644 --- a/desktop/tests/e2e/profile-nsec-reveal.spec.ts +++ b/desktop/tests/e2e/profile-nsec-reveal.spec.ts @@ -2,7 +2,7 @@ * Compact E2E tests for NsecRevealRow in ProfileSettingsCard. * Covers: reveal fetches + renders masked value, error state, collapse clears state. */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; @@ -22,7 +22,7 @@ test("reveal shows masked nsec value and hides it again on collapse", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await expandIdentity(page); @@ -49,7 +49,7 @@ test("reveal shows masked nsec value and hides it again on collapse", async ({ test("reveal shows error when get_nsec fails", async ({ page }) => { await installMockBridge(page, { nsecError: "Keychain locked" }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await expandIdentity(page); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 04411720acb..cc54ee23357 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { createMockAgentMemoryListing, @@ -276,7 +276,7 @@ test.beforeEach(async ({ page }) => { test("profile panel shows communication actions as quick action tiles", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -346,7 +346,7 @@ test("owned agent profile stays in parity between Agents and its DM", async ({ agentMemory: createMockAgentMemoryListing(), oaOwnerIsMe: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const agentName = "Parity Bot"; const agentPubkey = await addGenericAgent( page, @@ -437,7 +437,7 @@ test("keeps the saved profile description after a community round trip", async ( window.localStorage.setItem("buzz-communities", JSON.stringify(seed)); window.localStorage.setItem("buzz-active-community-id", seed[0].id); }, communities); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const description = "Description that should survive switching"; await openSettings(page, "profile"); @@ -467,7 +467,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => { const displayName = `Tyler QA ${stamp}`; const avatarUrl = `https://example.com/avatar-${stamp}.png`; const about = `Coordinating relay profile setup ${stamp}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await expect( @@ -524,7 +524,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => { }); test("saves profile metadata from the block Done button", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await expect(page.getByTestId("profile-display-name-value")).toHaveText( @@ -578,7 +578,7 @@ test("saves profile metadata from the block Done button", async ({ page }) => { }); test("shows profile save feedback as a toast", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await page.getByTestId("profile-metadata-edit").click(); @@ -601,7 +601,7 @@ test("nests the avatar edit button in a clipped notch", async ({ page }) => { await page.addInitScript(() => { window.localStorage.setItem("buzz-theme", "github-light"); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); @@ -632,7 +632,7 @@ test("nests the avatar edit button in a clipped notch", async ({ page }) => { test("swaps the avatar preview and mode tabs while editing", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); @@ -677,7 +677,7 @@ test("swaps the avatar preview and mode tabs while editing", async ({ test("highlights the avatar drop target while dragging an image", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); @@ -763,7 +763,7 @@ test("uploads local profile avatar files before saving", async ({ page }) => { }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); @@ -814,7 +814,7 @@ test("uploads local profile avatar files before saving", async ({ page }) => { test("renders emoji avatars with a static background layer", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); @@ -840,7 +840,7 @@ test("renders emoji avatars with a static background layer", async ({ test("offers emoji search and skin-tone controls for profile avatars", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); @@ -913,7 +913,7 @@ test("reveals emoji background colors only after choosing an emoji", async ({ page, }) => { const imageAvatarUrl = `https://example.com/avatar-color-controls-${Date.now()}.png`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); @@ -954,7 +954,7 @@ test("reveals emoji background colors only after choosing an emoji", async ({ }); test("snaps custom avatar colors to the dot grid", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); await page.getByTestId("profile-avatar-edit").click(); @@ -1001,7 +1001,7 @@ test("snaps custom avatar colors to the dot grid", async ({ page }) => { }); test("opens Send feedback from the profile menu", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openProfileMenu(page); await page.getByTestId("profile-popover-send-feedback").click(); await expect(page.getByTestId("send-feedback-dialog")).toBeVisible(); @@ -1025,7 +1025,7 @@ test("keeps Send disabled when a stale attachment attempt finishes", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openProfileMenu(page); await page.getByTestId("profile-popover-send-feedback").click(); @@ -1068,7 +1068,7 @@ test("proxies feedback attachment previews", async ({ page }) => { }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openProfileMenu(page); await page.getByTestId("profile-popover-send-feedback").click(); @@ -1084,7 +1084,7 @@ test("proxies feedback attachment previews", async ({ page }) => { }); test("updates presence from the profile menu", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openProfileMenu(page); await expect( @@ -1116,7 +1116,7 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a oaOwnerIsMe: true, }); await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const longAgentInstruction = [ "Watch the channel and help when asked.", @@ -1868,7 +1868,7 @@ test("an older agent message opens the same persona instance as the Agents libra }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId(`persona-agent-row-${personaId}`).click(); @@ -1921,7 +1921,7 @@ test("restored Inbox deep link hides the back arrow", async ({ page }) => { }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -1966,7 +1966,7 @@ test("declared owner sees runtime tab for a remote relay agent", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -2003,7 +2003,7 @@ test("declared owner sees runtime tab for a remote relay agent", async ({ test("declared owner sees runtime tab without a relay-agent record", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -2048,7 +2048,7 @@ test("declared owner sees runtime tab without a relay-agent record", async ({ test("non-owner agent profile shows only reported public agent data", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-agents").click(); await expect(page.getByTestId("chat-title")).toHaveText("agents"); @@ -2093,7 +2093,7 @@ test("owned agent absent from relay/managed lists still renders agent framing", { pubkey: ednaPubkey, displayName: "Edna", isAgent: true }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2133,7 +2133,7 @@ test("owned agent absent from relay/managed lists still renders agent framing", test("renders settings in the app shell with a back button", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const inboxNavButton = page .getByTestId("app-sidebar") @@ -2194,7 +2194,7 @@ test("notification settings drive the Inbox badge and desktop alerts", async ({ }); } - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); await openSettings(page, "notifications"); @@ -2322,7 +2322,7 @@ test("notification settings drive the Inbox badge and desktop alerts", async ({ test("desktop notification clicks open the matching forum thread", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "notifications"); await expect(page.getByTestId("notifications-desktop-state")).toContainText( @@ -2396,7 +2396,7 @@ test("desktop notification clicks open the matching forum thread", async ({ test("opens settings with the keyboard shortcut and updates theme", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectHomeView(page); await page.keyboard.press( @@ -2484,7 +2484,7 @@ test("opens settings with the keyboard shortcut and updates theme", async ({ }); test("supports webview zoom keyboard shortcuts", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectHomeView(page); const getTextScaleState = () => @@ -2562,7 +2562,7 @@ test("storage clear resets composed font size and keyboard zoom across windows", context, page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "appearance"); await page.getByTestId("font-size-larger").click(); @@ -2604,7 +2604,7 @@ test("storage clear resets composed font size and keyboard zoom across windows", const peerPage = await context.newPage(); await installMockBridge(peerPage); - await peerPage.goto("/"); + await bootstrapE2ePage(peerPage, "/"); await peerPage.evaluate(() => localStorage.clear()); await expect @@ -2641,7 +2641,7 @@ test("storage clear resets composed font size and keyboard zoom across windows", }); test("shows agent runtimes in agent settings", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "agents"); @@ -2696,7 +2696,7 @@ test("shows agent runtimes in agent settings", async ({ page }) => { test("settings subtitles share the Appearance secondary color", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "appearance"); const appearancePanel = page.getByTestId("settings-panel-appearance"); diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 0a663653ab1..ed2adf0f23d 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { bootstrapE2ePage, expect, test } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -56,7 +56,7 @@ test("top-level project lists show metadata and overflow actions", async ({ window.localStorage.setItem("buzz.projects.viewMode", "list"); }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await expect( page.getByRole("heading", { level: 2, name: "Projects Activity" }), @@ -252,7 +252,7 @@ test("creating a project publishes its initial repository grouping", async ({ }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-create-menu").hover(); await page.getByRole("menuitem", { name: "Project" }).click(); @@ -322,7 +322,7 @@ test("unsupported relays keep the initial repository accessible", async ({ window.__BUZZ_E2E_UNSUPPORTED_PROJECT_ANNOUNCEMENTS__ = true; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-create-menu").hover(); await page.getByRole("menuitem", { name: "Project" }).click(); @@ -371,7 +371,7 @@ test("project creation can retry after its repository publication fails", async window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__ = [30621]; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-create-menu").hover(); await page.getByRole("menuitem", { name: "Project" }).click(); @@ -400,7 +400,7 @@ test("project creation is idempotent after a lost publish acknowledgement", asyn window.__BUZZ_E2E_FAIL_PROJECT_EVENT_ACK_KINDS__ = [30621]; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-create-menu").hover(); await page.getByRole("menuitem", { name: "Project" }).click(); @@ -440,7 +440,7 @@ test("multi-repository projects switch the active repository", async ({ }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await addProjectToSidebar(page, "buzz"); const primaryRepository = page.getByTestId("sidebar-project-repository-buzz"); @@ -592,7 +592,7 @@ test("commit detail opens from the commits feed with a diff", async ({ await installMockBridge(page); // The preview server is a static file server without SPA fallback, so // enter at "/" and navigate via the sidebar. - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); // The overview no longer lists repository cards — switch to the @@ -728,7 +728,7 @@ test("project discussion row opens its channel thread in context", async ({ }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("channel-general").click(); await waitForMockLiveSubscription(page, "general"); await page.evaluate( @@ -790,7 +790,7 @@ test("pull request and issue feeds use compact work item rows", async ({ }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); // The overview no longer lists repository cards — switch to the @@ -900,7 +900,7 @@ test("adding a repository retries and reports an error when the 30617 publicatio window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__ = [30617, 30617]; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await addProjectToSidebar(page, "buzz"); await page.getByTestId("add-project-repository").click(); @@ -958,7 +958,7 @@ test("adding a repository treats a lost 30617 acknowledgement as success", async window.__BUZZ_E2E_FAIL_PROJECT_EVENT_ACK_KINDS__ = [30617]; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await addProjectToSidebar(page, "buzz"); await page.getByTestId("add-project-repository").click(); @@ -1018,7 +1018,7 @@ test("adding a repository blocks when a standalone 30617 already exists at that { owner: MOCK_OWNER, dtag: STANDALONE_DTAG }, ); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-projects").click(); await page @@ -1109,7 +1109,8 @@ test("navigating via a 30617 entity-link route opens the correct non-primary rep // TanStack Router's param extractor receives the raw decoded segment, and // %3A would be passed through literally (as the string "30617%3A…") rather // than decoded to "30617:…", causing the project lookup to fail. - await page.goto( + await bootstrapE2ePage( + page, `/#/projects/${RELAY_TOOLS_ADDRESS}?pullRequestId=${KNOWN_PR_ID}`, { waitUntil: "domcontentloaded" }, ); diff --git a/desktop/tests/e2e/project-inbox.spec.ts b/desktop/tests/e2e/project-inbox.spec.ts index 19e038907bb..2ca46c54966 100644 --- a/desktop/tests/e2e/project-inbox.spec.ts +++ b/desktop/tests/e2e/project-inbox.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -18,7 +18,7 @@ test("Buzz Git pull request renders and stays actionable in Inbox", async ({ await installMockBridge(page); await page.setViewportSize({ width: 1024, height: 720 }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByRole("button", { name: "Repositories", exact: true }).click(); const repositoryCardBody = page diff --git a/desktop/tests/e2e/project-issue-comments.spec.ts b/desktop/tests/e2e/project-issue-comments.spec.ts index d7eef9551f2..b5f679df9e7 100644 --- a/desktop/tests/e2e/project-issue-comments.spec.ts +++ b/desktop/tests/e2e/project-issue-comments.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -11,7 +11,7 @@ const ISSUE_COMMENTS = [ const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); async function openBuzzProject(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-projects").click(); const projectEntry = page diff --git a/desktop/tests/e2e/project-pr-review.spec.ts b/desktop/tests/e2e/project-pr-review.spec.ts index fc9448cebae..89e453e050c 100644 --- a/desktop/tests/e2e/project-pr-review.spec.ts +++ b/desktop/tests/e2e/project-pr-review.spec.ts @@ -1,4 +1,5 @@ -import { expect, type Locator, test } from "@playwright/test"; +import type { Locator } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -34,7 +35,7 @@ async function enableProjectsFeature(page: import("@playwright/test").Page) { } async function openBuzzProject(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-projects").click(); const projectEntry = page @@ -851,7 +852,7 @@ test("project pull requests preserve partial results from batched queries", asyn window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [1619]; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByRole("button", { name: "Reviews", exact: true }).click(); @@ -904,7 +905,7 @@ test("project pull request author rollover stays identity-only", async ({ }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByRole("button", { name: "Reviews", exact: true }).click(); await page.getByRole("button", { name: "List layout" }).click(); @@ -937,7 +938,7 @@ test("project issue author rollover matches pull requests", async ({ }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByRole("button", { name: "Tasks", exact: true }).click(); await page.getByRole("button", { name: "List layout" }).click(); @@ -973,7 +974,7 @@ test("project pull requests report aggregate root query failures", async ({ window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [1618]; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByRole("button", { name: "Reviews", exact: true }).click(); @@ -999,7 +1000,7 @@ test("project issues preserve partial results from aggregate queries", async ({ window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [1]; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByRole("button", { name: "Tasks", exact: true }).click(); @@ -1031,7 +1032,7 @@ test("project overview reports aggregate work-item failures", async ({ window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ = [1618]; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await expect( @@ -1053,7 +1054,7 @@ test("projects breadcrumb is centered in the window chrome", async ({ }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); const breadcrumbMetrics = await page @@ -1078,7 +1079,7 @@ test("sidebar distinguishes the Projects overview from an open project", async ( }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); const projectsOverview = page.getByTestId("open-projects-view"); await projectsOverview.click(); @@ -1108,7 +1109,7 @@ test("collapsed sidebar leaves a balanced Projects surface gutter", async ({ }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); const layout = page.getByTestId("projects-overview-layout"); @@ -1122,7 +1123,7 @@ test("collapsed sidebar leaves a balanced Projects surface gutter", async ({ test("project channels are grouped by project", async ({ page }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await expect(page.getByTestId("projects-section-all")).toBeVisible(); expect( @@ -1235,7 +1236,7 @@ test("overview tasks and reviews are grouped and selectable by project", async ( }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); for (const section of [ @@ -1311,7 +1312,7 @@ test("project section icons follow their titles", async ({ page }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-issues").click(); await expectIconAfterTitle("projects-page-header"); @@ -1326,7 +1327,7 @@ test("project overview presents collapsible context beside grouped activity", as }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await expect(page.getByTestId("projects-overview-panel")).toHaveCSS( @@ -1596,7 +1597,7 @@ test("project overview presents collapsible context beside grouped activity", as test("project overview content header toggles agent chat", async ({ page }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-prs").click(); await page.getByRole("button", { name: "List layout" }).click(); @@ -1681,7 +1682,7 @@ test("project overview info control animates the context rail", async ({ }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); const toggle = page.getByTestId("projects-overview-context-toggle"); @@ -1737,7 +1738,7 @@ test("selecting overview list rows switches the context pod to the cluster", asy }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByRole("button", { name: "Tasks", exact: true }).click(); await page.getByRole("button", { name: "List layout" }).click(); @@ -1824,7 +1825,7 @@ test("repository changes discard captured selection context before agent sends", }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await addProjectToSidebar(page, "buzz"); await page.getByTestId("sidebar-project-repository-buzz").click(); await page.getByRole("tab", { name: "Tasks", exact: true }).click(); @@ -1895,7 +1896,7 @@ test("overview lists position identifying and generic icons consistently", async }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-projects").click(); @@ -2079,7 +2080,7 @@ test("selecting repository workspace rows switches the context pod to the cluste }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-projects").click(); await page @@ -2257,7 +2258,7 @@ test("project detail chat resize tracks the pointer without easing", async ({ test("repository rows identify their git host", async ({ page }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByRole("button", { name: "Repositories", exact: true }).click(); await page.getByRole("button", { name: "List layout" }).click(); @@ -2281,7 +2282,7 @@ test("project subsections do not paint backgrounds behind list or grid items", a }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); for (const section of ["Repositories", "Reviews", "Tasks"] as const) { @@ -2325,7 +2326,7 @@ test("project subsections do not paint backgrounds behind list or grid items", a test("all project grid cards cap body copy at two lines", async ({ page }) => { await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); for (const section of [ @@ -2669,7 +2670,7 @@ test("external repositories stay on local source after a branch round trip", asy }; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await addProjectToSidebar(page, "buzz"); await page.getByTestId("sidebar-project-repository-relay-tools").click(); @@ -2763,7 +2764,7 @@ test("repository files beyond the eager preview limit load on demand", async ({ }; }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await addProjectToSidebar(page, "buzz"); await page.getByTestId("sidebar-project-repository-relay-tools").click(); @@ -2900,7 +2901,7 @@ test("narrow layouts keep section context reachable through a sheet", async ({ await page.setViewportSize({ height: 720, width: 900 }); await enableProjectsFeature(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); // Below the detached breakpoint the retained docked rail stays collapsed, diff --git a/desktop/tests/e2e/projects-v3-screenshots.spec.ts b/desktop/tests/e2e/projects-v3-screenshots.spec.ts index 93578ef552a..ccfa1f1b904 100644 --- a/desktop/tests/e2e/projects-v3-screenshots.spec.ts +++ b/desktop/tests/e2e/projects-v3-screenshots.spec.ts @@ -1,4 +1,5 @@ -import { expect, type Locator, test } from "@playwright/test"; +import type { Locator } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -20,7 +21,7 @@ async function expectSinglePrimaryTextColumn(row: Locator) { } async function openBuzzProject(page: import("@playwright/test").Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-projects").click(); const projectEntry = page @@ -37,7 +38,7 @@ test("projects activity overview screenshot", async ({ page }) => { window.localStorage.setItem("buzz-theme", "light"); }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await expect(page.getByTestId("projects-page-tabs")).toBeVisible(); await expect(page.getByTestId("projects-page-header")).toBeVisible(); @@ -60,7 +61,7 @@ test("projects activity overview screenshot", async ({ page }) => { test("sidebar project add flow browses before creating", async ({ page }) => { await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("sidebar-project-buzz")).toHaveCount(0); await page.getByTestId("sidebar-projects-section-label").hover(); await page.getByTestId("sidebar-projects-create").click(); @@ -1094,7 +1095,7 @@ test("projects v3 work-item list metadata", async ({ page }) => { window.localStorage.setItem("buzz.projects.viewMode", "list"); }); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-all").click(); diff --git a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts index 77630467da1..ca81385d97a 100644 --- a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts +++ b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, @@ -18,7 +18,7 @@ test("profile panel Public key row reveals its copy action on hover", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -62,7 +62,7 @@ test("new-DM agent name swaps to its public key on name hover", async ({ }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await expect(page.getByTestId("new-message-page")).toBeVisible(); @@ -135,7 +135,7 @@ test("selected new-DM recipient can be verified again through search", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openNewMessagePage(page); await expect(page.getByTestId("new-message-page")).toBeVisible(); @@ -230,7 +230,7 @@ test("selected new-DM recipient can be verified again through search", async ({ test("member removal confirm shows the full npub inline", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/reaction-names.spec.ts b/desktop/tests/e2e/reaction-names.spec.ts index 028bb1645df..edede2a9490 100644 --- a/desktop/tests/e2e/reaction-names.spec.ts +++ b/desktop/tests/e2e/reaction-names.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -70,7 +70,7 @@ test.beforeEach(async ({ page }) => { test("reaction popover resolves a reactor with no authored message in the window", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.waitForFunction( @@ -118,7 +118,7 @@ test("reaction popover resolves a reactor with no authored message in the window test("maximum-length reaction name wraps inside a fixed-width popover", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.waitForFunction( diff --git a/desktop/tests/e2e/reaction-order.spec.ts b/desktop/tests/e2e/reaction-order.spec.ts index 34cf071e771..7bc0d1660aa 100644 --- a/desktop/tests/e2e/reaction-order.spec.ts +++ b/desktop/tests/e2e/reaction-order.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import * as fs from "node:fs"; import * as path from "node:path"; @@ -30,7 +30,7 @@ function reactionTargetRow(page: import("@playwright/test").Page) { } async function openGeneral(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); } diff --git a/desktop/tests/e2e/relay-connectivity.spec.ts b/desktop/tests/e2e/relay-connectivity.spec.ts index dbe2b5d77c2..a910402d95e 100644 --- a/desktop/tests/e2e/relay-connectivity.spec.ts +++ b/desktop/tests/e2e/relay-connectivity.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -77,7 +77,7 @@ async function driveConnectionDegraded( test.describe("relay connectivity", () => { test("01 — sidebar unreachable card", async ({ page }) => { await installMockBridge(page, { channelsReadError: RELAY_UNREACHABLE }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Wait for the E2E seam to be installed (bridge init is complete), then force // the relay into a degraded state. The card is gated on !isRelayConnectionConnected: @@ -113,7 +113,7 @@ test.describe("relay connectivity", () => { test("02 — sidebar reconnect card while reconnecting", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Wait for a healthy boot, then force the relay into "reconnecting" state. await expect(page.getByTestId("channel-general")).toBeVisible(); @@ -134,7 +134,7 @@ test.describe("relay connectivity", () => { test("03 — canvas unreachable in management sheet", async ({ page }) => { await installMockBridge(page, { canvasReadError: RELAY_UNREACHABLE }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -186,7 +186,7 @@ test.describe("relay connectivity", () => { }, ); await installMockBridge(page, { profileReadError: RELAY_UNREACHABLE }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // The profile card should show the cached display name. const profileCard = page.getByTestId("sidebar-profile-card"); @@ -197,7 +197,7 @@ test.describe("relay connectivity", () => { test("05 — no-cache npub fallback when offline", async ({ page }) => { // No cache seeded — profile card falls back to the mock identity npub name. await installMockBridge(page, { profileReadError: RELAY_UNREACHABLE }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const profileCard = page.getByTestId("sidebar-profile-card"); // Default mock identity display name is "npub1mock...". @@ -209,7 +209,7 @@ test.describe("relay connectivity", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await driveConnectionDegraded(page); diff --git a/desktop/tests/e2e/relay-reconnect-affordance.spec.ts b/desktop/tests/e2e/relay-reconnect-affordance.spec.ts index 3ee83b6046f..4b6ab1b0957 100644 --- a/desktop/tests/e2e/relay-reconnect-affordance.spec.ts +++ b/desktop/tests/e2e/relay-reconnect-affordance.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -52,7 +52,7 @@ test.describe("relay reconnect affordance", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0); @@ -71,7 +71,7 @@ test.describe("relay reconnect affordance", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await driveConnectionState(page, "stalled"); diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index af573373d36..4f295cd2cf8 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -173,7 +173,7 @@ test("stalled early AUTH signing times out and starts a replacement dial", async websocketAuthBeforeConnectResolves: true, stallFirstAuthSigning: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect .poll(() => getMockWebsocketConnectAttempts(page), { timeout: 32_000 }) @@ -193,7 +193,7 @@ test("AUTH arriving before connect resolves does not lose the first send", async await installMockBridge(page, { websocketAuthBeforeConnectResolves: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect .poll( () => @@ -215,7 +215,7 @@ test("failed initial relay dial retries automatically", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["mock relay pod unavailable"], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // App-shell preconnect owns a keep-alive request. The first native dial is // rejected before a socket ID exists; the session must still enter its @@ -240,7 +240,7 @@ test("failed initial relay dial retries automatically", async ({ page }) => { test("routine traffic cannot bypass outage backoff and recovery stays automatic", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await setMockWebsocketUnavailable(page, true); @@ -289,7 +289,7 @@ test("routine traffic cannot bypass outage backoff and recovery stays automatic" test("authenticated reconnect reports connected while replay is rate-limited", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await activateRelayRateLimit(page, 5); @@ -311,7 +311,7 @@ test("authenticated reconnect reports connected while replay is rate-limited", a test("rate-limited reconnect backfill does not tear down the authenticated socket", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -367,7 +367,7 @@ test("service restart close resets accumulated backoff", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["down 1", "down 2", "down 3"], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible({ timeout: 15_000, }); @@ -387,7 +387,7 @@ test("service restart close resets accumulated backoff", async ({ page }) => { test("passive relay watchdog does not write while the websocket is half-open", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( @@ -413,7 +413,7 @@ test("passive relay watchdog does not write while the websocket is half-open", a test("sidebar reconnect prompt flips on live relay degradation without a query error", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Healthy boot: channels render from the mock bridge and the reconnect // prompt is absent (no query error, connection healthy). @@ -439,7 +439,7 @@ test("sidebar reconnect prompt flips on live relay degradation without a query e test("profile popover does not show relay reconnect controls", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await page.getByTestId("sidebar-profile-avatar-button").click(); @@ -458,7 +458,7 @@ test("profile popover does not show relay reconnect controls", async ({ test("resume event short-circuits accumulated reconnect backoff", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await setMockWebsocketUnavailable(page, true); @@ -485,7 +485,7 @@ test("resume event short-circuits accumulated reconnect backoff", async ({ test("resume events during repeated AUTH rejection cannot defeat the terminal cap", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -545,7 +545,7 @@ test("resume events during repeated AUTH rejection cannot defeat the terminal ca test("sub-2s degraded flap invalidates relay queries on recovery", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await page.evaluate(() => { @@ -587,7 +587,7 @@ test("sub-2s degraded flap invalidates relay queries on recovery", async ({ test("transient AUTH rejection reconnects and restores live traffic", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -622,7 +622,7 @@ test("transient AUTH rejection reconnects and restores live traffic", async ({ test("auth-required CLOSED restores the active live subscription", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -650,7 +650,7 @@ test("auth-required CLOSED restores the active live subscription", async ({ test("reconnect backfills more missed channel messages than the live subscription limit", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/relay-restart.live.spec.ts b/desktop/tests/e2e/relay-restart.live.spec.ts index f3c80e69a1b..058edb72908 100644 --- a/desktop/tests/e2e/relay-restart.live.spec.ts +++ b/desktop/tests/e2e/relay-restart.live.spec.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { installBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { TwoRelayHarness, type RelaySpec } from "./helpers/twoRelayHarness"; @@ -166,7 +166,7 @@ test.describe("relay restart live gate", () => { relayHttpUrl, relayWsUrl: `ws://127.0.0.1:${spec.ports.main}`, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Baseline: the app converges to a live authenticated session and sees // the channel created for this fresh database. diff --git a/desktop/tests/e2e/release-smoke.spec.ts b/desktop/tests/e2e/release-smoke.spec.ts index 19beb276171..961b90900ff 100644 --- a/desktop/tests/e2e/release-smoke.spec.ts +++ b/desktop/tests/e2e/release-smoke.spec.ts @@ -1,12 +1,23 @@ import { mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { expect, test, type Page, type TestInfo } from "@playwright/test"; +import { + expect, + test, + type Page, + type TestInfo, + bootstrapE2ePage, +} from "../helpers/test"; import { installRelayBridge } from "../helpers/bridge"; import { assertRelaySeeded } from "../helpers/seed"; import { denseSecondWall, seedScenario } from "../helpers/seedRelay"; -import { measureAction, type ActionMeasurement } from "./perf/metrics"; +import { + beginActionMeasurement, + closeActionMeasurement, + finishActionMeasurement, + type ActionMeasurement, +} from "./perf/metrics"; const RELAY_HTTP = process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000"; const GENERAL_CHANNEL_ID = "9f28288a-d724-587a-9709-92dc7f967110"; @@ -55,7 +66,7 @@ async function writeArtifact(testInfo: TestInfo, artifact: ScenarioArtifact) { async function openGeneral(page: Page) { await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.locator('[data-render-pending="true"]')).toHaveCount(0); @@ -68,32 +79,12 @@ async function mountedRowCount(page: Page) { return page.getByTestId("message-row").count(); } -test.beforeAll(async () => { - test.setTimeout(90_000); - await assertRelaySeeded(); -}); - -test("deep local-relay timeline remains bounded and every row is reachable", async ({ - page, -}, testInfo) => { - testInfo.setTimeout(10 * 60_000); - - const fixtureSecond = Math.floor(Date.now() / 1000) - 1; - const fixture = denseSecondWall({ - channelId: GENERAL_CHANNEL_ID, - count: DEEP_ROW_COUNT, - second: fixtureSecond, - }); - const expected = await seedScenario(fixture, { - relayHttpUrl: RELAY_HTTP, - concurrency: 64, - }); - expect(expected).toHaveLength(DEEP_ROW_COUNT); - const expectedIdHash = await sha256(expected.map(({ id }) => id)); - const expectedOrder = expected.map(({ id }) => id).sort(); - const expectedRank = new Map(expectedOrder.map((id, index) => [id, index])); - - const measurement = await measureAction(page, async () => { +async function measureDeepTimeline( + page: Page, + expectedRank: Map, +) { + const activeMeasurement = await beginActionMeasurement(page); + try { const timeline = await openGeneral(page); const initialMountedRows = await mountedRowCount(page); let duplicateSnapshots = 0; @@ -197,7 +188,7 @@ test("deep local-relay timeline remains bounded and every row is reachable", asy if (seen.size >= DEEP_ROW_COUNT) stallReason = "row-target-reached"; - return { + return await finishActionMeasurement(page, activeMeasurement, { initialMountedRows, finalMountedRows: await mountedRowCount(page), maxMountedRows, @@ -218,8 +209,38 @@ test("deep local-relay timeline remains bounded and every row is reachable", asy passCount: passes.length, stallReason, passes, - }; + }); + } finally { + await closeActionMeasurement(activeMeasurement); + } +} + +test.beforeAll(async () => { + test.setTimeout(90_000); + await assertRelaySeeded(); +}); + +test("deep local-relay timeline remains bounded and every row is reachable", async ({ + page, +}, testInfo) => { + testInfo.setTimeout(10 * 60_000); + + const fixtureSecond = Math.floor(Date.now() / 1000) - 1; + const fixture = denseSecondWall({ + channelId: GENERAL_CHANNEL_ID, + count: DEEP_ROW_COUNT, + second: fixtureSecond, }); + const expected = await seedScenario(fixture, { + relayHttpUrl: RELAY_HTTP, + concurrency: 64, + }); + expect(expected).toHaveLength(DEEP_ROW_COUNT); + const expectedIdHash = await sha256(expected.map(({ id }) => id)); + const expectedOrder = expected.map(({ id }) => id).sort(); + const expectedRank = new Map(expectedOrder.map((id, index) => [id, index])); + + const measurement = await measureDeepTimeline(page, expectedRank); await writeArtifact(testInfo, { schemaVersion: 1, diff --git a/desktop/tests/e2e/reminder-click-repro.spec.ts b/desktop/tests/e2e/reminder-click-repro.spec.ts index 60f95f80e51..6748c3670e9 100644 --- a/desktop/tests/e2e/reminder-click-repro.spec.ts +++ b/desktop/tests/e2e/reminder-click-repro.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -56,7 +56,7 @@ async function expectAppAliveAfterDialogClose( async function openReminderDialogFromMessageMenu( page: import("@playwright/test").Page, ) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -121,7 +121,7 @@ test.describe("reminder set → app stays clickable", () => { // dismissable layer are ever bundled again — body pointer-events stays // "none" after the dialog closes and the sidebar click below times out. test("03 — inbox row right-click → Remind me later", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox")).toBeVisible(); const row = page.getByTestId(INBOX_MENTION_ROW); diff --git a/desktop/tests/e2e/reminders.spec.ts b/desktop/tests/e2e/reminders.spec.ts index f60e2243897..38bd7341239 100644 --- a/desktop/tests/e2e/reminders.spec.ts +++ b/desktop/tests/e2e/reminders.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -8,7 +8,7 @@ const MOCK_PUBKEY = "deadbeef".repeat(8); // The inbox filter dropdown lives in the home pane, not the chat view. Land on // home and wait for the inbox before reaching for the filter trigger. async function gotoInboxHome(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox")).toBeVisible(); } @@ -78,7 +78,7 @@ test.describe("reminders", () => { }); test("02 — message action menu shows Remind me later", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -99,7 +99,7 @@ test.describe("reminders", () => { }); test("03 — Remind me later dialog with time presets", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 0e1d4cfed6f..a10d609e8b0 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -72,7 +72,7 @@ test("channel switch settles at the newest message after virtualized rows measur page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -127,7 +127,7 @@ test("first channel load paints the first window without waiting for the row-flo page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -177,7 +177,7 @@ test("preserves user scroll while older channel history loads", async ({ }, testInfo) => { testInfo.setTimeout(60_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -357,7 +357,7 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -571,7 +571,7 @@ test("reserves real buzz-bugs imeta image height before image loads", async ({ }) => { await page.route("**/media/**", () => new Promise(() => {})); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -633,7 +633,7 @@ test("deep-link to a message in older history scrolls and highlights it", async page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -842,7 +842,7 @@ test("unified channel search opens rows regardless of history position", async ( page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -1075,7 +1075,7 @@ test("composer expansion does not push bottom row out of viewport", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -1197,7 +1197,7 @@ test("mounted rows cover the viewport beneath the composer in both directions", page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -1288,7 +1288,7 @@ test("fast middle-page scroll settles with continuous mounted coverage", async ( page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -1402,7 +1402,7 @@ test("in-viewport reflow above the anchor row does not push it down", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -1521,7 +1521,7 @@ test("channel intro stays hidden while older history is loading", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -1622,7 +1622,7 @@ test("channel intro stays hidden while paginating past the timeline cap", async }, testInfo) => { testInfo.setTimeout(90_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -1755,7 +1755,7 @@ test("older-history fetches never overlap (no concurrent in-flight requests)", a page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -1821,7 +1821,7 @@ test("older-history spinner stays visible in viewport while fetching mid-scroll" page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -1906,7 +1906,7 @@ test("one scroll-up gesture pages older history once, not to the channel top", a }, testInfo) => { testInfo.setTimeout(60_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -2001,7 +2001,7 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as }, testInfo) => { testInfo.setTimeout(60_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -2124,7 +2124,7 @@ test("thread summary badge survives a retained older-history prepend", async ({ }, testInfo) => { testInfo.setTimeout(60_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); diff --git a/desktop/tests/e2e/scroll-smoothness.perf.ts b/desktop/tests/e2e/scroll-smoothness.perf.ts index 06fec8baef1..f2b0ea202ab 100644 --- a/desktop/tests/e2e/scroll-smoothness.perf.ts +++ b/desktop/tests/e2e/scroll-smoothness.perf.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -59,7 +59,7 @@ test("MEASURE: fast-wheel scroll-up layout cost on a busy un-virtualized timelin page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && @@ -207,7 +207,7 @@ test("MEASURE: prepend re-render cost while scrolled up (the untested event-cost page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" && diff --git a/desktop/tests/e2e/scrollback-buzzbugs.perf.ts b/desktop/tests/e2e/scrollback-buzzbugs.perf.ts index 184e371c973..9aa6f0e7c85 100644 --- a/desktop/tests/e2e/scrollback-buzzbugs.perf.ts +++ b/desktop/tests/e2e/scrollback-buzzbugs.perf.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { decode } from "nostr-tools/nip19"; import { getPublicKey } from "nostr-tools/pure"; @@ -179,7 +179,7 @@ test("MEASURE: scroll-back pagination latency in target channel", async ({ ); // ---- Boot to sidebar, open the target channel ---- - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); const chan = page.getByTestId(`channel-${TARGET_CHANNEL}`).first(); await chan.waitFor({ state: "visible", timeout: 45_000 }); diff --git a/desktop/tests/e2e/search-scope-screenshots.spec.ts b/desktop/tests/e2e/search-scope-screenshots.spec.ts index 3b19d8fd0ff..dbb0362679b 100644 --- a/desktop/tests/e2e/search-scope-screenshots.spec.ts +++ b/desktop/tests/e2e/search-scope-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -7,7 +7,7 @@ test("captures global and current-channel search scope states", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-deep-history").click(); await expect(page.getByTestId("chat-title")).toHaveText("deep-history"); await expect( diff --git a/desktop/tests/e2e/send-channel-binding.spec.ts b/desktop/tests/e2e/send-channel-binding.spec.ts index c1b51819230..1e88d7195ce 100644 --- a/desktop/tests/e2e/send-channel-binding.spec.ts +++ b/desktop/tests/e2e/send-channel-binding.spec.ts @@ -10,7 +10,7 @@ * handler open long enough for the channel click to race the in-flight send. */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -68,7 +68,7 @@ test("message with agent mention lands in compose-time channel despite mid-send ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -156,7 +156,7 @@ test("message with agent mention delivers correctly when no channel switch occur ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/settings-section-layout.spec.ts b/desktop/tests/e2e/settings-section-layout.spec.ts index 4e689d52dd3..8317db4748a 100644 --- a/desktop/tests/e2e/settings-section-layout.spec.ts +++ b/desktop/tests/e2e/settings-section-layout.spec.ts @@ -1,11 +1,11 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; test("settings sections share the Appearance rhythm", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "appearance"); const appearanceList = page @@ -84,7 +84,7 @@ test("Profile sections keep visible cards and aligned actions", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page, "profile"); const identity = page.getByTestId("profile-identity-card"); diff --git a/desktop/tests/e2e/sidebar-more-unread-overlap.spec.ts b/desktop/tests/e2e/sidebar-more-unread-overlap.spec.ts index 868dc5cc276..8af3bd7177a 100644 --- a/desktop/tests/e2e/sidebar-more-unread-overlap.spec.ts +++ b/desktop/tests/e2e/sidebar-more-unread-overlap.spec.ts @@ -14,7 +14,7 @@ * container (the same relative wrapper that owns the real top unread pill) and * asserts the pill clears the pinned header. */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -65,7 +65,7 @@ async function injectSyntheticPill( test.describe("sidebar MoreUnreadButton top chrome overlap", () => { test.beforeEach(async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); }); diff --git a/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts b/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts index df94afc6998..d6b4386e1fa 100644 --- a/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts +++ b/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -36,7 +36,7 @@ async function setup(page: Page, theme: string) { }, { list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id }, ); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("community-rail")).toBeVisible(); await expect(page.getByTestId("app-sidebar")).toBeVisible(); } diff --git a/desktop/tests/e2e/sidebar-relay-card.spec.ts b/desktop/tests/e2e/sidebar-relay-card.spec.ts index b9903614d2c..0dd3f392de4 100644 --- a/desktop/tests/e2e/sidebar-relay-card.spec.ts +++ b/desktop/tests/e2e/sidebar-relay-card.spec.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Page, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -117,7 +117,7 @@ test("sidebar generic relay failures use the reconnect card", async ({ }) => { await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // The card is gated on !isRelayConnectionConnected — a stale channelsReadError // alone (with state still "connected") no longer pins the card. Drive the relay @@ -132,7 +132,7 @@ test("relay outage notification stays dismissed through retries and re-arms afte page, }) => { await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await setRelayConnectionState(page, "disconnected"); const card = await expectGenericReconnectCard(page); @@ -160,7 +160,7 @@ test("relay outage notification re-arms after same-URL lifecycle teardown", asyn page, }) => { await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await setRelayConnectionState(page, "disconnected"); const card = await expectGenericReconnectCard(page); @@ -182,7 +182,7 @@ test("sidebar proxy sign-in failures use the reconnect card", async ({ }) => { await installMockBridge(page, { channelsReadError: PROXY_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Drive disconnected so the card appears immediately (connected is now authoritative). await setRelayConnectionState(page, "disconnected"); @@ -193,7 +193,7 @@ test("sidebar proxy sign-in failures use the reconnect card", async ({ test("sidebar access failures use the reconnect card", async ({ page }) => { await installMockBridge(page, { channelsReadError: ACCESS_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Drive disconnected so the card appears immediately (connected is now authoritative). await setRelayConnectionState(page, "disconnected"); @@ -204,7 +204,7 @@ test("sidebar access failures use the reconnect card", async ({ page }) => { test("collapsed sidebar still shows the reconnect card", async ({ page }) => { await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Drive degraded state so the card appears. await setRelayConnectionState(page, "disconnected"); @@ -236,7 +236,7 @@ test("sidebar stalled relay state uses the reconnect card", async ({ }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await setRelayConnectionState(page, "stalled"); @@ -248,7 +248,7 @@ test("sidebar application auth disconnects stay on the error path", async ({ }) => { await installMockBridge(page, { channelsReadError: RELAY_AUTH_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await setRelayConnectionState(page, "disconnected"); await expect(page.getByText(RELAY_AUTH_ERROR)).toBeVisible(); @@ -260,7 +260,7 @@ test("sidebar reconnect action shows connected before hiding", async ({ }) => { await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Drive disconnected state so the card appears immediately. await setRelayConnectionState(page, "disconnected"); @@ -285,7 +285,7 @@ test("sidebar reconnect action suppresses stale refresh errors after success", a }) => { await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Drive disconnected state so the card appears immediately. await setRelayConnectionState(page, "disconnected"); @@ -310,7 +310,7 @@ test("sidebar connected success clears when relay degrades again", async ({ }) => { await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Drive disconnected state so the card appears immediately. await setRelayConnectionState(page, "disconnected"); diff --git a/desktop/tests/e2e/sidebar-snapshot.spec.ts b/desktop/tests/e2e/sidebar-snapshot.spec.ts index eaf8797f11e..604b93e5e6c 100644 --- a/desktop/tests/e2e/sidebar-snapshot.spec.ts +++ b/desktop/tests/e2e/sidebar-snapshot.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -250,7 +250,7 @@ test("cold boot paints the complete snapshot, sends its hash, and revalidates", }); const navigationStartedAt = performance.now(); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const rows = page.locator('[data-channel-id^="snapshot-"]'); await expect(rows).toHaveCount(FULL_SNAPSHOT.length, { timeout: 2_000 }); const snapshotPaintedAt = performance.now(); @@ -298,7 +298,7 @@ test("matching not-modified preserves display mutations without persisting them" channelsReadDelayMs: READ_DELAY_MS, honorChannelsKnownHash: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount( FULL_SNAPSHOT.length, @@ -330,7 +330,7 @@ test("first-ever boot without a snapshot sends null and shows loading", async ({ page, }) => { await installMockBridge(page, { channelsReadDelayMs: READ_DELAY_MS }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("sidebar-loading")).toBeVisible({ timeout: 500, @@ -363,7 +363,7 @@ test("a different identity's snapshot is ignored", async ({ page }) => { ownerPubkey: STALE_COMMUNITY_PUBKEY, }); await installMockBridge(page, { channelsReadDelayMs: READ_DELAY_MS }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("sidebar-loading")).toBeVisible({ timeout: 500, @@ -395,7 +395,7 @@ test("display-only community pubkey cannot expose another identity's snapshot", { skipCommunitySeed: true }, ); await seedCommunities(page, STALE_COMMUNITY_PUBKEY); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); expect(await getTrackedSnapshotRows(page)).toEqual([]); @@ -421,7 +421,7 @@ test("partial hash/list write fails toward a full fetch", async ({ page }) => { channelsReadDelayMs: READ_DELAY_MS, honorChannelsKnownHash: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("sidebar-loading")).toBeVisible({ timeout: 500, @@ -446,7 +446,7 @@ test("unexpected not-modified response retries without a hash", async ({ await installMockBridge(page, { channelsNotModifiedResponses: 1, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("channel-general")).toBeVisible(); await expect @@ -462,7 +462,7 @@ test("mismatched not-modified hash falls back to a full list", async ({ channelsReadDelayMs: READ_DELAY_MS, channelsNotModifiedResponses: 1, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const snapshotRows = page.locator('[data-channel-id^="snapshot-"]'); await expect(snapshotRows).toHaveCount(FULL_SNAPSHOT.length, { @@ -507,7 +507,7 @@ test("hash mismatch replaces the snapshot with the full live list", async ({ channelsReadDelayMs: READ_DELAY_MS, honorChannelsKnownHash: true, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount( FULL_SNAPSHOT.length, @@ -561,7 +561,7 @@ test("community switch validates a stale relay snapshot and replaces it", async { skipCommunitySeed: true }, ); await seedCommunities(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); const payloadCountBeforeSwitch = (await getChannelsPayloads(page)).length; diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 01d9d64d67b..75d7afdb945 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; @@ -30,7 +30,7 @@ async function loadTheme(page: Page, theme: string) { window.localStorage.setItem("buzz-theme", selectedTheme); }, theme); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); } async function openAddCommunityDialog(page: Page) { @@ -197,7 +197,7 @@ test("sidebar rows separate hover, selected, and reorder states", async ({ test("add community starts with create and join choices", async ({ page }) => { await installMockBridge(page, {}); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openAddCommunityDialog(page); @@ -241,7 +241,7 @@ test("automatically shows community join requirements near the community URL", a version: "policy-v1", }, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openAddCommunityDialog(page); await page.getByTestId("add-community-join").click(); @@ -290,7 +290,7 @@ test("automatically shows community join requirements near the community URL", a test("joins a community URL without an API token field", async ({ page }) => { await installMockBridge(page, { applyCommunityDelayMs: 1_000 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openAddCommunityDialog(page); await page.getByTestId("add-community-join").click(); @@ -314,7 +314,7 @@ test("joins a community URL without an API token field", async ({ page }) => { }); test("hides Invites settings on open relays", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSettings(page); await expect(page.getByTestId("settings-nav-community-members")).toHaveCount( @@ -325,7 +325,7 @@ test("hides Invites settings on open relays", async ({ page }) => { test("leaving a channel from the context menu never freezes the app", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); // Cancel path: dialog opens from the context menu, then is dismissed. @@ -348,7 +348,7 @@ test("leaving a channel from the context menu never freezes the app", async ({ test("channel context menu only shows owner actions to the owner", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click({ button: "right" }); await expect( @@ -388,7 +388,7 @@ test("channel context menu explains when owner actions are loading", async ({ page, }) => { await installMockBridge(page, { channelMembersReadDelayMs: 500 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click({ button: "right" }); await expect( @@ -403,7 +403,7 @@ test("channel context menu explains when owner actions are loading", async ({ }); test("channel owner can archive from the context menu", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click({ button: "right" }); await page.getByRole("menuitem", { name: "Archive channel" }).click(); @@ -412,7 +412,7 @@ test("channel owner can archive from the context menu", async ({ page }) => { }); test("channel owner can delete from the context menu", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await page.getByTestId("channel-general").click({ button: "right" }); @@ -534,7 +534,7 @@ test("aligns the sidebar search with the channel title outside the Buzz theme", test("scales the sidebar backward while its chrome closes", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const sidebar = page.getByTestId("app-sidebar"); const sidebarSurface = sidebar.locator("[data-sidebar-transition-content]"); @@ -585,7 +585,7 @@ test("disables the sidebar collapse transition for reduced motion", async ({ page, }) => { await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const sidebarSurface = page .getByTestId("app-sidebar") @@ -601,7 +601,7 @@ test("disables the sidebar collapse transition for reduced motion", async ({ }); test("sidebar rail resizes without toggling the sidebar", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const rail = page.getByRole("button", { name: "Resize sidebar" }); await rail.click(); await expect(page.getByTestId("app-sidebar")).toBeVisible(); @@ -613,7 +613,7 @@ test("sidebar rail resizes without toggling the sidebar", async ({ page }) => { test("resizes, persists, and snaps to the default sidebar width", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); await expect.poll(() => sidebarWidth(page)).toBe(DEFAULT_SIDEBAR_WIDTH); @@ -639,7 +639,7 @@ test("resizes, persists, and snaps to the default sidebar width", async ({ test("shows a sidebar update card when an update is ready", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); await page.evaluate(() => { @@ -734,7 +734,7 @@ test("shows a sidebar update card when an update is ready", async ({ test("reflects an install started from the header update button on the sidebar card", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); await page.evaluate(() => { @@ -783,7 +783,7 @@ test("reflects an install started from the header update button on the sidebar c test("shows manual-required update card and never auto-downloads on non-AppImage installs", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); // Override the bridge to report an update available AND auto-update not diff --git a/desktop/tests/e2e/signout-confirmation.spec.ts b/desktop/tests/e2e/signout-confirmation.spec.ts index 7b6c9b57612..e425d5bf7c4 100644 --- a/desktop/tests/e2e/signout-confirmation.spec.ts +++ b/desktop/tests/e2e/signout-confirmation.spec.ts @@ -6,7 +6,7 @@ * 1. backup — check "I have saved my private key" * 2. typed confirmation — type the exact phrase "wipe all my data" */ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Page, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; @@ -25,7 +25,7 @@ test("delete button unlocks only after backup + typed phrase", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSignOutDialog(page); const deleteButton = page.getByTestId("signout-confirm"); @@ -55,7 +55,7 @@ test("delete button unlocks only after backup + typed phrase", async ({ test("completing both gates invokes sign_out", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSignOutDialog(page); await page.getByTestId("signout-backup-confirm").click(); @@ -79,7 +79,7 @@ test("completing both gates invokes sign_out", async ({ page }) => { test("cancel resets the gates for the next open", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSignOutDialog(page); // Satisfy both gates, then cancel. @@ -100,7 +100,7 @@ test("nsec load failure still allows sign-out (backup step degrades)", async ({ page, }) => { await installMockBridge(page, { nsecError: "Keychain locked" }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openSignOutDialog(page); // Error shown in place of the key; checkbox is still usable so the user is diff --git a/desktop/tests/e2e/signout-screenshots.spec.ts b/desktop/tests/e2e/signout-screenshots.spec.ts index 5cf35c70ab0..eea0e39a1d7 100644 --- a/desktop/tests/e2e/signout-screenshots.spec.ts +++ b/desktop/tests/e2e/signout-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; @@ -27,7 +27,7 @@ test.describe("signout screenshots", () => { page, }) => { await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "profile"); const section = page.getByTestId("settings-signout"); @@ -49,7 +49,7 @@ test.describe("signout screenshots", () => { page, }) => { await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "profile"); const section = page.getByTestId("settings-signout"); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index b3ea8bea617..c538a179d7e 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge"; @@ -85,7 +85,7 @@ test.beforeEach(async ({ page }) => { }); test("loads the app shell with mocked channels", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("app-sidebar")).toBeVisible(); await expect(page.getByTestId("stream-list")).toContainText("general"); @@ -111,7 +111,7 @@ async function chooseSharedComputeProvider( test("creates a new mocked stream", async ({ page }) => { const channelName = `release-notes-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page @@ -126,7 +126,7 @@ test("creates a new mocked stream", async ({ page }) => { test("Buzz shared compute explains automatic model selection", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.evaluate(() => { ( window as Window & { @@ -163,7 +163,7 @@ test("create agent persists Buzz shared compute with auto model", async ({ }) => { const agentName = `Shared compute agent ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); await page.locator("#persona-display-name").fill(agentName); @@ -209,7 +209,7 @@ test("create agent supports parallelism and system prompt overrides", async ({ }) => { const agentName = `Parallel agent ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); @@ -282,7 +282,7 @@ test("create agent supports parallelism and system prompt overrides", async ({ test("opens a mocked channel from the inbox feed", async ({ page }) => { const inboxList = page.getByTestId("home-inbox-list"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectHomeView(page); await expect(inboxList).toContainText("Please review the release checklist."); @@ -302,7 +302,7 @@ test("Inbox excludes generic channel and unowned agent traffic", async ({ }) => { const inboxList = page.getByTestId("home-inbox-list"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectHomeView(page); await expect(inboxList).not.toContainText( @@ -317,7 +317,7 @@ test("Inbox excludes generic channel and unowned agent traffic", async ({ }); test("inbox feed renders resolved author labels", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toContainText("alice"); await expect(page.getByTestId("home-inbox-list")).not.toContainText("You"); @@ -326,7 +326,7 @@ test("inbox feed renders resolved author labels", async ({ page }) => { test("opens sidebar search with the shortcut and loads the exact result", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await focusSidebarSearchWithShortcut(page); @@ -347,7 +347,7 @@ test("opens sidebar search with the shortcut and loads the exact result", async }); test("opens channel matches from search", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await focusSidebarSearchWithShortcut(page); @@ -382,7 +382,7 @@ test("opens channel matches from search", async ({ page }) => { test("global search offers an optional current-channel scope", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page).toHaveURL( @@ -475,7 +475,7 @@ test("global search offers a conversation-specific scope in direct messages", as }) => { const directMessageId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-alice-tyler").click(); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); await expect(page).toHaveURL( @@ -555,7 +555,7 @@ test("global search offers a conversation-specific scope in direct messages", as test("channel find shortcut opens unified search with scope selected", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page).toHaveURL( /#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50$/, @@ -576,7 +576,7 @@ test("channel find shortcut opens unified search with scope selected", async ({ test("global search omits channel scoping when no channel is active", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectHomeView(page); await focusSidebarSearchWithShortcut(page); @@ -590,7 +590,7 @@ test("global search omits channel scoping when no channel is active", async ({ test("global one-character search does not query the relay", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await focusSidebarSearchWithShortcut(page); await page.getByTestId("search-dialog-input").fill("x"); @@ -611,7 +611,7 @@ test("global one-character search does not query the relay", async ({ test("global search tolerates small channel and people typos", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await focusSidebarSearchWithShortcut(page); const input = page.getByTestId("search-dialog-input"); @@ -626,7 +626,7 @@ test("global search tolerates small channel and people typos", async ({ test("global search exposes a larger scrollable result window", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-deep-history").click(); await expect(page.getByTestId("chat-title")).toHaveText("deep-history"); await expect( @@ -667,7 +667,7 @@ test("global search exposes a larger scrollable result window", async ({ }); test("closes sidebar search with Escape", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await focusSidebarSearchWithShortcut(page); await page.getByTestId("search-dialog-input").fill("shipped"); @@ -681,7 +681,7 @@ test("closes sidebar search with Escape", async ({ page }) => { test("search shortcut opens search without disturbing the collapsed sidebar", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("open-search")).toBeVisible(); @@ -716,7 +716,7 @@ test("search shortcut opens search without disturbing the collapsed sidebar", as test("search results use your resolved profile label instead of You", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await focusSidebarSearchWithShortcut(page); @@ -731,7 +731,7 @@ test("search results use your resolved profile label instead of You", async ({ test("opens accessible unjoined channels from search in read-only mode", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await focusSidebarSearchWithShortcut(page); @@ -751,7 +751,7 @@ test("opens accessible unjoined channels from search in read-only mode", async ( }); test("replaces the channel pane when switching channels", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -781,7 +781,7 @@ test("replaces the channel pane when switching channels", async ({ page }) => { test("sends a mocked channel message", async ({ page }) => { const message = `Smoke message ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-input").fill(message); @@ -819,7 +819,7 @@ test("supports multiline drafts with Ctrl+Enter and sends with Enter", async ({ ]; const input = page.getByTestId("message-input"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect( @@ -858,7 +858,7 @@ test("does not shift the timeline when the composer grows", async ({ const input = page.getByTestId("message-input"); const prefix = `Composer growth ${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/spoiler.spec.ts b/desktop/tests/e2e/spoiler.spec.ts index 433a7044ae4..2f56bc36920 100644 --- a/desktop/tests/e2e/spoiler.spec.ts +++ b/desktop/tests/e2e/spoiler.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import type { Page } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -52,7 +52,7 @@ test("no-selection spoiler applies to every composer paragraph", async ({ origin: "http://127.0.0.1:4173", }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -92,7 +92,7 @@ test("image attachments can be marked and sent as hidden spoilers", async ({ page, }) => { await installSpoilerBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -128,7 +128,7 @@ test("text spoiler stays usable while attachment upload is pending", async ({ page, }) => { await installSpoilerBridge(page, { uploadDelayMs: 1_000 }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -163,7 +163,7 @@ test("hidden spoiler links reveal without opening on the first click", async ({ page, }) => { await installSpoilerBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -213,7 +213,7 @@ test("hidden spoilers stay masked on hover and focus until reveal", async ({ page, }) => { await installSpoilerBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -254,7 +254,7 @@ test("masked link inside a hidden spoiler does not leak its URL until revealed", }) => { const SECRET_URL = "https://private.example/leak-path"; await installSpoilerBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -312,7 +312,7 @@ test("non-interactive inbox preview spoilers let row clicks pass through", async page, }) => { await installSpoilerBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); await page.waitForFunction( () => diff --git a/desktop/tests/e2e/stream.spec.ts b/desktop/tests/e2e/stream.spec.ts index e70d2112509..b212a2c09d2 100644 --- a/desktop/tests/e2e/stream.spec.ts +++ b/desktop/tests/e2e/stream.spec.ts @@ -1,4 +1,10 @@ -import { expect, test, type Browser, type Page } from "@playwright/test"; +import { + expect, + test, + type Browser, + type Page, + bootstrapE2ePage, +} from "../helpers/test"; import { installRelayBridge, @@ -191,7 +197,7 @@ test.beforeAll(async () => { test("loads channels from the relay", async ({ page }) => { await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("stream-list")).toContainText("general"); await expect(page.getByTestId("stream-list")).toContainText("random"); @@ -209,8 +215,8 @@ test("loads the home feed from the relay", async ({ browser }) => { try { await installRelayBridge(page, "tyler"); await installRelayBridge(senderPage, "alice"); - await page.goto("/"); - await senderPage.goto("/"); + await bootstrapE2ePage(page, "/"); + await bootstrapE2ePage(senderPage, "/"); await expect(page.getByTestId("home-inbox")).toBeVisible(); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); @@ -242,8 +248,8 @@ test("shows sent inbox replies immediately in the inbox detail pane", async ({ try { await installRelayBridge(page, "tyler"); await installRelayBridge(senderPage, "alice"); - await page.goto("/"); - await senderPage.goto("/"); + await bootstrapE2ePage(page, "/"); + await bootstrapE2ePage(senderPage, "/"); await sendChannelMessage(senderPage, { channelName: "general", @@ -269,7 +275,7 @@ test("creates a relay-backed stream", async ({ page }) => { const channelName = `desktop-e2e-${Date.now()}`; await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill(channelName); await page @@ -285,7 +291,7 @@ test("sends a message through the real relay", async ({ page }) => { const message = `Integration message ${Date.now()}`; await installRelayBridge(page, "tyler"); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-input").fill(message); @@ -310,8 +316,8 @@ test("delivers a message to a second browser context in real time", async ({ await installRelayBridge(pageOne, "tyler"); await installRelayBridge(pageTwo, "alice"); - await pageOne.goto("/"); - await pageTwo.goto("/"); + await bootstrapE2ePage(pageOne, "/"); + await bootstrapE2ePage(pageTwo, "/"); await createAndJoinSharedStream(pageOne, pageTwo, channelName); await pageOne.getByTestId("message-input").fill(message); @@ -343,8 +349,8 @@ test("stays pinned to the latest message when new messages arrive at the bottom" await installRelayBridge(pageOne, "tyler"); await installRelayBridge(pageTwo, "alice"); - await pageOne.goto("/"); - await pageTwo.goto("/"); + await bootstrapE2ePage(pageOne, "/"); + await bootstrapE2ePage(pageTwo, "/"); await createAndJoinSharedStream(pageOne, pageTwo, channelName, { prefix }); await expect .poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom) @@ -388,8 +394,8 @@ test("stays pinned after you send a message and a remote reply arrives right aft await installRelayBridge(pageOne, "tyler"); await installRelayBridge(pageTwo, "alice"); - await pageOne.goto("/"); - await pageTwo.goto("/"); + await bootstrapE2ePage(pageOne, "/"); + await bootstrapE2ePage(pageTwo, "/"); await createAndJoinSharedStream(pageOne, pageTwo, channelName, { prefix }); await expect .poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom) @@ -437,8 +443,8 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({ await installRelayBridge(pageOne, "tyler"); await installRelayBridge(pageTwo, "alice"); - await pageOne.goto("/"); - await pageTwo.goto("/"); + await bootstrapE2ePage(pageOne, "/"); + await bootstrapE2ePage(pageTwo, "/"); await createAndJoinSharedStream(pageOne, pageTwo, channelName, { prefix }); await expect .poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom) @@ -493,8 +499,8 @@ test("keeps scroll position when new messages arrive above the fold", async ({ await installRelayBridge(pageOne, "tyler"); await installRelayBridge(pageTwo, "alice"); - await pageOne.goto("/"); - await pageTwo.goto("/"); + await bootstrapE2ePage(pageOne, "/"); + await bootstrapE2ePage(pageTwo, "/"); const minAwayDistance = 80; await createAndJoinSharedStream(pageOne, pageTwo, channelName, { prefix, diff --git a/desktop/tests/e2e/team-mentions.spec.ts b/desktop/tests/e2e/team-mentions.spec.ts index f6c5cf23580..cea988ef3c4 100644 --- a/desktop/tests/e2e/team-mentions.spec.ts +++ b/desktop/tests/e2e/team-mentions.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -36,7 +36,7 @@ test("owned team mention unfurls into its agents", async ({ page }) => { ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); const composer = page.getByTestId("message-composer"); diff --git a/desktop/tests/e2e/team-snapshot.spec.ts b/desktop/tests/e2e/team-snapshot.spec.ts index 6246c7ac8c0..d141869fd18 100644 --- a/desktop/tests/e2e/team-snapshot.spec.ts +++ b/desktop/tests/e2e/team-snapshot.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge, @@ -24,7 +24,7 @@ async function readCommandLog(page: import("@playwright/test").Page) { } async function gotoAgentsPage(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-agents-view").click(); } diff --git a/desktop/tests/e2e/terminal-wheel.spec.ts b/desktop/tests/e2e/terminal-wheel.spec.ts index a69e974ae13..e80d3b6ba04 100644 --- a/desktop/tests/e2e/terminal-wheel.spec.ts +++ b/desktop/tests/e2e/terminal-wheel.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; const TERM = 'section[aria-label="Buzz Term"]'; @@ -145,7 +145,7 @@ async function reveal(page: Page) { await page.setViewportSize({ width: 1280, height: 800 }); await installTerminalBackend(page); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); // Buzz Term needs a channel: TerminalBootstrap's context is null on Home, so // no session spawns and the chord is inert. @@ -175,7 +175,7 @@ test("project terminal button opens Buzz Term for the repository", async ({ await page.setViewportSize({ width: 1280, height: 800 }); await installTerminalBackend(page); await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-projects-view").click(); await page.getByTestId("projects-section-projects").click(); const projectEntry = page diff --git a/desktop/tests/e2e/thread-focus-mode.spec.ts b/desktop/tests/e2e/thread-focus-mode.spec.ts index 36eb0630baa..696bc9cd4ea 100644 --- a/desktop/tests/e2e/thread-focus-mode.spec.ts +++ b/desktop/tests/e2e/thread-focus-mode.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -173,7 +173,7 @@ test("focus and split preserve reading context and interaction ownership", async }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const rootId = await seedLongThread(page); await page.getByTestId("channel-general").click(); @@ -283,7 +283,7 @@ test("narrow threads do not offer an unavailable layout switch", async ({ }) => { await page.setViewportSize({ width: 860, height: 720 }); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); const rootId = await seedLongThread(page); await page.getByTestId("channel-general").click(); const summary = page.locator( diff --git a/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts b/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts index b4a61c5dafd..6ea0a20d243 100644 --- a/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts +++ b/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; @@ -123,7 +123,7 @@ async function setupRoleplayChannel(page: import("@playwright/test").Page) { }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL); await waitForMockLiveSubscription(page, CHANNEL); diff --git a/desktop/tests/e2e/thread-unread.spec.ts b/desktop/tests/e2e/thread-unread.spec.ts index f4f45d3319a..bfe2a8df5fd 100644 --- a/desktop/tests/e2e/thread-unread.spec.ts +++ b/desktop/tests/e2e/thread-unread.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; @@ -112,7 +112,7 @@ async function expandReply( test.describe("thread unread indicator", () => { test("01-thread-unread-badge", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Open general — catch-up adds mock-general-welcome to authoredRootIds await page.getByTestId("channel-general").click(); @@ -169,7 +169,7 @@ test.describe("thread unread indicator", () => { test("02-thread-new-divider", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -220,7 +220,7 @@ test.describe("thread unread indicator", () => { test("03-thread-badge-casual-browse", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -270,7 +270,7 @@ test.describe("thread unread indicator", () => { test("04-thread-deep-nested-unread", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -403,7 +403,7 @@ test.describe("thread unread indicator", () => { test("05-thread-in-panel-subtree-badge", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -504,7 +504,7 @@ test.describe("thread unread indicator", () => { test("06-in-panel-badge-bumps-on-live-reply", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -569,7 +569,7 @@ test.describe("thread unread indicator", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -664,7 +664,7 @@ test.describe("thread unread indicator", () => { // filter on activeReadAt this fails on the second entry. test("10-thread-badge-survives-channel-reentry", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -717,7 +717,7 @@ test.describe("thread unread indicator", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Open general and read its thread frontier, so the only thing that can be // unread afterward is a NEW reply — not the channel timeline. @@ -763,7 +763,7 @@ test.describe("thread unread indicator", () => { // window. test("12-thread-reply-lights-all-replies-sidebar-badge", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Emit ONE reply whose parent root is NOT in the window (orphan parent id), // so the loaded window is all-replies: no top-level message exists for @@ -808,7 +808,7 @@ test.describe("thread unread indicator", () => { // while staying in general. test("13-thread-badge-clears-on-read-without-reentry", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -872,7 +872,7 @@ test.describe("thread unread indicator", () => { // after the subtree-count fix does it reach 2. Asserting `2` gates both. test("14-mention-only-nested-thread-badge", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -950,7 +950,7 @@ test.describe("thread unread indicator", () => { // which is exactly what the toggle's forced-unread overlay exists to drive. test("15-mark-read-unread-menu-single-toggle", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/threadpane-ultrawide.spec.ts b/desktop/tests/e2e/threadpane-ultrawide.spec.ts index 1c525156308..a04b7df6cc6 100644 --- a/desktop/tests/e2e/threadpane-ultrawide.spec.ts +++ b/desktop/tests/e2e/threadpane-ultrawide.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; @@ -62,7 +62,7 @@ async function emitMockReply( } async function openThread(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); diff --git a/desktop/tests/e2e/timeline-no-shift.spec.ts b/desktop/tests/e2e/timeline-no-shift.spec.ts index 7065d293785..9a2b6318775 100644 --- a/desktop/tests/e2e/timeline-no-shift.spec.ts +++ b/desktop/tests/e2e/timeline-no-shift.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import type { Locator, Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; @@ -206,7 +206,7 @@ test("timeline does not recompute row estimates during ordinary scroll", async ( page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await waitForMockTimelineBridge(page); await page.evaluate(() => { for (let index = 0; index < 120; index += 1) { @@ -265,7 +265,7 @@ test("timeline reserves mixed-media rows before fast scrollback", async ({ contentType: "image/svg+xml", }); }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await waitForMockTimelineBridge(page); await page.evaluate(() => { window.__BUZZ_E2E__ = { @@ -446,7 +446,7 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async testInfo.setTimeout(45_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await waitForMockTimelineBridge(page); await seedNoShiftTimeline(page); @@ -603,7 +603,7 @@ test("thread panel late row reflow keeps the reading reply stable", async ({ testInfo.setTimeout(45_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await waitForMockTimelineBridge(page); const rootId = await page.evaluate(() => { @@ -696,7 +696,7 @@ test("thread panel stays put while replies stream in mid-scroll", async ({ testInfo.setTimeout(45_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await waitForMockTimelineBridge(page); page.on("console", (msg) => { diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts index d26001ba6ef..1a9116b2243 100644 --- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts +++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { readFileSync } from "node:fs"; import { installMockBridge } from "../helpers/bridge"; @@ -116,7 +116,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => }) => { await spoofMacPlatform(page); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Lock the native and webview placements together: removing this explicit // Tauri inset or shifting the nav row regresses the macOS chrome alignment. @@ -150,7 +150,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => // fixed-position native controls. await seedTextScale(page, 0.75); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Confirm the zoomed-out text scale applied without changing the root. await expectTextRemSize(page, "12px"); @@ -168,7 +168,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => // here) grew visibly taller than the fixed-size traffic lights. await seedTextScale(page, 1.5); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await expectTextRemSize(page, "24px"); diff --git a/desktop/tests/e2e/typing-latency.perf.ts b/desktop/tests/e2e/typing-latency.perf.ts index 881a42e28b9..a6b92f036b8 100644 --- a/desktop/tests/e2e/typing-latency.perf.ts +++ b/desktop/tests/e2e/typing-latency.perf.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -118,7 +118,7 @@ test("MEASURE: composer keystroke latency, quiet vs agent-busy channel", async ( }) => { test.setTimeout(240_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); diff --git a/desktop/tests/e2e/unread-pill.spec.ts b/desktop/tests/e2e/unread-pill.spec.ts index fc36a8440fc..45379eca73f 100644 --- a/desktop/tests/e2e/unread-pill.spec.ts +++ b/desktop/tests/e2e/unread-pill.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; @@ -101,7 +101,7 @@ async function scrollTimelineUp(page: import("@playwright/test").Page) { test.describe("unread pill & divider", () => { test("01-unread-pill-visible", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); // Open general, then switch to random so general becomes inactive await page.getByTestId("channel-general").click(); @@ -149,7 +149,7 @@ test.describe("unread pill & divider", () => { test("02-unread-divider-visible", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -173,7 +173,7 @@ test.describe("unread pill & divider", () => { test("03-pill-dismissed-after-scroll", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -205,7 +205,7 @@ test.describe("unread pill & divider", () => { test("04-mark-unread-suppresses-pill", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index e5ce2f21d85..d5802f9c138 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -1,4 +1,4 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Page, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; @@ -226,7 +226,7 @@ async function openReviewWithPostedTimecode( page: Page, commentText = "Neutral accent check", ) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -281,7 +281,7 @@ test("video upload previews use poster frames and inline videos open review mode themeName: VIDEO_REVIEW_ACCENT_THEME, }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -840,7 +840,7 @@ test("inline video hover reveals a timeline without a second play control", asyn }) => { await installVideoReviewHarness(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -937,7 +937,7 @@ test("video replies in threads open the review comments view", async ({ }) => { await installVideoReviewHarness(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -1104,7 +1104,7 @@ test("Inbox preserves bracketed timestamps without video evidence", async ({ }) => { await installVideoReviewHarness(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -1133,7 +1133,7 @@ test("Inbox recognizes reference-style video ancestors with custom alt text", as }) => { await installVideoReviewHarness(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -1175,7 +1175,7 @@ test("message timecodes deterministically open the first attached video", async }) => { await installVideoReviewHarness(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -1243,7 +1243,7 @@ test("message timecodes deterministically open the first attached video", async test("narrow inline videos hide playback speed control", async ({ page }) => { await installVideoReviewHarness(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -1288,7 +1288,7 @@ test("constrained landscape inline videos measure rendered width before showing }) => { await installVideoReviewHarness(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); @@ -1404,7 +1404,7 @@ test("right-click menus expose distinct selectors for links, relay video, and of }) => { await installVideoReviewHarness(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index f7a44f05c72..9178271d2a4 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import type { Locator, Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; @@ -102,7 +102,7 @@ test.describe("list virtualization", () => { page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-pulse-view").click(); // The seeded feed overflows the viewport (30 notes), so the windowed list @@ -138,7 +138,8 @@ test.describe("list virtualization", () => { test("02 — forum deep-link lands on an offscreen reply", async ({ page }) => { await installMockBridge(page); - await page.goto( + await bootstrapE2ePage( + page, `/#/channels/${WATERCOOLER_CHANNEL_ID}/posts/${FORUM_THREAD_ID}?replyId=${FORUM_DEEPLINK_REPLY_ID}`, ); @@ -174,7 +175,7 @@ test.describe("list virtualization", () => { }, ], }); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("channel-members-trigger").click(); @@ -202,7 +203,7 @@ test.describe("list virtualization", () => { }) => { await seedChannelSections(page); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -258,7 +259,10 @@ test.describe("list virtualization", () => { deepHistoryMessageCount: 1_800, channelWindowDelayMs: 300, }); - await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007"); + await bootstrapE2ePage( + page, + "/#/channels/feedf00d-0000-4000-8000-000000000007", + ); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); // Initial bottom positioning can momentarily cross the start threshold. Let @@ -500,7 +504,10 @@ test.describe("list virtualization", () => { deepHistoryMessageCount: 1_800, channelWindowDelayMs: 300, }); - await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007"); + await bootstrapE2ePage( + page, + "/#/channels/feedf00d-0000-4000-8000-000000000007", + ); const timeline = page.getByTestId("message-timeline"); await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); await page.waitForTimeout(1_000); @@ -623,7 +630,7 @@ test.describe("list virtualization", () => { test("thread-heavy history mounts every loaded row", async ({ page }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -675,7 +682,7 @@ test("channel switches settle the last row above the composer", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -725,7 +732,7 @@ test("offscreen rich-row resize preserves the viewport-center anchor", async ({ page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -810,7 +817,7 @@ test("live tail arrivals keep a bottom-pinned virtual timeline settled", async ( page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); @@ -860,7 +867,7 @@ test("live tail arrivals stay buffered while reading and release on jump", async page, }) => { await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); diff --git a/desktop/tests/e2e/voice-settings.spec.ts b/desktop/tests/e2e/voice-settings.spec.ts index 9bd8742ed7f..65b0e6e32cc 100644 --- a/desktop/tests/e2e/voice-settings.spec.ts +++ b/desktop/tests/e2e/voice-settings.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -13,7 +13,7 @@ test.describe("Pocket voice settings", () => { page, }) => { await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "voice"); const card = page.getByTestId("settings-voice"); @@ -77,7 +77,7 @@ test.describe("Pocket voice settings", () => { voicePreferences: ["pocket:eve"], }, }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "voice"); const card = page.getByTestId("settings-voice"); @@ -121,7 +121,7 @@ test.describe("Pocket voice settings", () => { page, }) => { await installMockBridge(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "voice"); await page.getByTestId("pocket-voice-import").click(); @@ -172,7 +172,7 @@ test.describe("Pocket voice settings", () => { page, }) => { await installMockBridge(page, { pocketVoiceImportResult: "cancel" }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "voice"); await expect(page.getByTestId("pocket-voice-selector")).toContainText( @@ -197,7 +197,7 @@ test.describe("Pocket voice settings", () => { page, }) => { await installMockBridge(page, { pocketVoiceImportResult: "invalid" }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openSettings(page, "voice"); await page.getByTestId("pocket-voice-import").click(); diff --git a/desktop/tests/e2e/warm-switch-markdown.perf.ts b/desktop/tests/e2e/warm-switch-markdown.perf.ts index 291a052f14c..82401278b6e 100644 --- a/desktop/tests/e2e/warm-switch-markdown.perf.ts +++ b/desktop/tests/e2e/warm-switch-markdown.perf.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -211,7 +211,7 @@ test("MEASURE: warm channel-switch cost (plain 300-row + markdown-heavy)", async }) => { test.setTimeout(300_000); await installMockBridge(page); - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.waitForFunction( () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", ); diff --git a/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts b/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts index 0115b608bfa..23e61c223ed 100644 --- a/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts +++ b/desktop/tests/e2e/welcome-agent-modal-screenshots.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Page, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge"; @@ -20,7 +20,7 @@ const editorPersona = { }; async function openChannel(page: Page, name: string) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId(`channel-${name}`).click(); await expect(page.getByTestId("chat-title")).toHaveText(name); } @@ -63,7 +63,7 @@ test.describe("welcome and channel agent entry points", () => { }, ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill("Welcome"); await page @@ -106,7 +106,7 @@ test.describe("welcome and channel agent entry points", () => { }, ], }); - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await openCreateChannelDialog(page); await page.getByTestId("create-channel-name").fill("Welcome"); await page.getByTestId("create-channel-permissions").click(); diff --git a/desktop/tests/e2e/where-to-run-config.spec.ts b/desktop/tests/e2e/where-to-run-config.spec.ts index dde0f70392d..eaed57aa2de 100644 --- a/desktop/tests/e2e/where-to-run-config.spec.ts +++ b/desktop/tests/e2e/where-to-run-config.spec.ts @@ -22,7 +22,7 @@ * after the probe resolves; it is pinned at the unit level in * whereToRunIntent.test.mjs (applyProbeResult). */ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; @@ -89,7 +89,7 @@ async function selectRunOnOption( /** Open Advanced in the create-agent dialog and select the mocked provider. */ async function openCreateDialogOnProvider(page: Page) { - await page.goto("/", { waitUntil: "domcontentloaded" }); + await bootstrapE2ePage(page, "/", { waitUntil: "domcontentloaded" }); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); const dialog = page.getByTestId("persona-dialog"); diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index f153afc69c3..e39805a14ce 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { parse as parseYaml } from "yaml"; import { installMockBridge } from "../helpers/bridge"; @@ -11,7 +11,7 @@ async function openCreateWorkflow( page: import("@playwright/test").Page, name: string, ) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-workflows-view").click(); await page.getByRole("button", { name: "Create Workflow" }).click(); const dialog = page.getByRole("dialog", { name: "Create workflow" }); diff --git a/desktop/tests/e2e/workflow-reaction-picker.spec.ts b/desktop/tests/e2e/workflow-reaction-picker.spec.ts index 457b23a4d51..bfb9a459b56 100644 --- a/desktop/tests/e2e/workflow-reaction-picker.spec.ts +++ b/desktop/tests/e2e/workflow-reaction-picker.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -16,7 +16,7 @@ test.beforeEach(async ({ page }) => { }); async function openReactionTrigger(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-workflows-view").click(); await expect(page.getByTestId("workflows-view")).toBeVisible(); diff --git a/desktop/tests/e2e/workflow-title-stability.spec.ts b/desktop/tests/e2e/workflow-title-stability.spec.ts index b9c52de7c00..3ec063e0c64 100644 --- a/desktop/tests/e2e/workflow-title-stability.spec.ts +++ b/desktop/tests/e2e/workflow-title-stability.spec.ts @@ -1,4 +1,10 @@ -import { expect, test, type Locator, type Page } from "@playwright/test"; +import { + expect, + test, + type Locator, + type Page, + bootstrapE2ePage, +} from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -10,7 +16,7 @@ test.beforeEach(async ({ page }) => { const GENERATED_NAME = /^[a-z]+-[a-z]+-[a-z]+$/; async function navigateToWorkflows(page: Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-workflows-view").click(); await expect(page.getByTestId("workflows-view")).toBeVisible(); } diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 2e26f57382e..c430556510c 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; import { installMockBridge } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; @@ -8,7 +8,7 @@ test.beforeEach(async ({ page }) => { }); async function navigateToWorkflows(page: import("@playwright/test").Page) { - await page.goto("/"); + await bootstrapE2ePage(page, "/"); await page.getByTestId("open-workflows-view").click(); await expect(page).toHaveURL(/#\/workflows$/); await expect(page.getByTestId("workflows-view")).toBeVisible(); @@ -164,7 +164,8 @@ test("Escape closes the selected inspector before the workflow modal", async ({ }) => { for (const width of [760, 1280]) { await page.setViewportSize({ width, height: 820 }); - await page.goto( + await bootstrapE2ePage( + page, "/#/workflows?view=create&channel=94a444a4-c0a3-5966-ab05-530c6ddc2301&pane=trigger", ); diff --git a/desktop/tests/helpers/bootstrap.ts b/desktop/tests/helpers/bootstrap.ts new file mode 100644 index 00000000000..f2d553706c3 --- /dev/null +++ b/desktop/tests/helpers/bootstrap.ts @@ -0,0 +1,66 @@ +import type { Page } from "@playwright/test"; + +export const E2E_APP_ORIGIN = "http://127.0.0.1:4173"; + +type BootstrapE2ePageOptions = { + /** Relative route to load after all test setup has been registered. */ + path?: string; + /** Test setup such as bridge or storage init-script registration. */ + beforeNavigate?: () => Promise; + expectedOrigin?: string; +}; + +/** + * Registers page setup and commits the first app navigation before a spec uses + * browser storage or page evaluation. This makes an unavailable preview server + * fail at its source instead of later as an opaque-origin storage error. + */ +export async function bootstrapE2ePage( + page: Page, + pathOrOptions: string | BootstrapE2ePageOptions = {}, + gotoOptions?: Parameters[1], +): Promise { + const { + path = "/", + beforeNavigate, + expectedOrigin = E2E_APP_ORIGIN, + } = typeof pathOrOptions === "string" + ? { path: pathOrOptions } + : pathOrOptions; + await beforeNavigate?.(); + + const target = new URL(path, expectedOrigin).toString(); + let response: Awaited>; + try { + response = await page.goto(target, gotoOptions); + } catch (error) { + throw bootstrapFailure(expectedOrigin, page.url(), error); + } + + if (response && !response.ok()) { + throw bootstrapFailure( + expectedOrigin, + page.url(), + `navigation returned HTTP ${response.status()}`, + ); + } + + if (new URL(page.url()).origin !== expectedOrigin) { + throw bootstrapFailure( + expectedOrigin, + page.url(), + "navigation changed origin", + ); + } +} + +function bootstrapFailure( + expectedOrigin: string, + currentUrl: string, + cause: unknown, +): Error { + return new Error( + `E2E app navigation did not commit to ${expectedOrigin} (current URL: ${currentUrl}). ` + + `Run pnpm test:e2e:smoke or run pnpm build:e2e and check the preview server. Cause: ${String(cause)}`, + ); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ed94e6b1767..0e6ed8444dc 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -2,6 +2,7 @@ import type { Page } from "@playwright/test"; import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; import type { MockManagedAgentSeed } from "../../src/testing/e2eBridge"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; +import { E2E_APP_ORIGIN } from "./bootstrap"; export const TEST_IDENTITIES = { tyler: { @@ -744,7 +745,14 @@ async function seedOnboardingCompletionForKnownIdentities( ...Object.values(TEST_IDENTITIES).map(({ pubkey }) => pubkey), ]; await page.addInitScript( - ({ onboardingPrefix, pubkeys: pubkeysToSeed, relayUrl, welcomePrefix }) => { + ({ + expectedOrigin, + onboardingPrefix, + pubkeys: pubkeysToSeed, + relayUrl, + welcomePrefix, + }) => { + if (location.origin !== expectedOrigin) return; const welcomeScope = encodeURIComponent(relayUrl); for (const pubkey of pubkeysToSeed) { window.localStorage.setItem(`${onboardingPrefix}${pubkey}`, "true"); @@ -755,6 +763,7 @@ async function seedOnboardingCompletionForKnownIdentities( } }, { + expectedOrigin: E2E_APP_ORIGIN, onboardingPrefix: ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX, pubkeys, relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL, @@ -769,7 +778,8 @@ async function seedDefaultCommunity( relayWsUrl?: string, ) { await page.addInitScript( - ({ fallback, identityOverrideKey, relayUrl }) => { + ({ expectedOrigin, fallback, identityOverrideKey, relayUrl }) => { + if (location.origin !== expectedOrigin) return; // If seedActiveIdentity() ran before this script (the normal ordering), // use its pubkey so the community matches the active identity and // migrateMachineOnboardingCompletion's strict voucher accepts it. @@ -810,6 +820,7 @@ async function seedDefaultCommunity( window.localStorage.setItem("buzz-active-community-id", communityId); }, { + expectedOrigin: E2E_APP_ORIGIN, fallback: fallbackPubkey, identityOverrideKey: "buzz:e2e-identity-override.v1", relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL, @@ -819,12 +830,17 @@ async function seedDefaultCommunity( async function seedPreviewFeaturesEnabled(page: Page) { await page.addInitScript( - ({ key, ids }) => { + ({ expectedOrigin, key, ids }) => { + if (location.origin !== expectedOrigin) return; const overrides: Record = {}; for (const id of ids) overrides[id] = true; window.localStorage.setItem(key, JSON.stringify(overrides)); }, - { key: FEATURE_OVERRIDES_STORAGE_KEY, ids: PREVIEW_FEATURE_IDS }, + { + expectedOrigin: E2E_APP_ORIGIN, + key: FEATURE_OVERRIDES_STORAGE_KEY, + ids: PREVIEW_FEATURE_IDS, + }, ); } diff --git a/desktop/tests/helpers/onboarding.ts b/desktop/tests/helpers/onboarding.ts index d7f61bafe69..1903f6f0bb5 100644 --- a/desktop/tests/helpers/onboarding.ts +++ b/desktop/tests/helpers/onboarding.ts @@ -1,5 +1,7 @@ import type { Page } from "@playwright/test"; -import { expect } from "@playwright/test"; + +import { E2E_APP_ORIGIN } from "./bootstrap"; +import { expect } from "./test"; export const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "buzz:e2e-identity-override.v1"; @@ -9,10 +11,15 @@ export async function seedActiveIdentity( identity: { privateKey: string; pubkey: string; username: string }, ) { await page.addInitScript( - ({ identity: nextIdentity, storageKey }) => { + ({ expectedOrigin, identity: nextIdentity, storageKey }) => { + if (location.origin !== expectedOrigin) return; window.localStorage.setItem(storageKey, JSON.stringify(nextIdentity)); }, - { identity, storageKey: E2E_IDENTITY_OVERRIDE_STORAGE_KEY }, + { + expectedOrigin: E2E_APP_ORIGIN, + identity, + storageKey: E2E_IDENTITY_OVERRIDE_STORAGE_KEY, + }, ); } diff --git a/desktop/tests/helpers/test.ts b/desktop/tests/helpers/test.ts new file mode 100644 index 00000000000..8c6487a9976 --- /dev/null +++ b/desktop/tests/helpers/test.ts @@ -0,0 +1,13 @@ +import { expect, test as base } from "@playwright/test"; + +export type * from "@playwright/test"; + +export { bootstrapE2ePage } from "./bootstrap"; + +/** + * Required E2E entrypoint. Register `bootstrapE2ePage(page)` in a `beforeEach` + * after any bridge, route, or init-script setup and before app interaction. + */ +const test = base; + +export { expect, test }; diff --git a/package.json b/package.json index dc31ed0aa0e..f9fec52a317 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "private": true, "packageManager": "pnpm@11.4.0", "scripts": { - "check": "pnpm -r check" + "check": "pnpm -r check", + "test:e2e:smoke": "pnpm --dir desktop test:e2e:smoke" }, "devDependencies": { "@biomejs/biome": "^2.4.6" diff --git a/web/biome.json b/web/biome.json index 12e778e4a92..70c87f83490 100644 --- a/web/biome.json +++ b/web/biome.json @@ -1,3 +1,25 @@ { - "extends": ["../biome.json"] + "extends": ["../biome.json"], + "overrides": [ + { + "includes": ["tests/e2e/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "@playwright/test": { + "importNames": ["test", "base", "default", "*"], + "message": "Import test from ../helpers/test so every E2E test receives automatic origin bootstrap." + } + } + } + } + } + } + } + } + ] } diff --git a/web/package.json b/web/package.json index d932a612c8c..059fa0971dd 100644 --- a/web/package.json +++ b/web/package.json @@ -10,11 +10,13 @@ "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:pubkey-truncation", + "check": "biome check . && pnpm check:e2e-bootstrap && pnpm check:pubkey-truncation", "format": "biome format --write .", "preview": "vite preview", "test:e2e": "pnpm build && playwright test", - "test:e2e:smoke": "pnpm build && playwright test --project=smoke" + "test:e2e:smoke": "pnpm build && playwright test --project=smoke", + "check:e2e-bootstrap": "pnpm test:check-e2e-bootstrap && node ./scripts/check-e2e-bootstrap.mjs", + "test:check-e2e-bootstrap": "node --test ./scripts/check-e2e-bootstrap.test.mjs" }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 9637e91dd31..520587d21b7 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ projects: [ { name: "smoke", - testMatch: ["**/smoke.spec.ts"], + testMatch: ["**/smoke.spec.ts", "**/bootstrap.spec.ts"], use: { ...devices["Desktop Chrome"], }, diff --git a/web/scripts/check-e2e-bootstrap.mjs b/web/scripts/check-e2e-bootstrap.mjs new file mode 100644 index 00000000000..cf4c357f9bb --- /dev/null +++ b/web/scripts/check-e2e-bootstrap.mjs @@ -0,0 +1,983 @@ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const SPEC_PATTERN = /\.(spec|perf)\.ts$/; +const TEST_MEMBERS = new Set(["only", "skip", "fixme", "fail"]); +const CONTAINERS = new Set(["forEach", "map"]); + +function callbackOf(call) { + const callback = call.arguments.at(-1); + return callback && + (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) + ? callback + : undefined; +} + +function bindingContainsPageFixture(name) { + if (ts.isIdentifier(name)) return name.text === "page"; + if (ts.isObjectBindingPattern(name)) + return name.elements.some( + (element) => + element.propertyName?.getText() === "page" || + (!element.propertyName && bindingContainsPageFixture(element.name)), + ); + return false; +} + +function stringKey(expression) { + return ts.isStringLiteral(expression) || + ts.isNoSubstitutionTemplateLiteral(expression) + ? expression.text + : undefined; +} + +function bindingInitializers(callback) { + const bindings = []; + const collect = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer + ) + bindings.push([node.name.text, node.initializer]); + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(node.left) + ) + bindings.push([node.left.text, node.right]); + ts.forEachChild(node, collect); + }; + collect(callback.body); + return bindings; +} + +function callbackUsesPageFixture(callback) { + if ( + callback.parameters.some((parameter) => + bindingContainsPageFixture(parameter.name), + ) + ) + return true; + + const fixtureObjects = new Set(); + for (const parameter of callback.parameters) { + if (ts.isIdentifier(parameter.name)) + fixtureObjects.add(parameter.name.text); + if (ts.isObjectBindingPattern(parameter.name)) { + for (const element of parameter.name.elements) { + if (element.dotDotDotToken && ts.isIdentifier(element.name)) + fixtureObjects.add(element.name.text); + } + } + } + + const bindings = bindingInitializers(callback); + let changed = true; + while (changed) { + changed = false; + for (const [name, initializer] of bindings) { + if ( + ts.isIdentifier(initializer) && + fixtureObjects.has(initializer.text) && + !fixtureObjects.has(name) + ) { + fixtureObjects.add(name); + changed = true; + } + } + } + + let usesPage = false; + const visit = (node) => { + if (usesPage) return; + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + fixtureObjects.has(node.expression.text) && + node.name.text === "page" + ) { + usesPage = true; + return; + } + if ( + ts.isElementAccessExpression(node) && + ts.isIdentifier(node.expression) && + fixtureObjects.has(node.expression.text) && + node.argumentExpression && + stringKey(node.argumentExpression) === "page" + ) { + usesPage = true; + return; + } + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer && + ts.isIdentifier(node.initializer) && + fixtureObjects.has(node.initializer.text) && + bindingContainsPageFixture(node.name) + ) { + usesPage = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(callback.body); + return usesPage || callbackCreatesPage(callback); +} + +function fixturePageReceivers(callback) { + const receivers = new Set(); + const fixtureObjects = new Set(); + for (const parameter of callback.parameters) { + if (ts.isIdentifier(parameter.name)) + fixtureObjects.add(parameter.name.text); + if (ts.isObjectBindingPattern(parameter.name)) { + for (const element of parameter.name.elements) { + if (element.dotDotDotToken && ts.isIdentifier(element.name)) + fixtureObjects.add(element.name.text); + if ( + ts.isIdentifier(element.name) && + (element.propertyName?.getText() === "page" || + (!element.propertyName && element.name.text === "page")) + ) + receivers.add(element.name.text); + } + } + } + const bindings = bindingInitializers(callback); + let changed = true; + while (changed) { + changed = false; + for (const [name, initializer] of bindings) { + if ( + ts.isIdentifier(initializer) && + fixtureObjects.has(initializer.text) && + !fixtureObjects.has(name) + ) { + fixtureObjects.add(name); + changed = true; + } + } + } + const visit = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer && + ts.isIdentifier(node.initializer) && + fixtureObjects.has(node.initializer.text) + ) { + for (const element of node.name.elements) { + if ( + ts.isIdentifier(element.name) && + (element.propertyName?.getText() === "page" || + (!element.propertyName && element.name.text === "page")) + ) + receivers.add(element.name.text); + } + } + ts.forEachChild(node, visit); + }; + visit(callback.body); + return receivers; +} + +function memberExpression(expression) { + const member = unwrapParentheses(expression); + return ts.isPropertyAccessExpression(member) || + ts.isElementAccessExpression(member) + ? member + : undefined; +} + +function propertyName(expression) { + const member = memberExpression(expression); + if (!member) return undefined; + if (ts.isPropertyAccessExpression(member)) return member.name.text; + return member.argumentExpression && + ts.isStringLiteral(member.argumentExpression) + ? member.argumentExpression.text + : undefined; +} + +function receiverName(expression) { + const member = memberExpression(expression); + if (!member) return undefined; + const receiver = unwrapParentheses(member.expression); + return ts.isIdentifier(receiver) ? receiver.text : undefined; +} + +function createdPageReceiver(initializer) { + let expression = unwrapParentheses(initializer); + if (ts.isAwaitExpression(expression)) + expression = unwrapParentheses(expression.expression); + return ( + ts.isCallExpression(expression) && + propertyName(expression.expression) === "newPage" + ); +} + +function createdPageAliases(callback) { + const createdPages = new Set(); + const bindings = bindingInitializers(callback); + let changed = true; + while (changed) { + changed = false; + for (const [name, initializer] of bindings) { + const isCreated = createdPageReceiver(initializer); + const isAlias = + ts.isIdentifier(initializer) && createdPages.has(initializer.text); + if ((isCreated || isAlias) && !createdPages.has(name)) { + createdPages.add(name); + changed = true; + } + } + } + return createdPages; +} + +function callbackCreatesPage(callback) { + return createdPageAliases(callback).size > 0; +} + +function directCall(statement) { + const expression = + ts.isExpressionStatement(statement) || ts.isReturnStatement(statement) + ? statement.expression + : ts.isVariableStatement(statement) && + statement.declarationList.declarations.length === 1 + ? statement.declarationList.declarations[0].initializer + : undefined; + let unwrapped = expression ? unwrapParentheses(expression) : undefined; + if (unwrapped && ts.isAwaitExpression(unwrapped)) + unwrapped = unwrapParentheses(unwrapped.expression); + return unwrapped && ts.isCallExpression(unwrapped) ? unwrapped : undefined; +} + +function calleeIdentifier(call) { + const callee = unwrapParentheses(call.expression); + return ts.isIdentifier(callee) ? callee : undefined; +} + +function readsLocalStorage(node) { + let found = false; + const visit = (child) => { + if (ts.isIdentifier(child) && child.text === "localStorage") found = true; + if (!found) ts.forEachChild(child, visit); + }; + ts.forEachChild(node, visit); + return found; +} + +function unsafeOperationReceiver(call) { + const receiver = receiverName(call.expression); + if (!receiver) return undefined; + const operation = propertyName(call.expression); + if ( + operation !== "goto" && + !(operation === "evaluate" && call.arguments.some(readsLocalStorage)) + ) + return undefined; + return receiver; +} + +function callbackEvents(callback, canonical, helpers, seen = new Set()) { + const parameters = callback.parameters.map((parameter) => + ts.isIdentifier(parameter.name) ? parameter.name.text : undefined, + ); + const events = []; + for (const { statement, conditional } of executableStatements(callback)) { + const call = directCall(statement); + if (!call) continue; + const awaited = awaitedCall(statement); + if ( + !conditional && + awaited && + calleeIdentifier(awaited)?.text === canonical.bootstrap && + awaited.arguments[0] && + ts.isIdentifier(awaited.arguments[0]) + ) { + const index = parameters.indexOf(awaited.arguments[0].text); + if (index >= 0) events.push({ kind: "bootstrap", index }); + } + const receiver = unsafeOperationReceiver(call); + if (receiver) { + const index = parameters.indexOf(receiver); + if (index < 0) return undefined; + events.push({ kind: "unsafe", index }); + } + const helperName = calleeIdentifier(call)?.text; + if (helperName) { + const helper = helpers.get(helperName); + if (!helper) continue; + if (seen.has(helperName)) return undefined; + const nestedSeen = new Set(seen).add(helperName); + const nestedEvents = callbackEvents( + helper, + canonical, + helpers, + nestedSeen, + ); + if (!nestedEvents) return undefined; + for (const event of nestedEvents) { + const argument = call.arguments[event.index]; + if (!argument || !ts.isIdentifier(argument)) return undefined; + const index = parameters.indexOf(argument.text); + if (index < 0) return undefined; + events.push({ + kind: + event.kind === "bootstrap" && (!awaited || conditional) + ? "conditional-bootstrap" + : event.kind, + index, + }); + } + } + } + return events; +} + +function hasUnbootstrappedReceiverOperation( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped = false, +) { + const receivers = new Set([ + ...createdPageAliases(callback), + ...fixturePageReceivers(callback), + ]); + if (!receivers.size) return false; + const bootstrapped = fixtureReceiversBootstrapped + ? fixturePageReceivers(callback) + : new Set(); + for (const { statement, conditional } of executableStatements(callback)) { + const call = directCall(statement); + if (!call) continue; + const awaited = awaitedCall(statement); + if ( + !conditional && + awaited && + calleeIdentifier(awaited)?.text === canonical.bootstrap && + awaited.arguments[0] && + ts.isIdentifier(awaited.arguments[0]) && + receivers.has(awaited.arguments[0].text) + ) + bootstrapped.add(awaited.arguments[0].text); + const receiver = unsafeOperationReceiver(call); + if (receiver && receivers.has(receiver) && !bootstrapped.has(receiver)) + return true; + const helperName = calleeIdentifier(call)?.text; + if (helperName) { + const helper = helpers.get(helperName); + if (!helper) continue; + const events = callbackEvents( + helper, + canonical, + helpers, + new Set([helperName]), + ); + if (!events) return true; + for (const event of events) { + const argument = call.arguments[event.index]; + if (!argument || !ts.isIdentifier(argument)) return true; + const mappedReceiver = argument.text; + if (!receivers.has(mappedReceiver)) return true; + if (event.kind === "unsafe" && !bootstrapped.has(mappedReceiver)) + return true; + if (!conditional && event.kind === "bootstrap") + bootstrapped.add(mappedReceiver); + } + } + } + return false; +} + +function memberOf(call) { + return ts.isPropertyAccessExpression(call.expression) && + ts.isIdentifier(call.expression.expression) && + call.expression.expression.text === "test" + ? call.expression.name.text + : undefined; +} + +function helperImport(file, filename, canonicalHelperPath) { + for (const statement of file.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) + ) + continue; + const resolved = path.resolve( + path.dirname(filename), + `${statement.moduleSpecifier.text}.ts`, + ); + const canonical = canonicalHelperPath + ? resolved === canonicalHelperPath + : statement.moduleSpecifier.text === "../helpers/test"; + if (!canonical) continue; + const elements = statement.importClause?.namedBindings; + if (!elements || !ts.isNamedImports(elements)) return undefined; + const names = new Map( + elements.elements.map((element) => [ + element.name.text, + element.propertyName?.text ?? element.name.text, + ]), + ); + if ( + names.get("test") === "test" && + names.get("bootstrapE2ePage") === "bootstrapE2ePage" + ) + return { test: "test", bootstrap: "bootstrapE2ePage" }; + } +} + +function bindingNameShadows(name, canonical) { + if (ts.isIdentifier(name)) + return name.text === canonical.test || name.text === canonical.bootstrap; + return name.elements.some( + (element) => + !ts.isOmittedExpression(element) && + bindingNameShadows(element.name, canonical), + ); +} + +function hasShadowingDeclaration(file, canonical) { + let shadowed = false; + const visit = (node) => { + if (shadowed) return; + if ( + ts.isVariableDeclaration(node) && + bindingNameShadows(node.name, canonical) + ) { + shadowed = true; + return; + } + if ( + (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && + node.name && + ts.isIdentifier(node.name) && + (node.name.text === canonical.test || + node.name.text === canonical.bootstrap) + ) { + shadowed = true; + return; + } + if ( + (ts.isArrowFunction(node) || + ts.isFunctionExpression(node) || + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node)) && + node.parameters.some((parameter) => + bindingNameShadows(parameter.name, canonical), + ) + ) { + shadowed = true; + return; + } + ts.forEachChild(node, visit); + }; + for (const statement of file.statements) { + if (ts.isImportDeclaration(statement)) continue; + visit(statement); + } + return shadowed; +} + +function collectModuleHelpers(file) { + const helpers = new Map(); + for (const statement of file.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) + helpers.set(statement.name.text, statement); + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.initializer && + (ts.isArrowFunction(declaration.initializer) || + ts.isFunctionExpression(declaration.initializer)) + ) + helpers.set(declaration.name.text, declaration.initializer); + } + } + } + return helpers; +} + +function awaitedCall(statement) { + const expression = + ts.isExpressionStatement(statement) || ts.isReturnStatement(statement) + ? statement.expression + : ts.isVariableStatement(statement) && + statement.declarationList.declarations.length === 1 + ? statement.declarationList.declarations[0].initializer + : undefined; + const unwrapped = expression ? unwrapParentheses(expression) : undefined; + if (!unwrapped || !ts.isAwaitExpression(unwrapped)) return undefined; + const call = unwrapParentheses(unwrapped.expression); + return ts.isCallExpression(call) ? call : undefined; +} + +function unwrapParentheses(expression) { + while (ts.isParenthesizedExpression(expression)) + expression = expression.expression; + return expression; +} + +function isStaticallyEmptyLoop(statement) { + if (ts.isForStatement(statement)) { + const condition = statement.condition + ? unwrapParentheses(statement.condition) + : undefined; + return condition?.kind === ts.SyntaxKind.FalseKeyword; + } + if (ts.isWhileStatement(statement)) + return ( + unwrapParentheses(statement.expression).kind === + ts.SyntaxKind.FalseKeyword + ); + if (ts.isForOfStatement(statement)) { + const expression = guaranteedIterableExpression(statement); + return ( + (ts.isArrayLiteralExpression(expression) && + !expression.elements.length) || + (ts.isStringLiteral(expression) && !expression.text.length) + ); + } + if (ts.isForInStatement(statement)) { + const expression = unwrapParentheses(statement.expression); + return ( + ts.isObjectLiteralExpression(expression) && !expression.properties.length + ); + } + return false; +} + +function unwrapConstAssertion(expression) { + expression = unwrapParentheses(expression); + return ts.isAsExpression(expression) && expression.type.getText() === "const" + ? unwrapParentheses(expression.expression) + : expression; +} + +function numericLiteral(expression) { + expression = unwrapParentheses(expression); + return ts.isNumericLiteral(expression) ? Number(expression.text) : undefined; +} + +function staticallyTrueComparison(expression, bindings = new Map()) { + expression = unwrapParentheses(expression); + if (!ts.isBinaryExpression(expression)) return false; + const left = ts.isIdentifier(expression.left) + ? bindings.get(expression.left.text) + : numericLiteral(expression.left); + const right = ts.isIdentifier(expression.right) + ? bindings.get(expression.right.text) + : numericLiteral(expression.right); + if (left === undefined || right === undefined) return false; + switch (expression.operatorToken.kind) { + case ts.SyntaxKind.LessThanToken: + return left < right; + case ts.SyntaxKind.LessThanEqualsToken: + return left <= right; + case ts.SyntaxKind.GreaterThanToken: + return left > right; + case ts.SyntaxKind.GreaterThanEqualsToken: + return left >= right; + case ts.SyntaxKind.EqualsEqualsToken: + case ts.SyntaxKind.EqualsEqualsEqualsToken: + return left === right; + default: + return false; + } +} + +function precedingConstInitializer(statement, name) { + const parent = statement.parent; + if (!ts.isBlock(parent)) return undefined; + for (const sibling of parent.statements) { + if (sibling === statement) return undefined; + if (!ts.isVariableStatement(sibling)) continue; + if (!(sibling.declarationList.flags & ts.NodeFlags.Const)) continue; + for (const declaration of sibling.declarationList.declarations) + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === name && + declaration.initializer + ) + return declaration.initializer; + } + return undefined; +} + +function guaranteedIterableExpression(statement) { + const expression = unwrapConstAssertion(statement.expression); + if (!ts.isIdentifier(expression)) return expression; + const initializer = precedingConstInitializer(statement, expression.text); + return initializer ? unwrapConstAssertion(initializer) : expression; +} + +function isConcreteObjectContributor(property) { + if (ts.isSpreadAssignment(property)) return false; + if (ts.isComputedPropertyName(property.name)) { + const expression = unwrapParentheses(property.name.expression); + return ( + ts.isStringLiteral(expression) || + ts.isNoSubstitutionTemplateLiteral(expression) || + ts.isNumericLiteral(expression) + ); + } + return ( + !ts.isPropertyAssignment(property) || property.name.text !== "__proto__" + ); +} + +function isGuaranteedIteration(statement) { + if (ts.isDoStatement(statement)) return true; + if (ts.isWhileStatement(statement)) + return isStaticallyTrue(statement.expression); + if (ts.isForOfStatement(statement)) { + const expression = guaranteedIterableExpression(statement); + return ( + (ts.isArrayLiteralExpression(expression) && + expression.elements.some((element) => !ts.isSpreadElement(element))) || + (ts.isStringLiteral(expression) && expression.text.length > 0) + ); + } + if (ts.isForInStatement(statement)) { + const expression = unwrapParentheses(statement.expression); + return ( + ts.isObjectLiteralExpression(expression) && + expression.properties.some(isConcreteObjectContributor) + ); + } + if (!ts.isForStatement(statement)) return false; + if (!statement.condition || isStaticallyTrue(statement.condition)) + return true; + const numericBindings = new Map(); + if ( + statement.initializer && + ts.isVariableDeclarationList(statement.initializer) + ) + for (const declaration of statement.initializer.declarations) { + const value = declaration.initializer + ? numericLiteral(declaration.initializer) + : undefined; + if (ts.isIdentifier(declaration.name) && value !== undefined) + numericBindings.set(declaration.name.text, value); + } + return staticallyTrueComparison(statement.condition, numericBindings); +} + +function isStaticallyFalse(expression) { + return unwrapParentheses(expression).kind === ts.SyntaxKind.FalseKeyword; +} + +function isStaticallyTrue(expression) { + return unwrapParentheses(expression).kind === ts.SyntaxKind.TrueKeyword; +} + +function* executableStatements(callback) { + const statements = ts.isBlock(callback.body) + ? callback.body.statements + : [ts.factory.createExpressionStatement(callback.body)]; + function* walk(statement, conditional = false) { + if (ts.isBlock(statement)) { + for (const child of statement.statements) yield* walk(child, conditional); + return; + } + if (ts.isIfStatement(statement)) { + if (!isStaticallyFalse(statement.expression)) + yield* walk(statement.thenStatement, true); + if (statement.elseStatement && !isStaticallyTrue(statement.expression)) + yield* walk(statement.elseStatement, true); + return; + } + if (ts.isSwitchStatement(statement)) { + for (const clause of statement.caseBlock.clauses) + for (const child of clause.statements) yield* walk(child, true); + return; + } + if (ts.isTryStatement(statement)) { + yield* walk(statement.tryBlock, conditional); + if (statement.catchClause) yield* walk(statement.catchClause.block, true); + if (statement.finallyBlock) + yield* walk(statement.finallyBlock, conditional); + return; + } + if ( + (ts.isForStatement(statement) || + ts.isForInStatement(statement) || + ts.isForOfStatement(statement) || + ts.isWhileStatement(statement) || + ts.isDoStatement(statement)) && + !isStaticallyEmptyLoop(statement) + ) { + yield* walk( + statement.statement, + conditional || !isGuaranteedIteration(statement), + ); + return; + } + yield { statement, conditional }; + } + for (const statement of statements) yield* walk(statement); +} + +function bootstrapReceivers( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped = false, +) { + const receivers = fixtureReceiversBootstrapped + ? fixturePageReceivers(callback) + : new Set(); + for (const { statement, conditional } of executableStatements(callback)) { + const call = awaitedCall(statement); + if (!call) continue; + if ( + !conditional && + calleeIdentifier(call)?.text === canonical.bootstrap && + call.arguments[0] && + ts.isIdentifier(call.arguments[0]) + ) + receivers.add(call.arguments[0].text); + const helperName = calleeIdentifier(call)?.text; + if (!conditional && helperName) { + const helper = helpers.get(helperName); + if (helper) { + const events = callbackEvents( + helper, + canonical, + helpers, + new Set([helperName]), + ); + if (!events) return undefined; + for (const event of events) { + if (event.kind !== "bootstrap") continue; + const argument = call.arguments[event.index]; + if (argument && ts.isIdentifier(argument)) + receivers.add(argument.text); + } + } + } + } + return receivers; +} + +function callbackBootstraps( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped = false, +) { + if ( + hasUnbootstrappedReceiverOperation( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped, + ) + ) + return false; + const receivers = bootstrapReceivers( + callback, + canonical, + helpers, + fixtureReceiversBootstrapped, + ); + if (!receivers) return false; + return ( + [...fixturePageReceivers(callback)].every((receiver) => + receivers.has(receiver), + ) && receivers.size > 0 + ); +} + +function makeScope(parent) { + return { parent, hooks: [], tests: [], children: [] }; +} + +function hasPageFixtureTest(file) { + let found = false; + const visit = (node) => { + if (found) return; + if (ts.isCallExpression(node)) { + const callback = callbackOf(node); + if ( + callback && + ((ts.isIdentifier(node.expression) && + node.expression.text === "test") || + (ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === "test" && + (TEST_MEMBERS.has(node.expression.name.text) || + node.expression.name.text === "beforeEach"))) && + callbackUsesPageFixture(callback) + ) { + found = true; + return; + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(file, visit); + return found; +} + +function scanFile(file, canonical) { + const helpers = collectModuleHelpers(file); + const root = makeScope(undefined); + const isTestCall = (call) => + ts.isIdentifier(call.expression) && call.expression.text === canonical.test; + const isMember = (call, name) => memberOf(call) === name; + + const scanStatements = (statements, scope) => { + for (const statement of statements) { + if (ts.isBlock(statement)) { + scanStatements(statement.statements, scope); + continue; + } + if ( + ts.isForStatement(statement) || + ts.isForInStatement(statement) || + ts.isForOfStatement(statement) + ) { + scanStatements( + ts.isBlock(statement.statement) + ? statement.statement.statements + : [statement.statement], + scope, + ); + continue; + } + if ( + !ts.isExpressionStatement(statement) || + !ts.isCallExpression(statement.expression) + ) + continue; + const call = statement.expression; + const callback = callbackOf(call); + if (isMember(call, "describe") && callback) { + const child = makeScope(scope); + scope.children.push(child); + if (ts.isBlock(callback.body)) + scanStatements(callback.body.statements, child); + continue; + } + if (isMember(call, "beforeEach") && callback) { + scope.hooks.push(callbackBootstraps(callback, canonical, helpers)); + continue; + } + if ( + isTestCall(call) || + (memberOf(call) && TEST_MEMBERS.has(memberOf(call))) + ) { + if (callback) + scope.tests.push({ + bootstraps: callbackBootstraps(callback, canonical, helpers), + bootstrapsWithFixtureHook: callbackBootstraps( + callback, + canonical, + helpers, + true, + ), + usesPage: callbackUsesPageFixture(callback), + }); + continue; + } + if ( + ts.isPropertyAccessExpression(call.expression) && + CONTAINERS.has(call.expression.name.text) && + callback && + ts.isArrayLiteralExpression(call.expression.expression) + ) { + if (ts.isBlock(callback.body)) + scanStatements(callback.body.statements, scope); + } + } + }; + scanStatements(file.statements, root); + return root; +} + +function everyTestCovered(scope, inheritedHook = false) { + const coveredByHook = inheritedHook || scope.hooks.some(Boolean); + return ( + scope.tests.every( + (test) => + !test.usesPage || + test.bootstraps || + (coveredByHook && test.bootstrapsWithFixtureHook), + ) && scope.children.every((child) => everyTestCovered(child, coveredByHook)) + ); +} + +function testCount(scope) { + return ( + scope.tests.filter((test) => test.usesPage).length + + scope.children.reduce((count, child) => count + testCount(child), 0) + ); +} + +export function checkSource( + source, + filename = "fixture.spec.ts", + canonicalHelperPath, +) { + const file = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + ); + const canonical = helperImport(file, filename, canonicalHelperPath); + if (!canonical) + return "must directly import unaliased test and bootstrapE2ePage from the canonical tests/helpers/test module"; + if (hasShadowingDeclaration(file, canonical)) + return "must not shadow canonical test or bootstrapE2ePage imports"; + const scope = scanFile(file, canonical); + if (!testCount(scope)) return "does not register a browser test"; + if (!everyTestCovered(scope)) + return "must structurally await bootstrapE2ePage for every test, directly or through an applicable ancestor test.beforeEach"; +} + +function specFiles(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const child = path.join(directory, entry.name); + if (entry.isDirectory()) return specFiles(child); + return entry.isFile() && SPEC_PATTERN.test(entry.name) ? [child] : []; + }); +} + +export function runCheck(projectRoot) { + const e2eRoot = path.join(projectRoot, "tests/e2e"); + const canonicalHelperPath = path.join(projectRoot, "tests/helpers/test.ts"); + return specFiles(e2eRoot).flatMap((filename) => { + const relative = path.relative(e2eRoot, filename); + const source = fs.readFileSync(filename, "utf8"); + const file = ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + ); + if (!hasPageFixtureTest(file)) return []; + const violation = checkSource(source, filename, canonicalHelperPath); + return violation ? [`${relative}: ${violation}`] : []; + }); +} + +if (process.argv[1] === import.meta.filename) { + const violations = runCheck(path.resolve(import.meta.dirname, "..")); + if (violations.length) { + console.error( + `E2E specs must register bootstrapE2ePage after their setup:\n${violations.join("\n")}`, + ); + process.exit(1); + } +} diff --git a/web/scripts/check-e2e-bootstrap.test.mjs b/web/scripts/check-e2e-bootstrap.test.mjs new file mode 100644 index 00000000000..b0c31c9d13e --- /dev/null +++ b/web/scripts/check-e2e-bootstrap.test.mjs @@ -0,0 +1,1342 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const projects = [ + ["desktop", "../scripts/check-e2e-bootstrap.mjs"], + ["web", "../scripts/check-e2e-bootstrap.mjs"], +]; +const imports = 'import { test, bootstrapE2ePage } from "../helpers/test";'; +const spec = (body) => + `${imports}\ntest("x", async ({ page }) => { ${body} });`; + +for (const [project, checkerPath] of projects) { + const { checkSource, runCheck } = await import(checkerPath); + const accepts = (source) => assert.equal(checkSource(source), undefined); + const rejects = (source) => assert.notEqual(checkSource(source), undefined); + + test(`${project}: accepts direct, hook, and beforeNavigate bootstrap patterns`, () => { + accepts(spec("await bootstrapE2ePage(page);")); + accepts( + `${imports}\ntest.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); });\ntest("x", async ({ page }) => { await page.title(); });`, + ); + accepts( + spec( + "await bootstrapE2ePage(page, { beforeNavigate: async () => page.addInitScript(() => {}) });", + ), + ); + }); + + test(`${project}: accepts module helpers and ratified containers`, () => { + accepts( + `${imports}\nasync function open(page) { return await bootstrapE2ePage(page); }\ntest("x", async ({ page }) => { const response = await open(page); void response; });`, + ); + accepts( + spec( + "try { await bootstrapE2ePage(page); } finally { await page.title(); }", + ), + ); + accepts( + spec( + "try { await page.title(); } finally { await bootstrapE2ePage(page); }", + ), + ); + accepts( + spec("for (const attempt of [0, 1]) { await bootstrapE2ePage(page); }"), + ); + accepts( + spec( + "for (let attempt = 0; attempt < 2; attempt += 1) { await bootstrapE2ePage(page); }", + ), + ); + }); + + test(`${project}: classifies indirect page fixture access and requires bootstrap`, () => { + const cases = [ + [ + "whole fixture object", + "fixtures", + "await fixtures.page.title();", + "const { page } = fixtures; await bootstrapE2ePage(page);", + ], + [ + "object rest", + "{ ...rest }", + "await rest.page.title();", + "const { page } = rest; await bootstrapE2ePage(page);", + ], + [ + "body destructuring", + "fixtures", + "const { page } = fixtures; await page.title();", + "const { page } = fixtures; await bootstrapE2ePage(page);", + ], + ]; + for (const [name, parameter, body, bootstrap] of cases) { + const source = `${imports}\ntest("${name}", async (${parameter}) => { ${body} });`; + assert.equal( + checkSource(source), + "must structurally await bootstrapE2ePage for every test, directly or through an applicable ancestor test.beforeEach", + ); + accepts( + `${imports}\ntest("${name}", async (${parameter}) => { ${bootstrap} ${body} });`, + ); + } + }); + + test(`${project}: follows bounded fixture-object aliases and element access`, () => { + const cases = [ + "const alias = fixtures; await alias.page.title();", + 'await fixtures["page"].title();', + "const first = fixtures; const second = first; const { page } = second; await page.title();", + ]; + for (const body of cases) { + const source = `${imports}\ntest("x", async (fixtures) => { ${body} });`; + assert.equal( + checkSource(source), + "must structurally await bootstrapE2ePage for every test, directly or through an applicable ancestor test.beforeEach", + ); + accepts( + `${imports}\ntest("x", async (fixtures) => { const { page } = fixtures; await bootstrapE2ePage(page); ${body} });`, + ); + } + }); + + test(`${project}: requires bootstrap for each created page receiver`, () => { + const unsafe = `${imports} + test("x", async ({ page, context }) => { + await bootstrapE2ePage(page); + const second = await context.newPage(); + await second.goto("/"); + });`; + assert.equal( + checkSource(unsafe), + "must structurally await bootstrapE2ePage for every test, directly or through an applicable ancestor test.beforeEach", + ); + rejects(`${imports} + test("x", async ({ page, context }) => { + const second = await context.newPage(); + await bootstrapE2ePage(second, "/"); + await page.title(); + });`); + accepts(`${imports} + test("x", async ({ page, context }) => { + const second = await context.newPage(); + await bootstrapE2ePage(page, "/"); + await bootstrapE2ePage(second, "/"); + await page.title(); + });`); + rejects(`${imports} + test("x", async ({ context }) => { + const second = await context.newPage(); + await second.goto("/"); + });`); + rejects(`${imports} + test("x", async ({ context }) => { + const second = await context.newPage(); + await second.goto("/"); + await bootstrapE2ePage(second, "/"); + });`); + rejects(`${imports} + test("x", async ({ page, context }) => { + await bootstrapE2ePage(page); + const second = await context.newPage(); + bootstrapE2ePage(second, "/"); + await second.goto("/"); + });`); + accepts(`${imports} + test("x", async ({ page, context }) => { + await bootstrapE2ePage(page); + const second = await context.newPage(); + await bootstrapE2ePage(second, "/"); + });`); + rejects(`${imports} + test("x", async ({ context }) => { + const second = await context.newPage(); + await second.goto("/"); + await bootstrapE2ePage(second, "/"); + await second.goto("/later"); + });`); + accepts(`${imports} + test("x", async ({ context }) => { + const second = await context.newPage(); + await bootstrapE2ePage(second, "/"); + await second.goto("/"); + await bootstrapE2ePage(second, "/later"); + await second.goto("/later"); + });`); + + rejects(`${imports} + async function navigate(target) { await target.goto("/"); } + test("fixture navigation before bootstrap", async ({ page }) => { + await navigate(page); + await bootstrapE2ePage(page, "/"); + });`); + rejects(`${imports} + async function readStorage(target) { await target.evaluate(() => localStorage.getItem("x")); } + test("fixture storage before bootstrap", async ({ page }) => { + await readStorage(page); + await bootstrapE2ePage(page, "/"); + });`); + accepts(`${imports} + test("fixture setup before bootstrap", async ({ page }) => { + await page.addInitScript(() => {}); + await bootstrapE2ePage(page, "/"); + });`); + + for (const body of [ + 'const second = await context.newPage(); if (false) await bootstrapE2ePage(second); await second.goto("/");', + 'const second = await context.newPage(); async function never() { await bootstrapE2ePage(second); } await second.goto("/");', + 'let second; second = await context.newPage(); await second.goto("/");', + 'const second = await context.newPage(); second.goto("/");', + ]) + rejects( + `${imports}\n test("x", async ({ context }) => { ${body} });`, + ); + }); + + test(`${project}: preserves helper event order and actual-argument mapping`, () => { + rejects(`${imports} + async function unsafeThenBootstrap(target) { + await target.goto("/"); + await bootstrapE2ePage(target); + } + test("x", async ({ page }) => { await unsafeThenBootstrap(page); });`); + accepts(`${imports} + async function bootstrapThenUnsafe(target) { + await bootstrapE2ePage(target); + await target.goto("/"); + } + test("x", async ({ page }) => { await bootstrapThenUnsafe(page); });`); + rejects(`${imports} + async function inner(safeTarget, unsafeTarget) { + await bootstrapE2ePage(safeTarget); + await unsafeTarget.goto("/"); + } + async function outer(unsafeTarget, other) { await inner(other, unsafeTarget); } + test("x", async ({ page }) => { + const other = {}; + await outer(page, other); + });`); + accepts(`${imports} + async function inner(target) { await bootstrapE2ePage(target); } + async function outer(unused, target) { await inner(target); } + test("x", async ({ page }) => { await outer(undefined, page); });`); + }); + + test(`${project}: conservatively checks executable conditional branches`, () => { + rejects( + spec( + 'if (condition) await page.goto("/"); await bootstrapE2ePage(page);', + ), + ); + rejects( + spec( + 'switch (mode) { case "go": await page.goto("/"); break; default: break; } await bootstrapE2ePage(page);', + ), + ); + accepts( + spec( + 'await bootstrapE2ePage(page); if (condition) await page.goto("/");', + ), + ); + accepts( + spec( + 'await bootstrapE2ePage(page); switch (mode) { case "go": await page.goto("/"); break; default: break; }', + ), + ); + rejects( + spec( + 'if (condition) await bootstrapE2ePage(page); await page.goto("/");', + ), + ); + accepts( + spec('if (false) await page.goto("/"); await bootstrapE2ePage(page);'), + ); + }); + + test(`${project}: keeps hook coverage receiver-aware`, () => { + rejects(`${imports} + test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); + test("x", async ({ context }) => { + const second = await context.newPage(); + await second.goto("/"); + });`); + accepts(`${imports} + test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); + test("x", async ({ page }) => { await page.title(); });`); + accepts(`${imports} + test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); + test("x", async ({ context }) => { + const second = await context.newPage(); + await bootstrapE2ePage(second); + await second.goto("/"); + });`); + }); + + test(`${project}: conservatively checks catch work`, () => { + rejects( + spec( + 'try { await page.title(); } catch { await page.goto("/"); } await bootstrapE2ePage(page);', + ), + ); + accepts( + spec( + 'await bootstrapE2ePage(page); try { await page.title(); } catch { await page.goto("/"); }', + ), + ); + rejects( + spec( + 'try { await page.title(); } catch { await bootstrapE2ePage(page); } await page.goto("/");', + ), + ); + }); + + test(`${project}: propagates unawaited helper unsafe events`, () => { + rejects( + `${imports} async function read(target) { await target.evaluate(() => localStorage.getItem("x")); } test("x", async ({ page }) => { read(page); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { go(page); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function boot(target) { await bootstrapE2ePage(target); } test("x", async ({ page }) => { boot(page); await page.goto("/"); });`, + ); + accepts( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { await bootstrapE2ePage(page); await go(page); });`, + ); + accepts( + `${imports} async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { setup(page); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: preserves safety events through parenthesized helper calls`, () => { + for (const call of ["(go(page));", "await (go(page));"]) + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { ${call} await bootstrapE2ePage(page); });`, + ); + for (const call of ["(go(page));", "await (go(page));"]) + accepts( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { await bootstrapE2ePage(page); ${call} });`, + ); + }); + + test(`${project}: preserves safety events through parenthesized helper callees`, () => { + for (const call of ["(go)(page);", "await (go)(page);"]) + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { ${call} await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { await (go(page)); await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { await (setup)(page); await (bootstrapE2ePage)(page); });`, + ); + }); + + test(`${project}: preserves supported operations through member parentheses`, () => { + for (const operation of [ + 'await (page.goto)("/");', + 'await (page).goto("/");', + 'await ((page)["goto"])("/");', + 'await (page.evaluate)(() => localStorage.getItem("x"));', + ]) + rejects(spec(`${operation} await bootstrapE2ePage(page);`)); + for (const operation of [ + 'await (target.goto)("/");', + 'await (target).goto("/");', + 'await (target.evaluate)(() => localStorage.getItem("x"));', + ]) + rejects( + `${imports} async function unsafe(target) { ${operation} } test("x", async ({ page }) => { await unsafe(page); await bootstrapE2ePage(page); });`, + ); + accepts(spec('await bootstrapE2ePage(page); await (page.goto)("/");')); + }); + + test(`${project}: recognizes parenthesized created-page operations`, () => { + for (const creation of [ + "await (context.newPage)()", + "await (context).newPage()", + 'await ((context)["newPage"])()', + "await ((context.newPage)())", + ]) + rejects( + `${imports} test("x", async ({ context }) => { const second = ${creation}; await second.goto("/"); });`, + ); + accepts( + `${imports} test("x", async ({ context }) => { const second = await (context.newPage)(); await bootstrapE2ePage(second); await (second.goto)("/"); });`, + ); + }); + + test(`${project}: grants loop bootstrap credit only for guaranteed iterations`, () => { + for (const loop of [ + "for (const value of values) await bootstrapE2ePage(page);", + "for (const key in values) await bootstrapE2ePage(page);", + "for (const value of [...[]]) await bootstrapE2ePage(page);", + "for (const value of [...values]) await bootstrapE2ePage(page);", + "for (const key in { ...{} }) await bootstrapE2ePage(page);", + "for (const key in { ...values }) await bootstrapE2ePage(page);", + "for (const key in { __proto__: null }) await bootstrapE2ePage(page);", + 'for (const key in { "__proto__": null }) await bootstrapE2ePage(page);', + "for (const key in { [Symbol()]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [Symbol.iterator]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [key]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [getKey()]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [Symbol.iterator]() {} }) await bootstrapE2ePage(page);", + "for (const key in { get [Symbol.iterator]() { return 1 } }) await bootstrapE2ePage(page);", + "for (const key in { set [Symbol.iterator](value) {} }) await bootstrapE2ePage(page);", + "for (const key in { [key]() {} }) await bootstrapE2ePage(page);", + "for (const key in { get [key]() { return 1 } }) await bootstrapE2ePage(page);", + "for (const key in { set [key](value) {} }) await bootstrapE2ePage(page);", + "while (condition) await bootstrapE2ePage(page);", + "for (; condition;) await bootstrapE2ePage(page);", + ]) + rejects(spec(`${loop} await page.goto("/");`)); + rejects( + spec( + 'let values = []; for (const value of values) await bootstrapE2ePage(page); values = [1]; await page.goto("/");', + ), + ); + accepts( + spec( + 'const values = [1]; for (const value of values) await bootstrapE2ePage(page); await page.goto("/");', + ), + ); + rejects( + spec( + 'const values = [...[]]; for (const value of values) await bootstrapE2ePage(page); await page.goto("/");', + ), + ); + for (const loop of [ + "for (const value of [1]) await bootstrapE2ePage(page);", + "for (const value of [...[], 1]) await bootstrapE2ePage(page);", + "for (const key in { ...{}, x: 1 }) await bootstrapE2ePage(page);", + 'for (const key in { ["__proto__"]: null }) await bootstrapE2ePage(page);', + "for (const key in { [`x`]: 1 }) await bootstrapE2ePage(page);", + "for (const key in { [1]: 1 }) await bootstrapE2ePage(page);", + 'for (const key in { ["x"]() {} }) await bootstrapE2ePage(page);', + 'for (const key in { get ["x"]() { return 1 } }) await bootstrapE2ePage(page);', + 'for (const key in { set ["x"](value) {} }) await bootstrapE2ePage(page);', + "for (const key in { __proto__: null, x: 1 }) await bootstrapE2ePage(page);", + 'for (const value of "x") await bootstrapE2ePage(page);', + "for (const key in { x: 1 }) await bootstrapE2ePage(page);", + "for (;;) { await bootstrapE2ePage(page); break; }", + "while (true) { await bootstrapE2ePage(page); break; }", + "do { await bootstrapE2ePage(page); } while (condition);", + ]) + accepts(spec(`${loop} await page.goto("/");`)); + rejects( + spec( + 'for (const value of values) await page.goto("/"); await bootstrapE2ePage(page);', + ), + ); + }); + + test(`${project}: invalidates unsafe events born on unmapped helper receivers`, () => { + for (const operation of [ + 'const alias = target; await alias.goto("/");', + 'const alias = target; await alias.evaluate(() => localStorage.getItem("x"));', + 'const unrelated = {}; await unrelated.goto("/");', + ]) + rejects( + `${imports} async function unsafe(target) { ${operation} } test("x", async ({ page }) => { await unsafe(page); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function unsafe(target) { await target.goto("/"); } test("x", async ({ page }) => { await unsafe(page); await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function setup(target) { const alias = target; await alias.addInitScript(() => {}); } test("x", async ({ page }) => { await setup(page); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: fails closed when callback helper events cannot map to receivers`, () => { + for (const actual of ["alias", "unrelated"]) + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { const alias = page; const unrelated = {}; await go(${actual}); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { await go(page); await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { const alias = page; await setup(alias); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: classifies bounded element-access operations`, () => { + rejects(spec('await page["goto"]("/"); await bootstrapE2ePage(page);')); + rejects( + spec( + 'await page["evaluate"](() => localStorage.getItem("x")); await bootstrapE2ePage(page);', + ), + ); + rejects( + `${imports} test("x", async ({ context }) => { const second = await context["newPage"](); await second.goto("/"); });`, + ); + accepts(spec('await bootstrapE2ePage(page); await page["goto"]("/");')); + accepts( + `${imports} test("x", async ({ context }) => { const second = await context["newPage"](); await bootstrapE2ePage(second); await second["goto"]("/"); });`, + ); + }); + + test(`${project}: fails closed for recursive and unmapped helpers`, () => { + rejects( + `${imports} async function setup(target) { await bootstrapE2ePage(target); await setup(target); } test("x", async ({ page }) => { await setup(page); });`, + ); + rejects( + `${imports} async function a(target) { await bootstrapE2ePage(target); await b(target); } async function b(target) { await a(target); } test("x", async ({ page }) => { await a(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { go(page as any); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { go((page)); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function go(target) { await target.goto("/"); } test("x", async (fixtures) => { go(fixtures.page); const { page } = fixtures; await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function inner(target) { await bootstrapE2ePage(target); } async function outer(target) { await inner(target); } test("x", async ({ page }) => { await outer(page); });`, + ); + accepts( + `${imports} async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { setup(page as any); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: rejects nested safety events mapped through local identifiers`, () => { + rejects( + `${imports} async function inner(target) { await target.goto("/"); } async function outer(target) { const alias = target; await inner(alias); } test("x", async ({ page }) => { await outer(page); await bootstrapE2ePage(page); });`, + ); + rejects( + `${imports} async function inner(target) { await target.goto("/"); } async function outer(target) { await inner(target); } test("x", async ({ page }) => { await outer(page); await bootstrapE2ePage(page); });`, + ); + accepts( + `${imports} async function inner(target) { await target.addInitScript(() => {}); } async function outer(target) { const alias = target; await inner(alias); } test("x", async ({ page }) => { await outer(page); await bootstrapE2ePage(page); });`, + ); + }); + + test(`${project}: applies created-page receiver enforcement in whole-file checks`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-created-page-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + for (const [name, body] of [ + [ + "wrong-fixture-receiver.spec.ts", + 'const second = await context.newPage(); await bootstrapE2ePage(second, "/"); await page.title();', + ], + [ + "safe-fixture-receiver.spec.ts", + 'const second = await context.newPage(); await bootstrapE2ePage(page, "/"); await bootstrapE2ePage(second, "/"); await page.title();', + ], + [ + "fixture-navigation-before-bootstrap.spec.ts", + 'await page.goto("/"); await bootstrapE2ePage(page, "/");', + ], + [ + "fixture-storage-before-bootstrap.spec.ts", + 'await page.evaluate(() => localStorage.getItem("x")); await bootstrapE2ePage(page, "/");', + ], + [ + "fixture-setup-before-bootstrap.spec.ts", + 'await page.addInitScript(() => {}); await bootstrapE2ePage(page, "/");', + ], + [ + "dead.spec.ts", + 'const second = await context.newPage(); if (false) await bootstrapE2ePage(second); await second.goto("/");', + ], + [ + "nested.spec.ts", + 'const second = await context.newPage(); async function never() { await bootstrapE2ePage(second); } await second.goto("/");', + ], + [ + "assigned.spec.ts", + 'let second; second = await context.newPage(); await second.goto("/");', + ], + [ + "unawaited-navigation.spec.ts", + 'const second = await context.newPage(); second.goto("/");', + ], + [ + "initial-navigation.spec.ts", + 'const second = await context.newPage(); await second.goto("/"); await bootstrapE2ePage(second, "/"); await second.goto("/later");', + ], + [ + "conditional-unsafe.spec.ts", + 'if (condition) await page.goto("/"); await bootstrapE2ePage(page);', + ], + [ + "conditional-safe.spec.ts", + 'await bootstrapE2ePage(page); if (condition) await page.goto("/");', + ], + [ + "switch-unsafe.spec.ts", + 'switch (mode) { case "go": await page.goto("/"); break; default: break; } await bootstrapE2ePage(page);', + ], + [ + "switch-safe.spec.ts", + 'await bootstrapE2ePage(page); switch (mode) { case "go": await page.goto("/"); break; default: break; }', + ], + [ + "initial-bootstrap.spec.ts", + 'await bootstrapE2ePage(page, "/"); const second = await context.newPage(); await bootstrapE2ePage(second, "/"); await second.goto("/"); await bootstrapE2ePage(second, "/later"); await second.goto("/later");', + ], + ]) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} test("x", async ({ page, context }) => { ${body} });`, + ); + fs.writeFileSync( + path.join(root, "tests/e2e/helper-order-unsafe.spec.ts"), + `${imports} async function open(target) { await target.goto("/"); await bootstrapE2ePage(target); } test("x", async ({ page }) => { await open(page); });`, + ); + fs.writeFileSync( + path.join(root, "tests/e2e/helper-order-safe.spec.ts"), + `${imports} async function open(target) { await bootstrapE2ePage(target); await target.goto("/"); } test("x", async ({ page }) => { await open(page); });`, + ); + fs.writeFileSync( + path.join(root, "tests/e2e/helper-mapping-unsafe.spec.ts"), + `${imports} async function inner(safeTarget, unsafeTarget) { await bootstrapE2ePage(safeTarget); await unsafeTarget.goto("/"); } async function outer(unsafeTarget, other) { await inner(other, unsafeTarget); } test("x", async ({ page }) => { const other = {}; await outer(page, other); });`, + ); + fs.writeFileSync( + path.join(root, "tests/e2e/helper-mapping-safe.spec.ts"), + `${imports} async function inner(target) { await bootstrapE2ePage(target); } async function outer(unused, target) { await inner(target); } test("x", async ({ page }) => { await outer(undefined, page); });`, + ); + assert.equal(runCheck(root).length, 12); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: enforces helper-call boundaries in whole-file checks`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-helper-boundaries-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "parenthesized-awaited-unsafe.spec.ts", + "await (go(page)); await bootstrapE2ePage(page);", + ], + [ + "parenthesized-unawaited-unsafe.spec.ts", + "(go(page)); await bootstrapE2ePage(page);", + ], + [ + "parenthesized-awaited-safe.spec.ts", + "await bootstrapE2ePage(page); await (go(page));", + ], + [ + "parenthesized-unawaited-safe.spec.ts", + "await bootstrapE2ePage(page); (go(page));", + ], + [ + "callback-alias-unsafe.spec.ts", + "const alias = page; await go(alias); await bootstrapE2ePage(page);", + ], + [ + "callback-unrelated-unsafe.spec.ts", + "const unrelated = {}; await go(unrelated); await bootstrapE2ePage(page);", + ], + [ + "callback-direct-unsafe.spec.ts", + "await go(page); await bootstrapE2ePage(page);", + ], + [ + "callback-setup-alias-safe.spec.ts", + "const alias = page; await setup(alias); await bootstrapE2ePage(page);", + ], + ]; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} async function go(target) { await target.goto("/"); } async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { ${body} });`, + ); + assert.deepEqual( + runCheck(root) + .map((entry) => entry.split(":")[0]) + .sort(), + [ + "callback-alias-unsafe.spec.ts", + "callback-direct-unsafe.spec.ts", + "callback-unrelated-unsafe.spec.ts", + "parenthesized-awaited-unsafe.spec.ts", + "parenthesized-unawaited-unsafe.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: enforces event birth and callee parentheses in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-event-accounting-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "callee-awaited-unsafe.spec.ts", + "await (go)(page); await bootstrapE2ePage(page);", + ], + [ + "callee-unawaited-unsafe.spec.ts", + "(go)(page); await bootstrapE2ePage(page);", + ], + [ + "whole-call-control.spec.ts", + "await (go(page)); await bootstrapE2ePage(page);", + ], + [ + "birth-alias-goto.spec.ts", + "await aliasGoto(page); await bootstrapE2ePage(page);", + ], + [ + "birth-alias-storage.spec.ts", + "await aliasStorage(page); await bootstrapE2ePage(page);", + ], + [ + "birth-unrelated-goto.spec.ts", + "await unrelatedGoto(page); await bootstrapE2ePage(page);", + ], + [ + "birth-direct-control.spec.ts", + "await go(page); await bootstrapE2ePage(page);", + ], + [ + "setup-control.spec.ts", + "await (setup)(page); await (bootstrapE2ePage)(page);", + ], + ]; + const helpers = ` + async function go(target) { await target.goto("/"); } + async function aliasGoto(target) { const alias = target; await alias.goto("/"); } + async function aliasStorage(target) { const alias = target; await alias.evaluate(() => localStorage.getItem("x")); } + async function unrelatedGoto(target) { const unrelated = {}; await unrelated.goto("/"); } + async function setup(target) { const alias = target; await alias.addInitScript(() => {}); } + `; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} ${helpers} test("x", async ({ page }) => { ${body} });`, + ); + assert.deepEqual( + runCheck(root) + .map((entry) => entry.split(":")[0]) + .sort(), + [ + "birth-alias-goto.spec.ts", + "birth-alias-storage.spec.ts", + "birth-direct-control.spec.ts", + "birth-unrelated-goto.spec.ts", + "callee-awaited-unsafe.spec.ts", + "callee-unawaited-unsafe.spec.ts", + "whole-call-control.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: enforces member parentheses and loop accounting in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-conservative-accounting-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "member-callee.spec.ts", + 'await (page.goto)("/"); await bootstrapE2ePage(page);', + ], + [ + "member-receiver.spec.ts", + 'await (page).goto("/"); await bootstrapE2ePage(page);', + ], + [ + "member-storage.spec.ts", + 'await (page.evaluate)(() => localStorage.getItem("x")); await bootstrapE2ePage(page);', + ], + [ + "helper-member.spec.ts", + "await unsafe(page); await bootstrapE2ePage(page);", + ], + [ + "created-member.spec.ts", + 'const second = await (context.newPage)(); await second.goto("/");', + ], + [ + "created-safe.spec.ts", + 'await bootstrapE2ePage(page); const second = await (context).newPage(); await bootstrapE2ePage(second); await (second.goto)("/");', + ], + [ + "dynamic-for-of.spec.ts", + 'for (const value of values) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "dynamic-while.spec.ts", + 'while (condition) { await bootstrapE2ePage(page); break; } await page.goto("/");', + ], + [ + "spread-array.spec.ts", + 'for (const value of [...[]]) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "spread-object.spec.ts", + 'for (const key in { ...{} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "temporal-loop.spec.ts", + 'let values = []; for (const value of values) await bootstrapE2ePage(page); values = [1]; await page.goto("/");', + ], + [ + "proto-identifier.spec.ts", + 'for (const key in { __proto__: null }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "proto-string.spec.ts", + 'for (const key in { "__proto__": null }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "proto-computed-safe.spec.ts", + 'for (const key in { ["__proto__"]: null }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-number-safe.spec.ts", + 'for (const key in { [1]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol.spec.ts", + 'for (const key in { [Symbol()]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol-member.spec.ts", + 'for (const key in { [Symbol.iterator]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-bound.spec.ts", + 'const key = Symbol(); for (const item in { [key]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-call.spec.ts", + 'for (const key in { [getKey()]: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol-method.spec.ts", + 'for (const key in { [Symbol.iterator]() {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol-getter.spec.ts", + 'for (const key in { get [Symbol.iterator]() { return 1 } }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-symbol-setter.spec.ts", + 'for (const key in { set [Symbol.iterator](value) {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-dynamic-method.spec.ts", + 'for (const key in { [dynamicKey]() {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-dynamic-getter.spec.ts", + 'for (const key in { get [dynamicKey]() { return 1 } }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-dynamic-setter.spec.ts", + 'for (const key in { set [dynamicKey](value) {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-static-method-safe.spec.ts", + 'for (const key in { ["x"]() {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-static-getter-safe.spec.ts", + 'for (const key in { get ["x"]() { return 1 } }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "computed-static-setter-safe.spec.ts", + 'for (const key in { set ["x"](value) {} }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "proto-mixed-safe.spec.ts", + 'for (const key in { __proto__: null, x: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "literal-loop-safe.spec.ts", + 'for (const value of [1]) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "concrete-spread-array-safe.spec.ts", + 'for (const value of [...[], 1]) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "concrete-spread-object-safe.spec.ts", + 'for (const key in { ...{}, x: 1 }) await bootstrapE2ePage(page); await page.goto("/");', + ], + [ + "do-loop-safe.spec.ts", + 'do { await bootstrapE2ePage(page); } while (condition); await page.goto("/");', + ], + ]; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} async function unsafe(target) { await (target.evaluate)(() => localStorage.getItem("x")); } test("x", async ({ page, context }) => { ${body} });`, + ); + assert.deepEqual( + runCheck(root) + .map((entry) => entry.split(":")[0]) + .sort(), + [ + "computed-bound.spec.ts", + "computed-call.spec.ts", + "computed-dynamic-getter.spec.ts", + "computed-dynamic-method.spec.ts", + "computed-dynamic-setter.spec.ts", + "computed-symbol-getter.spec.ts", + "computed-symbol-member.spec.ts", + "computed-symbol-method.spec.ts", + "computed-symbol-setter.spec.ts", + "computed-symbol.spec.ts", + "created-member.spec.ts", + "dynamic-for-of.spec.ts", + "dynamic-while.spec.ts", + "helper-member.spec.ts", + "member-callee.spec.ts", + "member-receiver.spec.ts", + "member-storage.spec.ts", + "proto-identifier.spec.ts", + "proto-string.spec.ts", + "spread-array.spec.ts", + "spread-object.spec.ts", + "temporal-loop.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: applies receiver-aware hooks and catch checks in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-hook-catch-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "hook-created-unsafe.spec.ts", + 'test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); test("x", async ({ context }) => { const second = await context.newPage(); await second.goto("/"); });', + ], + [ + "hook-fixture-safe.spec.ts", + 'test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); test("x", async ({ page }) => { await page.title(); });', + ], + [ + "hook-created-safe.spec.ts", + 'test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); test("x", async ({ context }) => { const second = await context.newPage(); await bootstrapE2ePage(second); await second.goto("/"); });', + ], + [ + "catch-unsafe.spec.ts", + 'test("x", async ({ page }) => { try { await page.title(); } catch { await page.goto("/"); } await bootstrapE2ePage(page); });', + ], + [ + "catch-safe.spec.ts", + 'test("x", async ({ page }) => { await bootstrapE2ePage(page); try { await page.title(); } catch { await page.goto("/"); } });', + ], + [ + "catch-bootstrap.spec.ts", + 'test("x", async ({ page }) => { try { await page.title(); } catch { await bootstrapE2ePage(page); } await page.goto("/"); });', + ], + ]; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} ${body}`, + ); + assert.deepEqual( + runCheck(root) + .map((entry) => entry.split(":")[0]) + .sort(), + [ + "catch-bootstrap.spec.ts", + "catch-unsafe.spec.ts", + "hook-created-unsafe.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: applies unawaited and element-access checks in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-unawaited-element-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "unawaited-storage.spec.ts", + 'async function read(target) { await target.evaluate(() => localStorage.getItem("x")); } test("x", async ({ page }) => { read(page); await bootstrapE2ePage(page); });', + ], + [ + "unawaited-goto.spec.ts", + 'async function go(target) { await target.goto("/"); } test("x", async ({ page }) => { go(page); await bootstrapE2ePage(page); });', + ], + [ + "unawaited-safe.spec.ts", + 'async function setup(target) { await target.addInitScript(() => {}); } test("x", async ({ page }) => { setup(page); await bootstrapE2ePage(page); });', + ], + [ + "element-goto.spec.ts", + 'test("x", async ({ page }) => { await page["goto"]("/"); await bootstrapE2ePage(page); });', + ], + [ + "element-created.spec.ts", + 'test("x", async ({ context }) => { const second = await context["newPage"](); await second.goto("/"); });', + ], + [ + "element-safe.spec.ts", + 'test("x", async ({ page }) => { await bootstrapE2ePage(page); await page["goto"]("/"); });', + ], + ]; + for (const [name, body] of cases) + fs.writeFileSync( + path.join(root, "tests/e2e", name), + `${imports} ${body}`, + ); + assert.deepEqual( + runCheck(root) + .map((x) => x.split(":")[0]) + .sort(), + [ + "element-created.spec.ts", + "element-goto.spec.ts", + "unawaited-goto.spec.ts", + "unawaited-storage.spec.ts", + ], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: applies recursion and unmapped guards in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-recursion-unmapped-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "direct.spec.ts", + 'async function setup(target){await bootstrapE2ePage(target);await setup(target);} test("x",async({page})=>{await setup(page);});', + ], + [ + "mutual.spec.ts", + 'async function a(target){await bootstrapE2ePage(target);await b(target);} async function b(target){await a(target);} test("x",async({page})=>{await a(page);});', + ], + [ + "as.spec.ts", + 'async function go(target){await target.goto("/");} test("x",async({page})=>{go(page as any);await bootstrapE2ePage(page);});', + ], + [ + "paren.spec.ts", + 'async function go(target){await target.goto("/");} test("x",async({page})=>{go((page));await bootstrapE2ePage(page);});', + ], + [ + "property.spec.ts", + 'async function go(target){await target.goto("/");} test("x",async(fixtures)=>{go(fixtures.page);const {page}=fixtures;await bootstrapE2ePage(page);});', + ], + [ + "safe.spec.ts", + 'async function inner(target){await bootstrapE2ePage(target);} test("x",async({page})=>{await inner(page);});', + ], + ]; + for (const [n, b] of cases) + fs.writeFileSync(path.join(root, "tests/e2e", n), `${imports} ${b}`); + assert.equal(runCheck(root).length, 5); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: applies local-identifier mapping guards in whole files`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "e2e-bootstrap-local-map-"), + ); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + const cases = [ + [ + "alias.spec.ts", + 'async function inner(target){await target.goto("/");} async function outer(target){const alias=target;await inner(alias);} test("x",async({page})=>{await outer(page);await bootstrapE2ePage(page);});', + ], + [ + "direct.spec.ts", + 'async function inner(target){await target.goto("/");} async function outer(target){await inner(target);} test("x",async({page})=>{await outer(page);await bootstrapE2ePage(page);});', + ], + [ + "setup.spec.ts", + 'async function inner(target){await target.addInitScript(()=>{});} async function outer(target){const alias=target;await inner(alias);} test("x",async({page})=>{await outer(page);await bootstrapE2ePage(page);});', + ], + ]; + for (const [n, b] of cases) + fs.writeFileSync(path.join(root, "tests/e2e", n), `${imports} ${b}`); + assert.equal(runCheck(root).length, 2); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: ignores destructuring property keys with different bound locals`, () => { + accepts( + spec( + "const { test: localTest, bootstrapE2ePage: localBootstrap } = helpers; void localTest; void localBootstrap; await bootstrapE2ePage(page);", + ), + ); + }); + + for (const [name, source] of [ + [ + ".ts helper suffix", + 'import { test, bootstrapE2ePage } from "../helpers/test.ts"; test("x", async () => {});', + ], + [ + "import alias", + 'import { test as e2eTest, bootstrapE2ePage } from "../helpers/test"; e2eTest("x", async () => { await bootstrapE2ePage(); });', + ], + [ + "re-export", + 'import { test, bootstrapE2ePage } from "./reexport"; test("x", async () => { await bootstrapE2ePage(); });', + ], + [ + "dynamic import", + 'const { test, bootstrapE2ePage } = await import("../helpers/test"); test("x", async () => { await bootstrapE2ePage(); });', + ], + [ + "dead-code call", + `${imports}\nasync function never(page) { await bootstrapE2ePage(page); } test("x", async () => {});`, + ], + [ + "comment", + `${imports}\n// await bootstrapE2ePage(page)\ntest("x", async () => {});`, + ], + [ + "string", + `${imports}\nconst hint = "await bootstrapE2ePage(page)"; test("x", async () => {});`, + ], + [ + "mixed safe and unsafe tests", + `${imports}\ntest("safe", async ({ page }) => { await bootstrapE2ePage(page); }); test("unsafe", async ({ page }) => { await page.title(); });`, + ], + ["missing bootstrap receiver", spec("await bootstrapE2ePage();")], + ["non-identifier bootstrap receiver", spec('await bootstrapE2ePage("/");')], + ["if-wrapped call", spec("if (true) { await bootstrapE2ePage(page); }")], + [ + "catch-only call", + spec( + "try { await page.title(); } catch { await bootstrapE2ePage(page); }", + ), + ], + [ + "zero-iteration false loop", + spec("for (; false;) { await bootstrapE2ePage(page); }"), + ], + [ + "zero-iteration empty iterable", + spec("for (const value of []) { await bootstrapE2ePage(page); }"), + ], + [ + "uncalled nested function", + spec("async function setup() { await bootstrapE2ePage(page); }"), + ], + [ + "unawaited helper", + `${imports}\nasync function open(page) { await bootstrapE2ePage(page); }\ntest("x", async ({ page }) => { open(page); });`, + ], + [ + "fake test.extend", + `${imports}\ntest.extend("x", async ({ page }) => { await bootstrapE2ePage(page); });`, + ], + [ + "misplaced describe hook", + `${imports}\ntest.describe("inside", () => { test.beforeEach(async ({ page }) => { await bootstrapE2ePage(page); }); test("covered", async () => {}); }); test("outside", async () => {});`, + ], + [ + "shadowed bootstrap", + `${imports}\ntest("x", async ({ page }) => { const bootstrapE2ePage = async () => {}; await bootstrapE2ePage(page); });`, + ], + [ + "object shorthand binding shadow", + spec( + "const { bootstrapE2ePage } = helpers; await bootstrapE2ePage(page);", + ), + ], + [ + "nested object alias binding shadow", + spec( + "const { bootstrap: { run: bootstrapE2ePage } } = helpers; await bootstrapE2ePage(page);", + ), + ], + [ + "nested array default binding shadow", + spec("const [[test = fallback]] = values; await bootstrapE2ePage(page);"), + ], + [ + "object rest binding shadow", + spec( + "const { ignored, ...bootstrapE2ePage } = helpers; await bootstrapE2ePage(page);", + ), + ], + [ + "destructured function parameter shadow", + spec( + "function configure({ nested: [bootstrapE2ePage] }) {} await bootstrapE2ePage(page);", + ), + ], + [ + "destructured arrow parameter shadow", + spec( + "const configure = ([{ test }]) => {}; await bootstrapE2ePage(page);", + ), + ], + [ + "destructured method parameter shadow", + spec( + "class Harness { configure({ helper: { bootstrapE2ePage = fallback } }) {} } await bootstrapE2ePage(page);", + ), + ], + [ + "parenthesized false condition", + spec("while (((false))) { await bootstrapE2ePage(page); }"), + ], + [ + "parenthesized empty array iterable", + spec("for (const value of (([]))) { await bootstrapE2ePage(page); }"), + ], + [ + "parenthesized empty string iterable", + spec('for (const value of ((""))) { await bootstrapE2ePage(page); }'), + ], + [ + "parenthesized empty object for-in", + spec("for (const key in (({}))) { await bootstrapE2ePage(page); }"), + ], + ]) { + test(`${project}: rejects ${name}`, () => rejects(source)); + } + + test(`${project}: classifies non-browser harnesses structurally`, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-bootstrap-")); + try { + fs.mkdirSync(path.join(root, "tests/e2e"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + fs.writeFileSync( + path.join(root, "tests/e2e/agents-everywhere.live.spec.ts"), + 'import { test } from "../helpers/test"; test("relay", async () => {});', + ); + fs.writeFileSync( + path.join(root, "tests/e2e/renamed-cli-harness.spec.ts"), + 'import { test } from "../helpers/test"; test("relay", async () => {});', + ); + fs.writeFileSync( + path.join(root, "tests/e2e/agents-everywhere.live.perf.ts"), + 'import { test } from "../helpers/test"; test("browser", async ({ page }) => { await page.title(); });', + ); + assert.deepEqual(runCheck(root), [ + "agents-everywhere.live.perf.ts: must directly import unaliased test and bootstrapE2ePage from the canonical tests/helpers/test module", + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test(`${project}: discovers nested specs with a canonical relative import`, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-bootstrap-")); + try { + fs.mkdirSync(path.join(root, "tests/e2e/nested"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests/helpers"), { recursive: true }); + fs.writeFileSync( + path.join(root, "tests/helpers/test.ts"), + "export {};\n", + ); + fs.writeFileSync( + path.join(root, "tests/e2e/nested/example.spec.ts"), + 'import { test, bootstrapE2ePage } from "../../helpers/test"; test("x", async ({ page }) => { await bootstrapE2ePage(page); });', + ); + assert.deepEqual(runCheck(root), []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +for (const [project, checkerPath] of projects) { + const { checkSource } = await import(checkerPath); + test(`${project}: traverses canonical parameterized registration containers only`, () => { + assert.equal( + checkSource(`${imports} + for (const name of ["one", "two"]) test(name, async ({ page }) => { await bootstrapE2ePage(page); }); + ["three"].forEach((name) => test(name, async ({ page }) => { await bootstrapE2ePage(page); })); + ["four"].map((name) => test(name, async ({ page }) => { await bootstrapE2ePage(page); }));`), + undefined, + ); + assert.notEqual( + checkSource(`${imports} + function registerLater() { test("hidden", async ({ page }) => { await bootstrapE2ePage(page); }); } + test("unsafe", async ({ page }) => { await page.title(); });`), + undefined, + ); + }); +} diff --git a/web/tests/e2e/bootstrap.spec.ts b/web/tests/e2e/bootstrap.spec.ts new file mode 100644 index 00000000000..00bbdd889c8 --- /dev/null +++ b/web/tests/e2e/bootstrap.spec.ts @@ -0,0 +1,20 @@ +import { expect, test, bootstrapE2ePage } from "../helpers/test"; +import { E2E_APP_ORIGIN } from "../helpers/bootstrap"; + +test.beforeEach(async ({ page }) => { + await bootstrapE2ePage(page); +}); + +test("starts each test at the app origin after explicit bootstrap", async ({ + page, +}) => { + expect(new URL(page.url()).origin).toBe(E2E_APP_ORIGIN); +}); + +test("reports a failed initial navigation at its source", async ({ page }) => { + await expect( + bootstrapE2ePage(page, { expectedOrigin: "http://127.0.0.1:1" }), + ).rejects.toThrow( + /E2E app navigation did not commit to http:\/\/127\.0\.0\.1:1 \(current URL: http:\/\/127\.0\.0\.1:4173\/\).*Run pnpm test:e2e:smoke or run pnpm build and check the preview server/, + ); +}); diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 22e26281656..3390a138acb 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -1,15 +1,15 @@ import { createHash } from "node:crypto"; -import { expect, test } from "@playwright/test"; +import { expect, test, bootstrapE2ePage } from "../helpers/test"; test("home page loads with Buzz branding", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await expect( page.getByRole("main").getByRole("img", { name: "Buzz" }), ).toBeVisible(); }); test("home page shows repositories section", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await expect(page.getByText("Repositories")).toBeVisible(); }); @@ -66,7 +66,7 @@ test("invite requires age and legal consent before opening Buzz", async ({ ]), }); }); - await page.goto("/invite/demo-code"); + await bootstrapE2ePage(page, "/invite/demo-code"); await expect( page.getByRole("link", { name: "Download it now" }), @@ -124,30 +124,32 @@ test("invite can enroll a NIP-07 identity for browser access", async ({ page, }) => { const pubkey = "ab".repeat(32); - await page.addInitScript((extensionPubkey) => { - ( - window as Window & { - nostr?: { - getPublicKey(): Promise; - signEvent( - event: Record, - ): Promise>; - }; - } - ).nostr = { - async getPublicKey() { - return extensionPubkey; - }, - async signEvent(event) { - return { - ...event, - id: "cd".repeat(32), - pubkey: extensionPubkey, - sig: "ef".repeat(64), - }; - }, - }; - }, pubkey); + const installNip07Extension = async () => { + await page.addInitScript((extensionPubkey) => { + ( + window as Window & { + nostr?: { + getPublicKey(): Promise; + signEvent( + event: Record, + ): Promise>; + }; + } + ).nostr = { + async getPublicKey() { + return extensionPubkey; + }, + async signEvent(event) { + return { + ...event, + id: "cd".repeat(32), + pubkey: extensionPubkey, + sig: "ef".repeat(64), + }; + }, + }; + }, pubkey); + }; await page.route("**/api/join-policy", async (route) => { await route.fulfill({ status: 200, @@ -195,7 +197,8 @@ test("invite can enroll a NIP-07 identity for browser access", async ({ }); }); - await page.goto("/invite/browser-code"); + await installNip07Extension(); + await bootstrapE2ePage(page, "/invite/browser-code"); await page.getByRole("button", { name: "Join in browser" }).click(); await expect(page).toHaveURL("/"); expect(claimObserved).toBe(true); @@ -227,7 +230,7 @@ test("invite asks Safari users to choose their Mac download", async ({ await route.fulfill({ status: 500 }); }); - await page.goto("/invite/demo-code"); + await bootstrapE2ePage(page, "/invite/demo-code"); const download = page.getByRole("link", { name: "Download it now" }); await expect(download).toHaveAttribute("aria-haspopup", "dialog"); await download.click(); @@ -339,7 +342,7 @@ test("invite download falls back for mobile and non-desktop devices", async ({ }); }); - await page.goto("/invite/demo-code"); + await bootstrapE2ePage(page, "/invite/demo-code"); await expect( page.getByRole("link", { name: "Download it now" }), device.name, diff --git a/web/tests/helpers/bootstrap.ts b/web/tests/helpers/bootstrap.ts new file mode 100644 index 00000000000..ffdd8beafec --- /dev/null +++ b/web/tests/helpers/bootstrap.ts @@ -0,0 +1,66 @@ +import type { Page } from "@playwright/test"; + +export const E2E_APP_ORIGIN = "http://127.0.0.1:4173"; + +type BootstrapE2ePageOptions = { + /** Relative route to load after all test setup has been registered. */ + path?: string; + /** Test setup such as extension or storage init-script registration. */ + beforeNavigate?: () => Promise; + expectedOrigin?: string; +}; + +/** + * Registers page setup and commits the first app navigation before a spec uses + * browser storage or page evaluation. This makes an unavailable preview server + * fail at its source instead of later as an opaque-origin storage error. + */ +export async function bootstrapE2ePage( + page: Page, + pathOrOptions: string | BootstrapE2ePageOptions = {}, + gotoOptions?: Parameters[1], +): Promise { + const { + path = "/", + beforeNavigate, + expectedOrigin = E2E_APP_ORIGIN, + } = typeof pathOrOptions === "string" + ? { path: pathOrOptions } + : pathOrOptions; + await beforeNavigate?.(); + + const target = new URL(path, expectedOrigin).toString(); + let response: Awaited>; + try { + response = await page.goto(target, gotoOptions); + } catch (error) { + throw bootstrapFailure(expectedOrigin, page.url(), error); + } + + if (response && !response.ok()) { + throw bootstrapFailure( + expectedOrigin, + page.url(), + `navigation returned HTTP ${response.status()}`, + ); + } + + if (new URL(page.url()).origin !== expectedOrigin) { + throw bootstrapFailure( + expectedOrigin, + page.url(), + "navigation changed origin", + ); + } +} + +function bootstrapFailure( + expectedOrigin: string, + currentUrl: string, + cause: unknown, +): Error { + return new Error( + `E2E app navigation did not commit to ${expectedOrigin} (current URL: ${currentUrl}). ` + + `Run pnpm test:e2e:smoke or run pnpm build and check the preview server. Cause: ${String(cause)}`, + ); +} diff --git a/web/tests/helpers/test.ts b/web/tests/helpers/test.ts new file mode 100644 index 00000000000..8c6487a9976 --- /dev/null +++ b/web/tests/helpers/test.ts @@ -0,0 +1,13 @@ +import { expect, test as base } from "@playwright/test"; + +export type * from "@playwright/test"; + +export { bootstrapE2ePage } from "./bootstrap"; + +/** + * Required E2E entrypoint. Register `bootstrapE2ePage(page)` in a `beforeEach` + * after any bridge, route, or init-script setup and before app interaction. + */ +const test = base; + +export { expect, test };