diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3d824bfc565..f3e8955dc3c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,7 @@ name: Docker image -# Builds and publishes the public Buzz relay images as ghcr.io/block/buzz. +# Builds and publishes the public Buzz relay images as ghcr.io// +# (ghcr.io/block/buzz upstream) plus the matching -push-gateway image. # Normal tags contain stripped binaries; matching debug-* tags contain the same # optimized binaries with line-table debug information for native profilers. # @@ -77,10 +78,11 @@ concurrency: permissions: {} env: - # Single source of truth for the image name. Set GHCR_IMAGE as a repo - # variable to override (e.g., for forks that want to push to their own - # namespace without forking this file). - IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/block/buzz' }} + # Single source of truth for the image names. Both default to the running + # repository's namespace, so forks publish (and cache) in their own registry. + # Set GHCR_IMAGE / GHCR_PUSH_GATEWAY_IMAGE as repo variables to override. + IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || format('ghcr.io/{0}', github.repository) }} + PUSH_GATEWAY_IMAGE: ${{ vars.GHCR_PUSH_GATEWAY_IMAGE != '' && vars.GHCR_PUSH_GATEWAY_IMAGE || format('ghcr.io/{0}-push-gateway', github.repository) }} jobs: build: @@ -104,6 +106,11 @@ jobs: arch: arm64 steps: + - name: Normalize image names + shell: bash + run: | + printf 'IMAGE_NAME=%s\nPUSH_GATEWAY_IMAGE=%s\n' "${IMAGE_NAME,,}" "${PUSH_GATEWAY_IMAGE,,}" >> "$GITHUB_ENV" + - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -330,6 +337,11 @@ jobs: tag_prefix: debug- steps: + - name: Normalize image names + shell: bash + run: | + printf 'IMAGE_NAME=%s\nPUSH_GATEWAY_IMAGE=%s\n' "${IMAGE_NAME,,}" "${PUSH_GATEWAY_IMAGE,,}" >> "$GITHUB_ENV" + - name: Checkout source uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -414,7 +426,7 @@ jobs: - name: Attest provenance for the merged image # Sigstore-signed in-toto attestation, verifiable with: - # gh attestation verify oci://ghcr.io/block/buzz: --owner block + # gh attestation verify oci://: --owner uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-name: ${{ env.IMAGE_NAME }} @@ -481,13 +493,13 @@ jobs: echo echo "Verify provenance:" echo '```' - echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block" + printf 'gh attestation verify oci://%s@%s --owner %s\n' "$IMAGE_NAME" "$MERGED_DIGEST" "$GITHUB_REPOSITORY_OWNER" echo '```' if [[ "$VARIANT" == "release" ]]; then echo echo "Verify deployment eligibility:" echo '```' - echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --repo block/buzz --signer-workflow block/buzz/.github/workflows/docker.yml --predicate-type https://buzz.block.xyz/attestations/deployment-eligibility/v1 --source-digest ${SOURCE_SHA}" + printf 'gh attestation verify oci://%s@%s --repo %s --signer-workflow %s/.github/workflows/docker.yml --predicate-type https://buzz.block.xyz/attestations/deployment-eligibility/v1 --source-digest %s\n' "$IMAGE_NAME" "$MERGED_DIGEST" "$GITHUB_REPOSITORY" "$GITHUB_REPOSITORY" "$SOURCE_SHA" echo '```' fi } >> "$GITHUB_STEP_SUMMARY" @@ -510,6 +522,11 @@ jobs: runner: ubuntu-24.04-arm arch: arm64 steps: + - name: Normalize image names + shell: bash + run: | + printf 'IMAGE_NAME=%s\nPUSH_GATEWAY_IMAGE=%s\n' "${IMAGE_NAME,,}" "${PUSH_GATEWAY_IMAGE,,}" >> "$GITHUB_ENV" + - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -539,7 +556,7 @@ jobs: id: meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: - images: ghcr.io/block/buzz-push-gateway + images: ${{ env.PUSH_GATEWAY_IMAGE }} labels: | org.opencontainers.image.title=Buzz Push Gateway org.opencontainers.image.description=Capability-gated APNs last hop for Buzz @@ -552,9 +569,9 @@ jobs: file: ./Dockerfile.push-gateway platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} - cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }} - cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} + outputs: type=image,name=${{ env.PUSH_GATEWAY_IMAGE }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: type=registry,ref=${{ env.PUSH_GATEWAY_IMAGE }}-buildcache:${{ matrix.arch }} + cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.PUSH_GATEWAY_IMAGE, matrix.arch) || '' }} - name: Export digest if: github.event_name != 'pull_request' env: @@ -614,6 +631,11 @@ jobs: id-token: write attestations: write steps: + - name: Normalize image names + shell: bash + run: | + printf 'IMAGE_NAME=%s\nPUSH_GATEWAY_IMAGE=%s\n' "${IMAGE_NAME,,}" "${PUSH_GATEWAY_IMAGE,,}" >> "$GITHUB_ENV" + - name: Download per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -632,7 +654,7 @@ jobs: id: meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: - images: ghcr.io/block/buzz-push-gateway + images: ${{ env.PUSH_GATEWAY_IMAGE }} tags: | type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} @@ -646,7 +668,7 @@ jobs: run: | set -euo pipefail tags=(); while IFS= read -r tag; do [ -n "$tag" ] && tags+=("-t" "$tag"); done <<< "$META_TAGS" - digests=(); for digest in *; do digests+=("ghcr.io/block/buzz-push-gateway@sha256:${digest}"); done + digests=(); for digest in *; do digests+=("${PUSH_GATEWAY_IMAGE}@sha256:${digest}"); done docker buildx imagetools create "${tags[@]}" "${digests[@]}" first_tag=$(echo "$META_TAGS" | head -n1) digest=$(docker buildx imagetools inspect "$first_tag" --format '{{json .Manifest}}' | jq -r '.digest') @@ -654,7 +676,7 @@ jobs: - name: Attest gateway image provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-name: ghcr.io/block/buzz-push-gateway + subject-name: ${{ env.PUSH_GATEWAY_IMAGE }} subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true - name: Gateway publication summary @@ -664,7 +686,7 @@ jobs: run: | set -euo pipefail { - echo "### Published \`ghcr.io/block/buzz-push-gateway\`" + echo "### Published \`${PUSH_GATEWAY_IMAGE}\`" echo printf "**Digest:** \`%s\`\n" "$GATEWAY_DIGEST" echo @@ -675,6 +697,6 @@ jobs: echo echo 'Verify provenance before deployment:' echo "\`\`\`" - printf 'gh attestation verify oci://ghcr.io/block/buzz-push-gateway@%s --owner block\n' "$GATEWAY_DIGEST" + printf 'gh attestation verify oci://%s@%s --owner %s\n' "$PUSH_GATEWAY_IMAGE" "$GATEWAY_DIGEST" "$GITHUB_REPOSITORY_OWNER" echo "\`\`\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index 7118d16708d..16e2e7ea629 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -39,18 +39,24 @@ on: - "deploy/charts/buzz/**" - "deploy/charts/buzz-push-gateway/**" - ".github/workflows/helm-chart.yml" + - "scripts/package-chart.sh" + - "scripts/test-chart-packaging.sh" - "ct.yaml" # Match docker.yml: deny-by-default, each job grants only what it needs. permissions: {} env: + IMAGE_REPOSITORY: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || format('ghcr.io/{0}', github.repository) }} + # Fork Docker builds publish main; upstream retains the chart's appVersion. + IMAGE_TAG: ${{ github.repository != 'block/buzz' && 'main' || '' }} # Single source of truth for the OCI chart repository (helm appends the chart - # name `buzz`, yielding oci://ghcr.io/block/buzz/charts/buzz, which is exactly - # the install ref documented in deploy/charts/buzz/README.md). Set - # GHCR_CHART_REPO as a repo variable to override (e.g., forks pushing to their - # own namespace without editing this file) — mirrors docker.yml's GHCR_IMAGE. - CHART_REPO: ${{ vars.GHCR_CHART_REPO != '' && vars.GHCR_CHART_REPO || 'oci://ghcr.io/block/buzz/charts' }} + # name `buzz`, yielding oci://ghcr.io///charts/buzz; upstream + # that is the install ref documented in deploy/charts/buzz/README.md). The + # default follows the running repository so forks publish to their own + # namespace. Set GHCR_CHART_REPO as a repo variable to override — mirrors + # docker.yml's GHCR_IMAGE. + CHART_REPO: ${{ vars.GHCR_CHART_REPO != '' && vars.GHCR_CHART_REPO || format('oci://ghcr.io/{0}/charts', github.repository) }} jobs: lint-and-unittest: @@ -86,6 +92,9 @@ jobs: - name: Build chart dependencies run: helm dependency build deploy/charts/buzz + - name: Verify packaged image defaults + run: scripts/test-chart-packaging.sh deploy/charts/buzz + - name: Push gateway chart lint + render guard run: deploy/charts/buzz-push-gateway/tests/render.sh @@ -165,6 +174,11 @@ jobs: contents: read packages: write # push the chart to GHCR steps: + - name: Normalize chart repository + shell: bash + run: | + printf 'CHART_REPO=%s\n' "${CHART_REPO,,}" >> "$GITHUB_ENV" + - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -226,7 +240,7 @@ jobs: run: helm dependency build deploy/charts/buzz - name: Package chart - run: helm package deploy/charts/buzz --destination dist + run: scripts/package-chart.sh deploy/charts/buzz "$IMAGE_REPOSITORY" "$IMAGE_TAG" dist - name: Push chart to GHCR env: @@ -241,7 +255,7 @@ jobs: CHART_REPO: ${{ env.CHART_REPO }} VERSION: ${{ steps.ver.outputs.version }} run: | - # CHART_REPO is oci://ghcr.io/block/buzz/charts; helm push appends the + # CHART_REPO is oci://ghcr.io///charts; helm push appends the # chart name, so the install ref is .../charts/buzz. INSTALL_REF="${CHART_REPO}/buzz" { diff --git a/.github/workflows/push-gateway-helm-chart.yml b/.github/workflows/push-gateway-helm-chart.yml index 897e23acd1f..6999a2f00b9 100644 --- a/.github/workflows/push-gateway-helm-chart.yml +++ b/.github/workflows/push-gateway-helm-chart.yml @@ -16,9 +16,14 @@ on: - "deploy/charts/buzz-push-gateway/**" - "crates/buzz-push-gateway/src/config.rs" - ".github/workflows/push-gateway-helm-chart.yml" + - "scripts/package-chart.sh" + - "scripts/test-chart-packaging.sh" permissions: {} env: - CHART_REPO: oci://ghcr.io/block/buzz/charts + IMAGE_REPOSITORY: ${{ vars.GHCR_PUSH_GATEWAY_IMAGE != '' && vars.GHCR_PUSH_GATEWAY_IMAGE || format('ghcr.io/{0}-push-gateway', github.repository) }} + # Defaults to the running repository's namespace so forks publish to their + # own registry; GHCR_CHART_REPO overrides it (same variable as helm-chart.yml). + CHART_REPO: ${{ vars.GHCR_CHART_REPO != '' && vars.GHCR_CHART_REPO || format('oci://ghcr.io/{0}/charts', github.repository) }} jobs: validate: runs-on: ubuntu-latest @@ -33,6 +38,7 @@ jobs: with: { version: v3.16.4 } - run: deploy/charts/buzz-push-gateway/tests/render.sh - run: deploy/charts/buzz-push-gateway/tests/release-contract.sh + - run: scripts/test-chart-packaging.sh deploy/charts/buzz-push-gateway publish: if: github.event_name != 'pull_request' needs: validate @@ -41,6 +47,10 @@ jobs: contents: read packages: write steps: + - name: Normalize chart repository + shell: bash + run: | + printf 'CHART_REPO=%s\n' "${CHART_REPO,,}" >> "$GITHUB_ENV" - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} @@ -66,5 +76,5 @@ jobs: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - run: helm package deploy/charts/buzz-push-gateway --destination dist + - run: scripts/package-chart.sh deploy/charts/buzz-push-gateway "$IMAGE_REPOSITORY" main dist - run: helm push "dist/buzz-push-gateway-${VERSION}.tgz" "$CHART_REPO" diff --git a/.github/workflows/sprig-image.yml b/.github/workflows/sprig-image.yml index 5d5e12ae0cf..6e689ffeeb9 100644 --- a/.github/workflows/sprig-image.yml +++ b/.github/workflows/sprig-image.yml @@ -1,8 +1,9 @@ name: Sprig image # Builds and publishes the public agent container image as -# ghcr.io/block/buzz-sprig — the digest-pinned box the Kubernetes backend -# deploys agents into (see Dockerfile.sprig and docs/remote-agents.md). +# ghcr.io//-sprig (ghcr.io/block/buzz-sprig upstream) — the +# digest-pinned box the Kubernetes backend deploys agents into (see +# Dockerfile.sprig and docs/remote-agents.md). # # Strategy mirrors docker.yml (the relay image): each architecture builds on # its native runner, pushes to GHCR by digest, then a merge job stitches the @@ -17,7 +18,7 @@ name: Sprig image # - workflow_dispatch → manual publish at the current ref # # NOTE: the first push creates the GHCR package PRIVATE by default. An org -# admin must flip ghcr.io/block/buzz-sprig to public once (Package settings → +# admin must flip the sprig package to public once (Package settings → # Change visibility). Subsequent pushes keep the visibility. on: @@ -46,9 +47,10 @@ concurrency: permissions: {} env: - # Single source of truth for the image name; override with the - # GHCR_SPRIG_IMAGE repo variable (same pattern as docker.yml). - IMAGE_NAME: ${{ vars.GHCR_SPRIG_IMAGE != '' && vars.GHCR_SPRIG_IMAGE || 'ghcr.io/block/buzz-sprig' }} + # Single source of truth for the image name; it defaults to the running + # repository's namespace so forks publish to their own registry. Override + # with the GHCR_SPRIG_IMAGE repo variable (same pattern as docker.yml). + IMAGE_NAME: ${{ vars.GHCR_SPRIG_IMAGE != '' && vars.GHCR_SPRIG_IMAGE || format('ghcr.io/{0}-sprig', github.repository) }} jobs: build: @@ -72,6 +74,11 @@ jobs: arch: arm64 steps: + - name: Normalize image name + shell: bash + run: | + printf 'IMAGE_NAME=%s\n' "${IMAGE_NAME,,}" >> "$GITHUB_ENV" + - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -156,6 +163,11 @@ jobs: attestations: write steps: + - name: Normalize image name + shell: bash + run: | + printf 'IMAGE_NAME=%s\n' "${IMAGE_NAME,,}" >> "$GITHUB_ENV" + - name: Download per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -213,7 +225,7 @@ jobs: echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT" - name: Attest provenance for the merged image - # Verify with: gh attestation verify oci://ghcr.io/block/buzz-sprig: --owner block + # Verify with: gh attestation verify oci://: --owner uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-name: ${{ env.IMAGE_NAME }} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4e3b0f6d2b9..119a9e61ff0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1309,6 +1309,8 @@ declare global { status: PresenceStatus; }) => void; __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + /** Persist without live delivery when arranging a cold-query fixture. */ + emitLive?: boolean; channelName: string; content: string; parentEventId?: string | null; @@ -5253,6 +5255,7 @@ function emitMockChannelMessage( createdAt?: number, pending?: boolean, id?: string, + emitLive = true, ) { const eventKind = kind ?? 9; if (!parentEventId) { @@ -5272,7 +5275,7 @@ function emitMockChannelMessage( ); if (pending) event.pending = true; recordMockMessage(channelId, event); - emitMockLiveEvent(channelId, event); + if (emitLive) emitMockLiveEvent(channelId, event); return event; } @@ -5305,9 +5308,13 @@ function emitMockChannelMessage( ); if (pending) event.pending = true; recordMockMessage(channelId, event); - emitMockLiveEvent(channelId, event); + if (emitLive) emitMockLiveEvent(channelId, event); const rootEvent = history.find((candidate) => candidate.id === rootEventId); - if (rootEvent && mockHuddle?.state.ephemeral_channel_id === channelId) { + if ( + emitLive && + rootEvent && + mockHuddle?.state.ephemeral_channel_id === channelId + ) { const summary = buildMockChannelThreadSummary( channelId, rootEvent, @@ -11646,6 +11653,7 @@ export function maybeInstallE2eTauriMocks() { createdAt, pending, id, + emitLive, }) => { const channel = mockChannels.find( (candidate) => candidate.name === channelName, @@ -11665,6 +11673,7 @@ export function maybeInstallE2eTauriMocks() { createdAt, pending, id, + emitLive, ); }; window.__BUZZ_E2E_SET_MOCK_USER_STATUS__ = ({ diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts index 1d475da08ac..494b07a1df0 100644 --- a/desktop/tests/e2e/empty-edit-delete.spec.ts +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -2,11 +2,14 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; -// The mock identity's own pre-seeded message in #general (authored by -// DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge.ts). Editing/deleting one's own +// A message the mock identity authors in #general. Editing/deleting one's own // message is exactly Sam's workflow: "delete a message by clearing its edit." -const OWN_MESSAGE_ID = "mock-general-welcome"; -const RENDERED_ORIGINAL_CONTENT = "Welcome to general"; +// It is emitted per test rather than reusing the seeded `mock-general-welcome`: +// edits and deletions only overlay a target with a real 64-hex event id, so +// that seed keeps its edit only until the post-subscribe window refetch. +const ORIGINAL_CONTENT = "Ship notes for #general"; +const RENDERED_ORIGINAL_CONTENT = "Ship notes for general"; +let OWN_MESSAGE_ID: string; // Open the more-actions menu for a message row and wait for the menu to mount. async function openMoreActionsMenu( @@ -46,6 +49,40 @@ test.beforeEach(async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind: number; + exactChannel: boolean; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 9, + exactChannel: true, + }) ?? false, + ), + ) + .toBe(true); + const emitted = await page.evaluate( + (content) => + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + }) => { id: string } | undefined; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", content }), + ORIGINAL_CONTENT, + ); + if (!emitted?.id) throw new Error("mock message emit did not return an id"); + OWN_MESSAGE_ID = emitted.id; }); for (const selection of ["Enter", "Tab", "click"]) { diff --git a/desktop/tests/e2e/huddle-transcription.spec.ts b/desktop/tests/e2e/huddle-transcription.spec.ts index e6c58bed8d5..622392989eb 100644 --- a/desktop/tests/e2e/huddle-transcription.spec.ts +++ b/desktop/tests/e2e/huddle-transcription.spec.ts @@ -850,6 +850,10 @@ test("assigns distinct agent voices and exposes compact per-agent controls", asy }); expect(new Set(assignedVoices).size).toBe(2); + // Let the huddle chat finish mounting and autofocus before opening an + // overlay; a late composer focus would dismiss the popover. + await expect(page.getByTestId("message-input")).toBeFocused(); + await page.getByRole("button", { name: "Voice settings for alice" }).click(); await waitForAnimations(page); const voiceMenu = page.locator( diff --git a/desktop/tests/e2e/project-conversation-load-failure.spec.ts b/desktop/tests/e2e/project-conversation-load-failure.spec.ts index 7ed1a88256b..6a62f4b6b5b 100644 --- a/desktop/tests/e2e/project-conversation-load-failure.spec.ts +++ b/desktop/tests/e2e/project-conversation-load-failure.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { waitForMockChannelHeadReady } from "../helpers/channelHeadReady"; /** * End-to-end guard for the false-empty thread-load bug on the SECOND producer @@ -69,6 +70,36 @@ async function setThreadRepliesFailing(page: Page, failing: boolean) { }, failing); } +async function seedProjectMessage( + page: Page, + content: string, + parentEventId?: string, +) { + return page.evaluate( + ({ author, content, parentEventId }) => { + const emit = ( + window as typeof window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__: (input: { + channelName: string; + content: string; + parentEventId?: string; + pubkey: string; + emitLive: boolean; + }) => { id: string }; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + return emit({ + channelName: "general", + content, + parentEventId, + pubkey: author, + emitLive: false, + }).id; + }, + { author: TEST_IDENTITIES.alice.pubkey, content, parentEventId }, + ); +} + test.describe("project conversation load failure", () => { test("terminal fetch failure shows error card, never false-empty; Retry recovers", async ({ page, @@ -77,65 +108,19 @@ test.describe("project conversation load failure", () => { await installMockBridge(page); await page.goto("/", { waitUntil: "domcontentloaded" }); - // Seed the conversation BEFORE opening general — i.e. before its live - // subscription exists — so the reply lands in the mock store (searchable, - // and returnable by a successful get_thread_replies) but is never live - // pushed into the thread-replies cache. If general were open first, the - // live handler would seed that cache and the panel would render the list - // branch, masking the error card. The root carries the repository discovery - // token so it surfaces as the channel's latest discussion hit (what the - // Channels-tab row opens); its reply omits the token so it never competes - // to be the opened hit. - // - // Wait for the emitter first: emitting before the app boots is a silent - // no-op (the helper is undefined), which would leave the store empty. - await expect - .poll(() => - page.evaluate( - () => - typeof ( - window as typeof window & { - __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: unknown; - } - ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", - ), - ) - .toBe(true); - const rootId = await page.evaluate( - ({ author, rootContent, replyContent }) => { - const now = Math.floor(Date.now() / 1000); - const emit = ( - window as typeof window & { - __BUZZ_E2E_EMIT_MOCK_MESSAGE__: (input: { - channelName: string; - content: string; - parentEventId?: string; - pubkey?: string; - createdAt?: number; - }) => { id: string }; - } - ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; - const root = emit({ - channelName: "general", - content: rootContent, - pubkey: author, - createdAt: now, - }); - emit({ - channelName: "general", - content: replyContent, - parentEventId: root.id, - pubkey: author, - createdAt: now + 1, - }); - return root.id; - }, - { - author: TEST_IDENTITIES.alice.pubkey, - rootContent: ROOT_CONTENT, - replyContent: REPLY_CONTENT, - }, + // Arrange an active subscription before seeding. Persist the fixture without + // live delivery so a startup race cannot populate the thread-replies cache + // and hide the error card. Retry must obtain the reply through its real query. + await page.getByTestId("channel-general").click(); + await waitForMockChannelHeadReady( + page, + "general", + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", ); + // Install the failure before any project navigation can prefetch replies. + await installThreadFailureSwitch(page); + await setThreadRepliesFailing(page, true); + const rootId = await seedProjectMessage(page, ROOT_CONTENT); expect(rootId).toBeTruthy(); // Navigate to the project's Channels tab and open the general conversation. @@ -156,11 +141,7 @@ test.describe("project conversation load failure", () => { .first(); await expect(channelRow).toBeVisible({ timeout: 10_000 }); - // Fail every thread-replies fetch, THEN open the conversation: the panel's - // query and its retry both fail, driving the terminal error state with an - // empty reply cache and nothing to fall back to. - await installThreadFailureSwitch(page); - await setThreadRepliesFailing(page, true); + // The panel's query and its automatic retry must reach a terminal error. await channelRow.click(); const panel = page.getByTestId("project-conversation-panel"); @@ -178,8 +159,10 @@ test.describe("project conversation load failure", () => { 0, ); - // Retry with fetches succeeding: the reply loads and renders — the error - // card is gone and no false-empty appears. + // Add the reply only after the cold-query error is visible, so background + // channel history cannot seed it into the reply cache before the failure. + // Silent persistence makes Retry obtain it through the real query path. + await seedProjectMessage(page, REPLY_CONTENT, rootId); await setThreadRepliesFailing(page, false); await page.getByTestId("message-thread-replies-retry").click(); await expect(page.getByTestId("message-thread-replies-error")).toHaveCount( diff --git a/desktop/tests/e2e/voice-note.spec.ts b/desktop/tests/e2e/voice-note.spec.ts index eadab504f55..466ff4b4cd5 100644 --- a/desktop/tests/e2e/voice-note.spec.ts +++ b/desktop/tests/e2e/voice-note.spec.ts @@ -910,7 +910,7 @@ test("starting a duplicate voice-note player pauses the other instance", async ( await page.getByTestId("channel-general").click(); await waitForMockLiveSubscription(page, "general"); - await page.evaluate( + const messageIds = await page.evaluate( ({ audioUrl }) => { const emit = ( window as Window & { @@ -918,7 +918,7 @@ test("starting a duplicate voice-note player pauses the other instance", async ( channelName: string; content: string; extraTags: string[][]; - }) => unknown; + }) => { id: string }; } ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; if (!emit) throw new Error("Mock message emitter is unavailable."); @@ -935,20 +935,31 @@ test("starting a duplicate voice-note player pauses the other instance", async ( ], ], }; - emit(input); - emit(input); + return [emit(input).id, emit(input).id]; }, { audioUrl: AUDIO_URL }, ); const cards = page.getByTestId("audio-message-attachment"); await expect(cards).toHaveCount(2); - const firstAudio = cards.nth(0).locator("audio"); - const secondAudio = cards.nth(1).locator("audio"); + // Live delivery and the channel-head refresh can reorder equal-time messages. + // Keep each assertion bound to the player that was clicked. + const firstCard = page.locator(`[data-message-id="${messageIds[0]}"]`); + const secondCard = page.locator(`[data-message-id="${messageIds[1]}"]`); + const firstAudio = firstCard.locator("audio"); + const secondAudio = secondCard.locator("audio"); + // The fixture is one second long. Only the other player's Play action should + // pause it; natural completion must not satisfy this assertion. + await cards.locator("audio").evaluateAll((elements) => { + for (const audio of elements) (audio as HTMLAudioElement).loop = true; + }); - await cards.nth(0).getByRole("button", { name: "Play voice note" }).click(); + await firstCard.getByRole("button", { name: "Play voice note" }).click(); await expect(firstAudio).toHaveJSProperty("paused", false); - await cards.nth(1).getByRole("button", { name: "Play voice note" }).click(); + await secondCard.getByRole("button", { name: "Play voice note" }).click(); await expect(firstAudio).toHaveJSProperty("paused", true); await expect(secondAudio).toHaveJSProperty("paused", false); + await firstCard.getByRole("button", { name: "Play voice note" }).click(); + await expect(secondAudio).toHaveJSProperty("paused", true); + await expect(firstAudio).toHaveJSProperty("paused", false); }); diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index baf7e29a9c2..d9548589392 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -96,7 +96,13 @@ async function createWorkflow( await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + // The inspector swaps from the trigger to the new step after the click; an + // unscoped fill can land in the trigger's message-text condition instead. + const stepInspector = dialog.getByTestId("workflow-node-inspector"); + await expect( + stepInspector.getByRole("button", { name: "Action", exact: true }), + ).toBeVisible(); + await stepInspector.getByLabel("Message text").fill("Workflow notification"); if (options?.stepName) { await dialog.getByRole("button", { name: "Step details" }).click(); await dialog.getByLabel("Name (optional)").fill(options.stepName); diff --git a/scripts/package-chart.sh b/scripts/package-chart.sh new file mode 100755 index 00000000000..d0d58c69775 --- /dev/null +++ b/scripts/package-chart.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Set image defaults in the published artifact without changing the source chart. +set -euo pipefail + +chart="${1:?chart directory is required}" +export IMAGE_REPOSITORY="${2:?image repository is required}" +export IMAGE_TAG="${3-}" +destination="${4:?package destination is required}" +package_dir="$(mktemp -d)" +trap 'rm -rf "$package_dir"' EXIT +cp -a "$chart" "$package_dir/chart" + +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml - "$package_dir/chart/values.yaml" <<'RUBY' +path = ARGV.fetch(0) +values = YAML.load_file(path) +image = values.fetch('image') +image['repository'] = ENV.fetch('IMAGE_REPOSITORY').downcase +tag = ENV.fetch('IMAGE_TAG') +image['tag'] = tag unless tag.empty? +File.write(path, YAML.dump(values)) +RUBY + +helm package "$package_dir/chart" --destination "$destination" diff --git a/scripts/test-chart-packaging.sh b/scripts/test-chart-packaging.sh new file mode 100755 index 00000000000..4f79ce9265a --- /dev/null +++ b/scripts/test-chart-packaging.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail +chart="${1:?chart directory is required}" +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT +cp "$chart/values.yaml" "$test_dir/source-values.yaml" + +for scenario in upstream fork override; do + case "$scenario" in + upstream) repository=ghcr.io/block/buzz; tag='' ;; + fork) repository=GHCR.IO/VoltageCloud/Buzz; tag=main ;; + override) repository=GHCR.IO/Custom/Relay; tag=release-123 ;; + esac + destination="$test_dir/$scenario" + mkdir -p "$destination" + scripts/package-chart.sh "$chart" "$repository" "$tag" "$destination" + helm show values "$destination"/*.tgz > "$destination/values.yaml" + env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml - \ + "$test_dir/source-values.yaml" "$destination/values.yaml" "$repository" "$tag" <<'RUBY' +source = YAML.load_file(ARGV.fetch(0)) +packaged = YAML.load_file(ARGV.fetch(1)) +expected = source.dup +expected['image'] = source.fetch('image').merge('repository' => ARGV.fetch(2).downcase) +expected['image']['tag'] = ARGV.fetch(3) unless ARGV.fetch(3).empty? +raise 'packaged values do not match the selected image' unless packaged == expected +RUBY +done +cmp "$chart/values.yaml" "$test_dir/source-values.yaml"