9dfa06ffee
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>
671 lines
25 KiB
TypeScript
671 lines
25 KiB
TypeScript
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();
|
|
});
|