feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
// Custom-emoji end-to-end guard.
|
||||
//
|
||||
// The composer renders a known `:shortcode:` as a real inline atom node
|
||||
// (`img[data-custom-emoji]`) that selects/copies/deletes as one unit, while
|
||||
// still serializing to `:shortcode:` on send. The message timeline renders the
|
||||
// same shortcode as `img[data-custom-emoji]` via remarkCustomEmoji.
|
||||
//
|
||||
// The `:buzz:` shortcode lives in a member-authored kind:30030 set
|
||||
// (d=`buzz:custom-emoji`) served by the mock bridge from two distinct
|
||||
// pubkeys. `listCustomEmoji` reads every member's set over the relay WS and
|
||||
// unions them (deduped by shortcode+url) into the community palette — which is
|
||||
// live even in mock-bridge mode (the mock only intercepts Tauri commands), so
|
||||
// this spec uses the simpler mock-bridge setup like messaging.spec.ts.
|
||||
const SHORTCODE = "buzz";
|
||||
const MOCK_MEDIA_PROXY_PORT = 54321;
|
||||
|
||||
async function waitForMockLiveSubscription(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
({ ch }) =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
||||
channelName: string;
|
||||
}) => boolean;
|
||||
}
|
||||
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
|
||||
false,
|
||||
{ ch: channelName },
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function openGeneral(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await waitForMockLiveSubscription(page, "general");
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
// The mock emoji sets point at example.com placeholder URLs. Fulfill them
|
||||
// with a real image so visibility assertions still prove the emoji rendered
|
||||
// as an actual, non-broken custom-emoji image.
|
||||
await page.route("https://example.com/e2e/**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#22c55e"/></svg>',
|
||||
}),
|
||||
);
|
||||
// Keep the reaction fixture deliberately non-square so the real renderer can
|
||||
// prove object-contain letterboxes it inside the fixed square glyph box.
|
||||
await page.route(
|
||||
`http://127.0.0.1:${MOCK_MEDIA_PROXY_PORT}/media/**`,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#22c55e"/></svg>',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("typing a known :shortcode: renders an inline emoji node in the composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
// pressSequentially (not fill) so the node input rule fires on the final ":".
|
||||
await input.pressSequentially(`:${SHORTCODE}:`);
|
||||
|
||||
const node = input.locator("img[data-custom-emoji]");
|
||||
await expect(node).toHaveCount(1);
|
||||
await expect(node).toHaveAttribute("alt", `:${SHORTCODE}:`);
|
||||
await expect(node).toHaveAttribute("data-shortcode", SHORTCODE);
|
||||
// The raw text must NOT linger alongside the node.
|
||||
await expect(input).not.toContainText(`:${SHORTCODE}:`);
|
||||
});
|
||||
|
||||
test("emoji autocomplete ranks an exact standard shortcode before a custom substring", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openGeneral(page);
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
await input.pressSequentially(":joy");
|
||||
|
||||
const autocomplete = page.getByTestId("emoji-autocomplete");
|
||||
await expect(autocomplete).toBeVisible();
|
||||
const labels = await autocomplete.locator("button").allTextContents();
|
||||
expect(labels[0]).toContain(":joy:");
|
||||
expect(
|
||||
labels.findIndex((label) => label.includes(":bufo_joy:")),
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const screenshotDir = path.resolve(
|
||||
"test-results/emoji-autocomplete-screenshots",
|
||||
);
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
const screenshotPath = path.join(
|
||||
screenshotDir,
|
||||
"joy-exact-before-custom-substring.png",
|
||||
);
|
||||
await waitForAnimations(page);
|
||||
await autocomplete.screenshot({ path: screenshotPath });
|
||||
await testInfo.attach("joy-exact-before-custom-substring", {
|
||||
path: screenshotPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
test("emoji autocomplete keeps semantic matches ahead of loose shortcode fallbacks", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
await input.pressSequentially(":sad");
|
||||
|
||||
const autocomplete = page.getByTestId("emoji-autocomplete");
|
||||
await expect(autocomplete).toBeVisible();
|
||||
await expect(autocomplete.locator("button").first()).not.toContainText(
|
||||
":sandwich:",
|
||||
);
|
||||
});
|
||||
|
||||
test("custom emoji deletes as a single unit (like a built-in emoji)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
await input.pressSequentially(`hi :${SHORTCODE}:`);
|
||||
|
||||
const node = input.locator("img[data-custom-emoji]");
|
||||
await expect(node).toHaveCount(1);
|
||||
|
||||
// One backspace at the end removes the whole atom node, not a character of
|
||||
// hidden text.
|
||||
await input.press("Backspace");
|
||||
await expect(node).toHaveCount(0);
|
||||
await expect(input).toContainText("hi");
|
||||
});
|
||||
|
||||
test("custom emoji round-trips through select-all + send to the timeline", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
await input.pressSequentially(`:${SHORTCODE}:`);
|
||||
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
|
||||
|
||||
// Select-all then a single delete clears the node as one unit, proving it is
|
||||
// part of the selectable document (the bug was the caret skipping it).
|
||||
await input.press("ControlOrMeta+a");
|
||||
await input.press("Backspace");
|
||||
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(0);
|
||||
|
||||
// Re-enter and send: it must serialize to `:shortcode:` and re-render as an
|
||||
// <img> in the timeline (remarkCustomEmoji), not as raw text.
|
||||
await input.pressSequentially(`:${SHORTCODE}:`);
|
||||
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const sentEmoji = page
|
||||
.getByTestId("message-timeline")
|
||||
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`);
|
||||
await expect(sentEmoji.last()).toBeVisible();
|
||||
await sentEmoji.last().hover();
|
||||
await expect(page.getByText(`:${SHORTCODE}:`).last()).toBeVisible();
|
||||
// The composer clears after send.
|
||||
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("native emoji-only messages leave space below the author metadata", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
const nativeEmoji = "😜";
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
await page.keyboard.insertText(nativeEmoji);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const row = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: nativeEmoji })
|
||||
.last();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const author = row.getByText("npub1mock...", { exact: true });
|
||||
const emojiBody = row.locator(".text-4xl").last();
|
||||
await expect(author).toBeVisible();
|
||||
await expect(emojiBody).toContainText(nativeEmoji);
|
||||
await expect(emojiBody).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const authorBox = await author.boundingBox();
|
||||
const emojiBodyBox = await emojiBody.boundingBox();
|
||||
if (!authorBox || !emojiBodyBox) {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
return emojiBodyBox.y - (authorBox.y + authorBox.height);
|
||||
})
|
||||
.toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
// Regression guard for custom-emoji REACTIONS.
|
||||
//
|
||||
// The bug (shipped in the custom-emoji launch, PR #816): the reaction renderer
|
||||
// put the relay emoji URL straight into <img src> without going through
|
||||
// rewriteRelayUrl(). WKWebView bypasses the VPN tunnel, so the direct relay URL gets a
|
||||
// Cloudflare Access 403 and shows a broken image — even though the same emoji
|
||||
// rendered fine inline in chat (that path rewrites). The chat path was covered
|
||||
// by the tests above; the reaction path was not, which is why it slipped.
|
||||
//
|
||||
// This drives the real interactive react flow (hover -> Open reactions ->
|
||||
// emoji-mart custom category) so it exercises the add_reaction Tauri command,
|
||||
// then asserts the rendered reaction <img> src points at the loopback media
|
||||
// proxy. On the pre-fix code the src would be the raw relay URL, so this test
|
||||
// fails there — exactly the assertion that would have caught the bug.
|
||||
//
|
||||
// `:react:` is a relay-hosted fixture emoji (URL on the relay origin matching
|
||||
// rewriteRelayUrl()'s /media/{64-hex}.{ext} pattern), and the mock bridge
|
||||
// answers get_media_proxy_port with port 54321 so the rewrite resolves to a
|
||||
// real 127.0.0.1 URL rather than the buzz-media:// fallback.
|
||||
|
||||
const REACTION_SHORTCODE = "react";
|
||||
const SELECTED_ACTION_CLASS = /(^|\s)bg-secondary(\s|$)/;
|
||||
// A seeded message in `general` with a real 64-hex id — the only reactable
|
||||
// target in mock mode (getReactionTargetId() requires a 64-hex `e` tag, which
|
||||
// user-sent mock messages don't have). Mirrors REACTION_TARGET_CONTENT in the
|
||||
// bridge.
|
||||
const REACTION_TARGET_CONTENT = "React to me with a custom emoji";
|
||||
|
||||
function reactionTargetRow(page: import("@playwright/test").Page) {
|
||||
return page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: REACTION_TARGET_CONTENT })
|
||||
.last();
|
||||
}
|
||||
|
||||
function messageActionBar(row: import("@playwright/test").Locator) {
|
||||
return row.locator("[data-testid^='message-action-bar-']");
|
||||
}
|
||||
|
||||
function messageReactionTrigger(row: import("@playwright/test").Locator) {
|
||||
return row.locator("[data-testid^='react-message-']");
|
||||
}
|
||||
|
||||
async function quickReactionStorageContains(
|
||||
page: import("@playwright/test").Page,
|
||||
emoji: string,
|
||||
) {
|
||||
return page.evaluate((selectedEmoji) => {
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (!key?.startsWith("buzz.quick-reaction-emojis.v1")) continue;
|
||||
if (window.localStorage.getItem(key)?.includes(selectedEmoji)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, emoji);
|
||||
}
|
||||
|
||||
test("message quick reaction tray stays neutral after selecting a tray emoji", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
const row = reactionTargetRow(page);
|
||||
await expect(row).toBeVisible();
|
||||
await row.hover();
|
||||
|
||||
const quickReactionButton = row.getByRole("button", {
|
||||
name: "React with :+1:",
|
||||
});
|
||||
await expect(quickReactionButton).toBeVisible();
|
||||
await quickReactionButton.click();
|
||||
|
||||
await expect(row.getByLabel("Toggle 👍 reaction")).toBeVisible();
|
||||
await row.hover();
|
||||
await expect(quickReactionButton).not.toHaveAttribute("aria-pressed", "true");
|
||||
await expect(quickReactionButton).not.toHaveClass(SELECTED_ACTION_CLASS);
|
||||
await expect(messageReactionTrigger(row)).not.toHaveClass(
|
||||
SELECTED_ACTION_CLASS,
|
||||
);
|
||||
});
|
||||
|
||||
test("reacting with a custom emoji renders via the loopback media proxy", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
// Reveal the hover action bar on the seeded reaction-target message, then
|
||||
// open the reaction picker.
|
||||
const row = reactionTargetRow(page);
|
||||
await expect(row).toBeVisible();
|
||||
await row.hover();
|
||||
await row.getByLabel("Open reactions").click();
|
||||
|
||||
// emoji-mart renders inside a Shadow DOM web component. Search by shortcode
|
||||
// to surface the custom emoji, then click it.
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await picker.locator("input[type='search']").fill(REACTION_SHORTCODE);
|
||||
await picker
|
||||
.getByRole("button", { name: `:${REACTION_SHORTCODE}:` })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The reaction pill renders the custom emoji as an <img alt=":react:">. Its
|
||||
// src must be the loopback proxy URL — proving rewriteRelayUrl() ran. A raw
|
||||
// relay URL here is the bug.
|
||||
const reactionPill = row.getByLabel(
|
||||
`Toggle :${REACTION_SHORTCODE}: reaction`,
|
||||
);
|
||||
const reactionImg = reactionPill.locator(
|
||||
`img[alt=':${REACTION_SHORTCODE}:']`,
|
||||
);
|
||||
await expect(reactionImg).toBeVisible();
|
||||
await expect(reactionImg).toHaveAttribute(
|
||||
"src",
|
||||
new RegExp(
|
||||
`^http://127\\.0\\.0\\.1:${MOCK_MEDIA_PROXY_PORT}/media/[\\da-f]{64}\\.png$`,
|
||||
),
|
||||
);
|
||||
await expect(reactionPill).toHaveCSS("height", "28px");
|
||||
await expect(reactionImg).toHaveCSS("height", "14px");
|
||||
await expect(reactionImg).toHaveCSS("width", "14px");
|
||||
await expect(reactionImg).toHaveCSS("object-fit", "contain");
|
||||
await expect(reactionImg).toHaveCSS("transform", "none");
|
||||
await expect
|
||||
.poll(() =>
|
||||
reactionImg.evaluate((image) => {
|
||||
const imageRect = image.getBoundingClientRect();
|
||||
const pillRect = image.closest("button")?.getBoundingClientRect();
|
||||
if (!(image instanceof HTMLImageElement) || !pillRect) return null;
|
||||
return {
|
||||
naturalHeight: image.naturalHeight,
|
||||
naturalWidth: image.naturalWidth,
|
||||
topOffset: imageRect.top - pillRect.top,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.toEqual({ naturalHeight: 20, naturalWidth: 40, topOffset: 7 });
|
||||
|
||||
await expect
|
||||
.poll(() => quickReactionStorageContains(page, `:${REACTION_SHORTCODE}:`))
|
||||
.toBe(true);
|
||||
await expect(
|
||||
messageActionBar(row).locator("button[title=':react:']"),
|
||||
).toHaveCount(0);
|
||||
await expect(messageReactionTrigger(row)).not.toHaveClass(
|
||||
SELECTED_ACTION_CLASS,
|
||||
);
|
||||
|
||||
const inlineAddReactionButton = row.getByLabel("Add reaction");
|
||||
// The picker closes with the pointer/focus position depending on animation
|
||||
// timing. Put the row into a deterministic idle state before checking the
|
||||
// pill's pre-existing hidden behavior.
|
||||
await page.mouse.move(0, 0);
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
inlineAddReactionButton.evaluate((button) => {
|
||||
return getComputedStyle(button).opacity;
|
||||
}),
|
||||
)
|
||||
.toBe("0");
|
||||
await expect
|
||||
.poll(() =>
|
||||
inlineAddReactionButton.evaluate((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||
}),
|
||||
)
|
||||
.toBe("40x28");
|
||||
await expect
|
||||
.poll(() =>
|
||||
inlineAddReactionButton.evaluate((button) => {
|
||||
return getComputedStyle(button).transitionProperty;
|
||||
}),
|
||||
)
|
||||
.not.toContain("width");
|
||||
await row.hover();
|
||||
await expect(inlineAddReactionButton).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
inlineAddReactionButton.evaluate((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||
}),
|
||||
)
|
||||
.toBe("40x28");
|
||||
|
||||
// Toggle the reaction back off: click the pill, which fires remove_reaction
|
||||
// -> emits a kind:5 deletion targeting the reaction event. The pill must
|
||||
// disappear. Guards the mock-bridge deletion path: the reaction event needs a
|
||||
// 64-hex id, because the timeline only honors deletions whose `e` tag is
|
||||
// 64-hex (getDeletionTargets). A 32-hex reaction id leaves a stale pill here.
|
||||
await reactionPill.click();
|
||||
await expect(reactionImg).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Edit-flow regression guards.
|
||||
//
|
||||
// Two bugs lived on the edit path and were invisible to the send-only specs
|
||||
// above:
|
||||
// Bug 1 — opening a message that contains a custom emoji for editing showed
|
||||
// the literal `:shortcode:` text in the composer instead of the inline
|
||||
// image. The node only materialized via the live input rule; loading via
|
||||
// `setContent` (how edit-open seeds the composer) left it as text because
|
||||
// the customEmoji node had no markdown parse rule.
|
||||
// Bug 2 — adding a custom emoji while editing, then saving, shipped a bare
|
||||
// `:shortcode:` because the edit-save path didn't attach NIP-30 emoji tags
|
||||
// (the send path does). Without those tags the renderer can't resolve the
|
||||
// shortcode → literal text in the timeline.
|
||||
//
|
||||
// These drive the real interactive edit flow (More actions → Edit message →
|
||||
// save) so they exercise the `edit_message` Tauri command end to end. The mock
|
||||
// bridge mirrors the real relay: it emits a kind:40003 edit event carrying the
|
||||
// emoji tags, and the timeline overlays it via applyEditTagOverlay.
|
||||
|
||||
async function openMessageEditor(
|
||||
page: import("@playwright/test").Page,
|
||||
rowText: string,
|
||||
) {
|
||||
const row = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: rowText })
|
||||
.last();
|
||||
await expect(row).toBeVisible();
|
||||
await row.hover();
|
||||
await row.getByLabel("More actions").click();
|
||||
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
||||
// The composer enters edit mode (shows the edit-target banner).
|
||||
await expect(page.getByTestId("edit-target")).toBeVisible();
|
||||
}
|
||||
|
||||
test("editing a message with a custom emoji shows the image, not the shortcode (Bug 1)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
// Send our own message containing a custom emoji so it is editable.
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
await input.pressSequentially(`edit-bug1 :${SHORTCODE}:`);
|
||||
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-timeline")
|
||||
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`)
|
||||
.last(),
|
||||
).toBeVisible();
|
||||
|
||||
// Open it for editing. The composer loads via setContent — the path the
|
||||
// markdown parse rule fixes. The known shortcode must render as the inline
|
||||
// node, NOT as literal `:buzz:` text.
|
||||
await openMessageEditor(page, "edit-bug1");
|
||||
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
|
||||
await expect(input.locator("img[data-custom-emoji]")).toHaveAttribute(
|
||||
"alt",
|
||||
`:${SHORTCODE}:`,
|
||||
);
|
||||
// The raw shortcode text must NOT linger in the editor.
|
||||
await expect(input).not.toContainText(`:${SHORTCODE}:`);
|
||||
});
|
||||
|
||||
test("adding a custom emoji while editing keeps the image after save (Bug 2)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
// Send a plain message we'll edit to add an emoji to.
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.click();
|
||||
await input.pressSequentially("edit-bug2 plain");
|
||||
await page.getByTestId("send-message").click();
|
||||
const row = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "edit-bug2" })
|
||||
.last();
|
||||
await expect(row).toBeVisible();
|
||||
// No emoji yet.
|
||||
await expect(row.locator("img[data-custom-emoji]")).toHaveCount(0);
|
||||
|
||||
// Edit it: append a custom emoji, then save.
|
||||
await openMessageEditor(page, "edit-bug2");
|
||||
await input.click();
|
||||
await input.pressSequentially(` :${SHORTCODE}:`);
|
||||
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
// After the edit round-trips through edit_message → kind:40003 (with emoji
|
||||
// tags) → applyEditTagOverlay, the timeline must render the emoji as an
|
||||
// <img>, not a bare `:buzz:`. The pre-fix edit path shipped no emoji tags,
|
||||
// so this row would show literal text and fail here.
|
||||
await expect(
|
||||
row.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`),
|
||||
).toBeVisible();
|
||||
await expect(row).not.toContainText(`:${SHORTCODE}:`);
|
||||
});
|
||||
|
||||
// System-message reaction guard. The original bug in this PR: system messages
|
||||
// (joins, topic changes, etc.) couldn't take reactions. The seeded kind:40099
|
||||
// join event renders via SystemMessageRow, which now carries the reaction
|
||||
// affordance. This drives the real react flow on a system row and asserts the
|
||||
// pill appears — the surface the fix targeted.
|
||||
test("a system message accepts a custom-emoji reaction", async ({ page }) => {
|
||||
await openGeneral(page);
|
||||
|
||||
const row = page.getByTestId("system-message-row").first();
|
||||
await expect(row).toBeVisible();
|
||||
await row.hover();
|
||||
await row.getByLabel("Open reactions").click();
|
||||
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await picker.locator("input[type='search']").fill(REACTION_SHORTCODE);
|
||||
await picker
|
||||
.getByRole("button", { name: `:${REACTION_SHORTCODE}:` })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const reactionImg = row
|
||||
.getByLabel(`Toggle :${REACTION_SHORTCODE}: reaction`)
|
||||
.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
|
||||
await expect(reactionImg).toBeVisible();
|
||||
});
|
||||
|
||||
test("emoji picker search input has spellcheck, autocorrect, and autocapitalize disabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
// Open the reaction picker on the seeded reactable message.
|
||||
const row = reactionTargetRow(page);
|
||||
await expect(row).toBeVisible();
|
||||
await row.hover();
|
||||
await row.getByLabel("Open reactions").click();
|
||||
|
||||
// Wait for the picker to be visible, then read the shadow-root input attributes.
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await expect(picker.locator("input[type='search']")).toBeVisible();
|
||||
|
||||
const attrs = await picker.evaluate((el: Element) => {
|
||||
const input = (
|
||||
el as HTMLElement & { shadowRoot: ShadowRoot }
|
||||
).shadowRoot?.querySelector<HTMLInputElement>('input[type="search"]');
|
||||
if (!input) throw new Error("search input not found in shadow root");
|
||||
return {
|
||||
spellcheck: input.spellcheck,
|
||||
autocorrect: input.getAttribute("autocorrect"),
|
||||
autocapitalize: input.getAttribute("autocapitalize"),
|
||||
};
|
||||
});
|
||||
|
||||
expect(attrs.spellcheck).toBe(false);
|
||||
expect(attrs.autocorrect).toBe("off");
|
||||
expect(attrs.autocapitalize).toBe("off");
|
||||
});
|
||||
|
||||
// Regression guard for PR #1438: after the spellcheck fix, the shadow-DOM
|
||||
// traversal must also own autofocus so Radix's focus-scope can't steal it.
|
||||
// Will reported that opening the picker required a manual click before typing.
|
||||
test("emoji picker search input is focused immediately on open (no manual click needed)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
// Open the reaction picker — it passes autoFocus, so the input must receive
|
||||
// focus via our shadow traversal before the user interacts.
|
||||
const row = reactionTargetRow(page);
|
||||
await expect(row).toBeVisible();
|
||||
await row.hover();
|
||||
await row.getByLabel("Open reactions").click();
|
||||
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await expect(picker.locator("input[type='search']")).toBeVisible();
|
||||
|
||||
// The search input must be the active element inside the shadow root.
|
||||
// If Radix's focus-scope wins instead, shadowRoot.activeElement is the
|
||||
// popover host (or null), not the input — this assertion catches that.
|
||||
const isFocused = await picker.evaluate((el: Element) => {
|
||||
const host = el as HTMLElement & { shadowRoot: ShadowRoot };
|
||||
const input = host.shadowRoot?.querySelector<HTMLInputElement>(
|
||||
'input[type="search"]',
|
||||
);
|
||||
if (!input) return false;
|
||||
return host.shadowRoot?.activeElement === input;
|
||||
});
|
||||
expect(isFocused).toBe(true);
|
||||
});
|
||||
|
||||
test("composer emoji picker search input is focused immediately on open", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
// Open the composer emoji picker via the toolbar button.
|
||||
await page.getByTestId("composer-emoji-button").click();
|
||||
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await expect(picker.locator("input[type='search']")).toBeVisible();
|
||||
|
||||
// The search input must be the active element inside the shadow root so
|
||||
// the user can type immediately without a manual click.
|
||||
const isFocused = await picker.evaluate((el: Element) => {
|
||||
const host = el as HTMLElement & { shadowRoot: ShadowRoot };
|
||||
const input = host.shadowRoot?.querySelector<HTMLInputElement>(
|
||||
'input[type="search"]',
|
||||
);
|
||||
if (!input) return false;
|
||||
return host.shadowRoot?.activeElement === input;
|
||||
});
|
||||
expect(isFocused).toBe(true);
|
||||
|
||||
// Pick an emoji and verify focus returns to the composer message input,
|
||||
// not left stranded in the now-closed popover. The insertEmoji handler
|
||||
// calls editor.chain().focus() before setIsEmojiPickerOpen(false), so
|
||||
// the Tiptap editor owns focus before Radix's onCloseAutoFocus fires.
|
||||
await picker.getByRole("button", { name: "😀" }).first().click();
|
||||
await expect(page.getByTestId("message-input")).toBeFocused();
|
||||
});
|
||||
|
||||
test("composer emoji picker restores editor focus on Escape dismiss", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openGeneral(page);
|
||||
|
||||
// Open the composer emoji picker via the toolbar button.
|
||||
await page.getByTestId("composer-emoji-button").click();
|
||||
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await expect(picker.locator("input[type='search']")).toBeVisible();
|
||||
|
||||
// Dismiss via Escape without selecting an emoji. The onClose callback
|
||||
// in ComposerEmojiPicker must restore focus to the editor so the user
|
||||
// can keep typing without an extra click.
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByTestId("message-input")).toBeFocused();
|
||||
});
|
||||
Reference in New Issue
Block a user