Files
buzz/desktop/tests/e2e/drafts-screenshots.spec.ts
T
cls 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
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

444 lines
15 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/drafts";
// Mock bridge default pubkey — must match DEFAULT_MOCK_PUBKEY in bridge.ts
const MOCK_PUBKEY = "deadbeef".repeat(8);
const DRAFT_STORE_KEY = `buzz-drafts.v1:${MOCK_PUBKEY}`;
// Channel IDs from the mock bridge seed data
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
// Fixed timestamps for deterministic rendering
const CREATED_AT_1 = "2026-07-01T10:00:00.000Z";
const CREATED_AT_2 = "2026-07-02T14:30:00.000Z";
type StoredDraftState = {
content: string;
selectionStart: number;
selectionEnd: number;
channelId: string;
createdAt: string;
updatedAt: string;
pendingImeta: unknown[];
spoileredAttachmentUrls: string[];
status: "active" | "sent";
};
type StoredDrafts = Record<string, StoredDraftState>;
/** Active drafts: text draft in #general, image-only draft in #agents, long text draft. */
const ACTIVE_DRAFTS: StoredDrafts = {
[`channel:${GENERAL_CHANNEL_ID}`]: {
content:
"Hey team — I've been working on the new onboarding flow. Check out the latest mockups when you get a chance!",
selectionStart: 107,
selectionEnd: 107,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_1,
updatedAt: CREATED_AT_1,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
[`channel:${AGENTS_CHANNEL_ID}`]: {
// Image-only draft — exercises the "1 attachment" fallback in getDraftPreview
content: "",
selectionStart: 0,
selectionEnd: 0,
channelId: AGENTS_CHANNEL_ID,
createdAt: CREATED_AT_2,
updatedAt: CREATED_AT_2,
pendingImeta: [
{
url: "https://example.com/screenshot.png",
sha256: "abc123",
size: 204800,
type: "image/png",
dim: "1280x900",
},
],
spoileredAttachmentUrls: [],
status: "active",
},
};
/**
* Patch the mock community to include the pubkey so initDraftStore gets the
* correct pubkey on app startup. The community is seeded by installMockBridge
* without a pubkey field; this addInitScript runs after that seed (init
* scripts execute in registration order) and adds it.
*/
async function patchCommunityPubkey(page: import("@playwright/test").Page) {
await page.addInitScript(
({ pubkey }) => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<Record<string, unknown>>)
: [];
if (communities[0]) {
communities[0].pubkey = pubkey;
window.localStorage.setItem(
"buzz-communities",
JSON.stringify(communities),
);
}
},
{ pubkey: MOCK_PUBKEY },
);
}
/** Seed draft localStorage before page load via addInitScript. */
async function seedDraftStore(
page: import("@playwright/test").Page,
drafts: StoredDrafts,
) {
await page.addInitScript(
({ storeKey, value }) => {
window.localStorage.setItem(storeKey, JSON.stringify(value));
},
{ storeKey: DRAFT_STORE_KEY, value: drafts },
);
}
/** Navigate to `/`, wait for inbox, then select the Drafts filter. */
async function openDraftsPanel(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("inbox-filter-trigger").click();
await page.getByRole("menuitemradio", { name: "Drafts" }).click();
// Dismiss the dropdown so it doesn't obscure the panel assertions.
await page.keyboard.press("Escape");
const panel = page.getByTestId("home-inbox-drafts");
await expect(panel).toBeVisible({ timeout: 8_000 });
return panel;
}
test.describe("drafts screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
test("01 — drafts section populated", async ({ page }) => {
await installMockBridge(page);
await patchCommunityPubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
const panel = await openDraftsPanel(page);
// Both active draft rows should be visible
const draftRows = panel.locator("[data-testid^='home-draft-item-']");
await expect(draftRows).toHaveCount(2, { timeout: 6_000 });
// The text draft row shows content
await expect(
panel.getByText(
"Hey team — I've been working on the new onboarding flow.",
),
).toBeVisible({ timeout: 5_000 });
// The image-only draft shows the attachment fallback
await expect(panel.getByText("1 attachment")).toBeVisible({
timeout: 5_000,
});
// Section heading should be "DRAFTS"
await expect(panel.getByText("Drafts", { exact: true })).toBeVisible();
const detail = page.getByTestId("home-inbox-draft-detail");
await expect(detail).toBeVisible();
await expect(detail.getByText("1 attachment")).toBeVisible();
await expect(detail.getByText("You", { exact: true })).toBeVisible();
await expect(detail.getByText("Draft", { exact: true })).toBeVisible();
await detail.locator("article").hover();
await expect(
detail.getByTestId("home-inbox-draft-action-bar"),
).toBeVisible();
await waitForAnimations(page);
await page.getByTestId("home-inbox").screenshot({
path: `${SHOTS}/01-drafts-section-populated.png`,
});
});
test("02 — narrow draft list opens the detail view", async ({ page }) => {
await page.setViewportSize({ width: 520, height: 900 });
await installMockBridge(page);
await patchCommunityPubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
const panel = await openDraftsPanel(page);
const draftRow = panel.locator(
`[data-testid='home-draft-item-channel:${GENERAL_CHANNEL_ID}']`,
);
await draftRow.getByRole("button", { name: /View draft/ }).click();
const detail = page.getByTestId("home-inbox-draft-detail");
await expect(detail).toBeVisible();
await expect(panel).not.toBeVisible();
await detail.getByRole("button", { name: "Back to drafts list" }).click();
await expect(panel).toBeVisible();
await expect(detail).not.toBeVisible();
});
test("03 — hover actions visible", async ({ page }) => {
await installMockBridge(page);
await patchCommunityPubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
const panel = await openDraftsPanel(page);
// Wait for the text draft row
const textDraftRow = panel.locator(
`[data-testid='home-draft-item-channel:${GENERAL_CHANNEL_ID}']`,
);
await expect(textDraftRow).toBeVisible({ timeout: 6_000 });
// Hover to reveal action buttons
await textDraftRow.hover();
// All three action buttons should become visible on hover
const openDraftBtn = textDraftRow.getByRole("button", {
name: "Open draft",
exact: true,
});
const sendMessageBtn = textDraftRow.getByRole("button", {
name: "Send message",
exact: true,
});
const deleteDraftBtn = textDraftRow.getByRole("button", {
name: "Delete draft",
});
await expect(openDraftBtn).toBeVisible({ timeout: 4_000 });
await expect(sendMessageBtn).toBeVisible({ timeout: 4_000 });
await expect(deleteDraftBtn).toBeVisible({ timeout: 4_000 });
await waitForAnimations(page);
await panel.screenshot({ path: `${SHOTS}/03-hover-actions.png` });
});
test("04 — empty state", async ({ page }) => {
await installMockBridge(page);
// No draft seed → empty state
const panel = await openDraftsPanel(page);
// Empty state: FileText icon + "No drafts" text
await expect(panel.getByText("No drafts")).toBeVisible({ timeout: 5_000 });
await waitForAnimations(page);
await panel.screenshot({ path: `${SHOTS}/04-empty-state.png` });
});
test("05 — thread-draft send confirm dialog", async ({ page }) => {
// Regression test for IMPORTANT #1: thread-reply draft Send navigates to
// the correct channel and passes the autoSend key so the thread composer
// (not the main composer) arms the auto-submit.
await installMockBridge(page);
await patchCommunityPubkey(page);
// A fixed fake root event ID — in the mock bridge get_event is unhandled
// so useDraftRootStatus will map it to `error` (not `deleted`), keeping
// the draft sendable.
const THREAD_ROOT_ID =
"aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444";
const THREAD_DRAFT_KEY = `thread:${THREAD_ROOT_ID}`;
await page.addInitScript(
({ storeKey, value }) => {
window.localStorage.setItem(storeKey, JSON.stringify(value));
},
{
storeKey: DRAFT_STORE_KEY,
value: {
[THREAD_DRAFT_KEY]: {
content: "Thread reply draft content",
selectionStart: 26,
selectionEnd: 26,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_1,
updatedAt: CREATED_AT_1,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
} satisfies StoredDrafts,
},
);
const panel = await openDraftsPanel(page);
// The thread draft row should appear.
const draftRow = panel.locator(
`[data-testid='home-draft-item-${THREAD_DRAFT_KEY}']`,
);
await expect(draftRow).toBeVisible({ timeout: 8_000 });
// "Thread deleted" label must NOT appear — root status is `error` (optimistic).
await expect(panel.getByText("Thread deleted")).not.toBeVisible();
// Hover to reveal the three action buttons.
await draftRow.hover();
const sendBtn = draftRow.getByRole("button", {
name: "Send message",
exact: true,
});
await expect(sendBtn).toBeVisible({ timeout: 4_000 });
// Click "Send message" — confirm dialog should appear.
await sendBtn.click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 4_000 });
await expect(dialog.getByText("Send message")).toBeVisible();
// Confirm dialog body names the channel.
await expect(dialog.getByText(/general/i)).toBeVisible();
await waitForAnimations(page);
await panel.screenshot({
path: `${SHOTS}/05-thread-draft-send-dialog.png`,
});
// Click Send — dialog closes and we navigate to the channel with ?autoSend.
await dialog.getByRole("button", { name: "Send", exact: true }).click();
await expect(dialog).not.toBeVisible({ timeout: 4_000 });
// URL must include all three params set by DraftsPanel.handleConfirmSend:
// ?messageId=<rootId> — scroll-targets the root message in the timeline
// ?threadRootId=<rootId> — opens the thread panel for this root
// ?autoSend=thread:<rootId> — arms the thread composer's once-only guard
//
// Integration boundary: the mock bridge does not support get_event or
// message send, so ChannelRouteScreen.fetchRouteTargetEvents fails silently,
// threadHeadMessage stays null, and the MessageThreadPanel (and its composer)
// never mount. The auto-submit effect and the actual send are NOT assertable
// in this E2E shard — they are covered by MessageComposerAutoSend.test.mjs
// (key-match guard) and MessageComposerDraftImagePersist.test.mjs (full
// composer mount path).
await expect(page).toHaveURL(new RegExp(`messageId=${THREAD_ROOT_ID}`), {
timeout: 6_000,
});
await expect(page).toHaveURL(new RegExp(`threadRootId=${THREAD_ROOT_ID}`));
await expect(page).toHaveURL(
new RegExp(`autoSend=${encodeURIComponent(THREAD_DRAFT_KEY)}`),
);
});
test("06 — active-draft count appears only on the Drafts filter", async ({
page,
}) => {
// Draft counts stay beside the Drafts option instead of decorating the
// overall Inbox filter trigger. Two drafts make the count explicit.
await installMockBridge(page);
await patchCommunityPubkey(page);
await seedDraftStore(page, ACTIVE_DRAFTS);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("home-inbox")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("inbox-draft-badge")).toHaveCount(0);
await expect(page.getByTestId("inbox-filter-trigger")).toHaveAttribute(
"aria-label",
"Filter inbox: All. 2 active drafts",
);
// Open the filter dropdown so the badge-option is visible too.
await page.getByTestId("inbox-filter-trigger").click();
const dropdownBadge = page.getByTestId("inbox-draft-badge-option");
await expect(dropdownBadge).toBeVisible({ timeout: 4_000 });
await expect(dropdownBadge).toHaveText("2");
await waitForAnimations(page);
// Capture the Inbox header and the dropdown-only count.
await page.getByTestId("home-inbox").screenshot({
path: `${SHOTS}/06-draft-badge.png`,
});
// Dismiss the dropdown cleanly.
await page.keyboard.press("Escape");
});
test("07 — thread-deleted state (orphaned thread-reply draft)", async ({
page,
}) => {
// A thread-reply draft whose root event is definitively deleted:
// `useDraftRootStatus` maps it to `deleted`, showing the "Thread deleted"
// label, greying the row, and disabling Open/Send.
const DELETED_ROOT_ID =
"dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000";
const THREAD_DRAFT_KEY = `thread:${DELETED_ROOT_ID}`;
await installMockBridge(page, { deletedEventIds: [DELETED_ROOT_ID] });
await patchCommunityPubkey(page);
await seedDraftStore(page, {
[THREAD_DRAFT_KEY]: {
content: "Planning to follow up on the discussion from last week.",
selectionStart: 52,
selectionEnd: 52,
channelId: GENERAL_CHANNEL_ID,
createdAt: CREATED_AT_1,
updatedAt: CREATED_AT_1,
pendingImeta: [],
spoileredAttachmentUrls: [],
status: "active",
},
});
const panel = await openDraftsPanel(page);
// The orphaned draft row should render.
const draftRow = panel.locator(
`[data-testid='home-draft-item-${THREAD_DRAFT_KEY}']`,
);
await expect(draftRow).toBeVisible({ timeout: 8_000 });
// "Thread deleted" badge must appear once the root-status query resolves.
const orphanLabel = panel.getByTestId(
`home-draft-orphaned-label-${THREAD_DRAFT_KEY}`,
);
await expect(orphanLabel).toBeVisible({ timeout: 8_000 });
// Hover the row to confirm Open and Send are disabled.
await draftRow.hover();
// Both the open and send buttons are labelled "Thread deleted" when orphaned.
const disabledBtns = draftRow.getByRole("button", {
name: "Thread deleted",
});
await expect(disabledBtns).toHaveCount(2, { timeout: 4_000 });
// Delete is still enabled.
await expect(
draftRow.getByRole("button", { name: "Delete draft" }),
).toBeVisible({ timeout: 4_000 });
await waitForAnimations(page);
await panel.screenshot({
path: `${SHOTS}/07-thread-deleted-state.png`,
});
});
});