From 44bb203c86ec26b2c6204e8b3069e36c933290de Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 20 Oct 2026 10:37:06 -0400 Subject: [PATCH] fix(ci): stabilize fork publishing and desktop smoke fixtures Publish images, caches, and packaged Helm charts under the owning repository, preserving explicit image overrides and upstream defaults. Persist thread fixtures without live delivery and arrange failed queries before project navigation. Add the reply after the cold-query failure so background history cannot mask the error and Retry must fetch it. Bind duplicate audio-player assertions to message IDs across timeline reordering. Loop the short fixture so only explicit playback handoff can satisfy the pause assertion, and exercise handoff in both directions. Backport the smoke-spec fixes from upstream block/buzz#8343 (7b11c61). The huddle voice-menu test waits for the composer's delayed autofocus before it opens the popover. The empty-edit test edits a per-test message with a real event id instead of the seeded non-hex welcome message. The workflow test fills the step inspector only after it shows the new step. Signed-off-by: Tee8z --- .github/workflows/docker.yml | 56 ++++++--- .github/workflows/helm-chart.yml | 28 +++-- .github/workflows/push-gateway-helm-chart.yml | 14 ++- .github/workflows/sprig-image.yml | 26 ++-- desktop/src/testing/e2eBridge.ts | 15 ++- desktop/tests/e2e/empty-edit-delete.spec.ts | 45 ++++++- .../tests/e2e/huddle-transcription.spec.ts | 4 + .../project-conversation-load-failure.spec.ts | 113 ++++++++---------- desktop/tests/e2e/voice-note.spec.ts | 27 +++-- desktop/tests/e2e/workflows.spec.ts | 8 +- scripts/package-chart.sh | 23 ++++ scripts/test-chart-packaging.sh | 28 +++++ 12 files changed, 273 insertions(+), 114 deletions(-) create mode 100755 scripts/package-chart.sh create mode 100755 scripts/test-chart-packaging.sh 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"