Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ArrowLeft } from "lucide-react";
import * as React from "react";

import type { CreateProjectInput } from "@/features/projects/useCreateProject";
import { CREATE_PROJECT_REFOCUS_DELAY_MS } from "@/features/projects/ui/createProjectFormFocus";
import { CreateProjectFormSettings } from "@/features/projects/ui/CreateProjectFormSettings";
import { useCreateProjectFormSettings } from "@/features/projects/ui/useCreateProjectFormSettings";
import { cn } from "@/shared/lib/cn";
Expand Down Expand Up @@ -46,7 +47,7 @@ export function CreateProjectFormContent({

const timerId = globalThis.setTimeout(() => {
nameInputRef.current?.focus();
}, 50);
}, CREATE_PROJECT_REFOCUS_DELAY_MS);
return () => globalThis.clearTimeout(timerId);
}, [active, initialName]);

Expand Down
5 changes: 5 additions & 0 deletions desktop/src/features/projects/ui/createProjectFormFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Delay before the create-project form focuses its name field again after it
* opens. E2E specs import it to wait out that refocus before filling the form.
*/
export const CREATE_PROJECT_REFOCUS_DELAY_MS = 50;
95 changes: 81 additions & 14 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,11 @@ type E2eConfig = {
/** Reject successive mock `join_channel` calls, then resume. */
joinChannelErrors?: string[];
channelsReadDelayMs?: number;
/**
* Hold the first `get_channels` read until
* `__BUZZ_E2E_RELEASE_CHANNELS_READ__` is called.
*/
deferFirstChannelsRead?: boolean;
/** Return not-modified for this many reads before resuming full payloads. */
channelsNotModifiedResponses?: number;
/** When true, a matching knownHash returns a not-modified channel payload. */
Expand Down Expand Up @@ -1352,6 +1357,11 @@ declare global {
event: string,
payload: unknown,
) => Promise<void>;
/**
* Number of live app listeners for a mocked Tauri event. Spec
* subscriptions made through `__TAURI_INTERNALS__.listen` are not counted.
*/
__BUZZ_E2E_EVENT_LISTENER_COUNT__?: (event: string) => number;
__BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__?: (input: {
members: MockHuddleMemberSeed[];
transcriptionEnabled: boolean;
Expand Down Expand Up @@ -8771,7 +8781,7 @@ async function replayInstallOutput(
);
}
// `emit` reaches listeners registered through the real `listen` API, which
// is what the UI hook uses; mockIPC's shouldMockEvents wires the two.
// is what the UI hook uses; the bridge's mocked event plugin wires the two.
await emit("acp-install-output", {
runtime_id: runtimeId,
seq: installOutputSeq++,
Expand Down Expand Up @@ -11824,7 +11834,7 @@ export function maybeInstallE2eTauriMocks() {
for (const release of held) release();
return held.length;
};
deferNextChannelsRead = false;
deferNextChannelsRead = config.mock?.deferFirstChannelsRead === true;
deferredChannelsReadResolve = null;
window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0;
window.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__ = () => {
Expand Down Expand Up @@ -15380,21 +15390,78 @@ export function maybeInstallE2eTauriMocks() {
handleMockCommand(command, payload ?? null);
window.__BUZZ_E2E_EMIT_TAURI_EVENT__ = (event, payload) =>
emit(event, payload);
mockIPC(handleMockCommand, { shouldMockEvents: true });
const tauriInternals = (
window as typeof window & {
__TAURI_INTERNALS__: {
listen?: (
event: string,
callback: () => void,
) => Promise<() => Promise<void>>;
};
// The mocked emit drops events that have no listener yet. The event plugin is
// mocked here, as mockIPC's `shouldMockEvents` would, so live app listeners
// can be counted per event and specs can wait for the app to subscribe first.
// Wrapping `__TAURI_INTERNALS__.invoke` instead would break specs that trap
// mockIPC's assignment of it.
// Spec subscriptions pass this target object, which listen() forwards as is
// and the mock ignores, so a spec that subscribes first cannot satisfy that
// wait for the app.
const tauriInternals = () =>
(
window as typeof window & {
__TAURI_INTERNALS__: {
runCallback: (id: number, data: unknown) => void;
listen?: (
event: string,
callback: () => void,
) => Promise<() => Promise<void>>;
};
}
).__TAURI_INTERNALS__;
const specListenTarget = { kind: "Any" } as const;
const eventHandlers = new Map<string, number[]>();
const appEventHandlers = new Map<string, Set<number>>();
const handleMockIpc = (command: string, payload: unknown) => {
const args = (payload ?? {}) as {
event: string;
handler?: number;
eventId?: number;
target?: unknown;
};
switch (command) {
case "plugin:event|listen": {
const handler = args.handler as number;
eventHandlers.set(args.event, [
...(eventHandlers.get(args.event) ?? []),
handler,
]);
if (args.target !== specListenTarget) {
const handlers =
appEventHandlers.get(args.event) ?? new Set<number>();
handlers.add(handler);
appEventHandlers.set(args.event, handlers);
}
return Promise.resolve(handler);
}
case "plugin:event|emit":
for (const handler of eventHandlers.get(args.event) ?? []) {
tauriInternals().runCallback(handler, args);
}
return Promise.resolve(null);
case "plugin:event|unlisten":
eventHandlers.set(
args.event,
(eventHandlers.get(args.event) ?? []).filter(
(handler) => handler !== args.eventId,
),
);
appEventHandlers.get(args.event)?.delete(args.eventId as number);
return Promise.resolve(null);
default:
return handleMockCommand(command, payload);
}
).__TAURI_INTERNALS__;
};
mockIPC(handleMockIpc);
window.__BUZZ_E2E_EVENT_LISTENER_COUNT__ = (event) =>
appEventHandlers.get(event)?.size ?? 0;
// Page-evaluated E2E specs use this surface; delegate to Tauri's mocked channel
// so their listeners observe the same events emitted by application test seams.
tauriInternals.listen = async (event, callback) => {
const unlisten = await listen(event, () => callback());
tauriInternals().listen = async (event, callback) => {
const unlisten = await listen(event, () => callback(), {
target: specListenTarget,
});
return async () => unlisten();
};

Expand Down
15 changes: 13 additions & 2 deletions desktop/tests/e2e/agent-availability.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,9 @@ for (const scenario of [
} else {
await expect.poll(effects).toEqual(["shutdown"]);
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
await expect(page.locator("[data-sonner-toast]")).toContainText(
"shutdown refused",
);
// Retry the same user action without healing presence. A successful
// request permits the explicitly confirmed local deletion, not a claim
// of remote termination. No membership write precedes it.
Expand All @@ -733,8 +736,16 @@ for (const scenario of [
window as typeof window & { __DELETE_FAIL_SHUTDOWN__?: boolean }
).__DELETE_FAIL_SHUTDOWN__ = false;
});
await page.getByTestId("user-profile-delete-agent-row").click();
await page.getByTestId("agent-delete-confirm-action").click();
// The failure toast can cover the action rows, and a pointer over it
// pauses its dismissal. The toaster has no close button, so retry with
// the keyboard, which the toast cannot intercept.
const deleteRow = page.getByTestId("user-profile-delete-agent-row");
await expect(deleteRow).toBeEnabled();
await deleteRow.focus();
await deleteRow.press("Enter");
const confirmDelete = page.getByTestId("agent-delete-confirm-action");
await confirmDelete.focus();
await confirmDelete.press("Enter");
await expect
.poll(effects)
.toEqual(["shutdown", "shutdown", "delete", "remove-member"]);
Expand Down
62 changes: 28 additions & 34 deletions desktop/tests/e2e/channel-composer-overflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,17 @@ async function emit(
page: import("@playwright/test").Page,
content: string,
parentEventId: string | null = null,
createdAt?: number,
) {
const event = await page.evaluate(
(payload) =>
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: payload.channel,
content: payload.content,
createdAt: payload.createdAt,
parentEventId: payload.parentEventId,
}),
{ channel: CHANNEL, content, parentEventId },
{ channel: CHANNEL, content, createdAt, parentEventId },
);
if (!event) throw new Error("mock message emitter is not installed");
return event as { created_at: number; id: string };
Expand Down Expand Up @@ -221,9 +223,14 @@ test.describe("composer overlays mask scrolled content", () => {
await expect(page.getByTestId("message-timeline")).toBeVisible();
await waitForMockLiveSubscription(page, CHANNEL);

// Give each message its own second. Same-second messages order by id, so
// the last one emitted is not necessarily the bottom row.
const base = Math.floor(Date.now() / 1000);
let newestMessageId = "";
for (let i = 0; i < 20; i++) {
newestMessageId = (await emit(page, `Bottom-clearance message ${i}`)).id;
newestMessageId = (
await emit(page, `Bottom-clearance message ${i}`, null, base + i)
).id;
}
await page.waitForTimeout(300);
await page.evaluate(() => {
Expand All @@ -241,40 +248,27 @@ test.describe("composer overlays mask scrolled content", () => {
);
await waitForAnimations(page);

const clearsComposer = await page.evaluate((messageId) => {
const composer = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"] [data-testid="message-composer"]',
);
const newestRow = document.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(messageId)}"]`,
);
if (!composer || !newestRow) return false;
return (
newestRow.getBoundingClientRect().bottom <=
composer.getBoundingClientRect().top
);
}, newestMessageId);
expect(clearsComposer).toBe(true);
// The timeline re-pins to the bottom on the composer's resize, which can
// land after the animations settle, so poll rather than measure once.
const newestClearsComposer = () =>
page.evaluate((messageId) => {
const composer = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"] [data-testid="message-composer"]',
);
const newestRow = document.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(messageId)}"]`,
);
return Boolean(
composer &&
newestRow &&
newestRow.getBoundingClientRect().bottom <=
composer.getBoundingClientRect().top,
);
}, newestMessageId);
await expect.poll(newestClearsComposer).toBe(true);

await page.setViewportSize({ width: 1280, height: 620 });
await expect
.poll(() =>
page.evaluate((messageId) => {
const composer = document.querySelector<HTMLElement>(
'[data-testid="channel-composer-overlay"] [data-testid="message-composer"]',
);
const newestRow = document.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(messageId)}"]`,
);
return Boolean(
composer &&
newestRow &&
newestRow.getBoundingClientRect().bottom <=
composer.getBoundingClientRect().top,
);
}, newestMessageId),
)
.toBe(true);
await expect.poll(newestClearsComposer).toBe(true);
});

test("thread panel replies fade out behind the reply composer", async ({
Expand Down
17 changes: 8 additions & 9 deletions desktop/tests/e2e/message-copy-link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test";

import { KIND_HUDDLE_STARTED } from "../../src/shared/constants/kinds";
import { installMockBridge } from "../helpers/bridge";
import { waitForMockChannelHeadReady } from "../helpers/channelHeadReady";

const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";

Expand Down Expand Up @@ -163,29 +164,27 @@ test("pending and huddle rows omit both copy-link surfaces", async ({
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect
.poll(() =>
page.evaluate(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
),
)
.toBe(true);
// Emit only after history and its head refresh settle, so neither can
// replace the rows under test.
await waitForMockChannelHeadReady(page, "general", GENERAL_CHANNEL_ID);

// The ids must not match a seeded #general message. "d".repeat(64) is the
// seeded reaction target, which has a copy-link button of its own.
const { huddleId, pendingId } = await page.evaluate((huddleKind) => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) throw new Error("Mock message emitter is unavailable.");
const pending = emit({
channelName: "general",
content: "Pending copy-link regression",
id: "c".repeat(64),
id: "2e".repeat(32),
pending: true,
});
const huddle = emit({
channelName: "general",
content: JSON.stringify({
ephemeral_channel_id: "10000000-0000-4000-8000-000000000001",
}),
id: "d".repeat(64),
id: "1d".repeat(32),
kind: huddleKind,
});
return { huddleId: huddle.id, pendingId: pending.id };
Expand Down
14 changes: 10 additions & 4 deletions desktop/tests/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,15 @@ test("message links to visible root messages open the thread panel", async ({
name: "Open message in channel general",
});
await expect(rootThreadLink).toHaveText("general");
const pendingChipBox = await rootThreadLink.boundingBox();
// The sent row can still be mounting here, and boundingBox() then reads
// null. Poll until the chip has laid out and keep that pending width.
let pendingChipWidth = 0;
await expect
.poll(async () => {
pendingChipWidth = (await rootThreadLink.boundingBox())?.width ?? 0;
return pendingChipWidth;
})
.toBeGreaterThan(0);
await expect
.poll(() =>
page.evaluate(
Expand Down Expand Up @@ -776,9 +784,7 @@ test("message links to visible root messages open the thread panel", async ({
// channel label at the exact width it had while the fetch was in flight, and
// never the fetched snippet or the truncated event hash.
await expect(rootThreadLink).toHaveText("general");
expect((await rootThreadLink.boundingBox())?.width).toBe(
pendingChipBox?.width,
);
expect((await rootThreadLink.boundingBox())?.width).toBe(pendingChipWidth);
await expect(
messageTooltip.locator('[data-buzz-tooltip-metadata-content=""]'),
).toHaveClass(/line-clamp-3/);
Expand Down
9 changes: 4 additions & 5 deletions desktop/tests/e2e/nostr-bind.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,12 @@ async function openNostrBind(
) {
await installMockBridge(page, mock);
await page.goto("/");
// The mocked emit drops events with no listener, and the app subscribes to
// deep links only after boot. Wait for that listener before emitting.
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function",
(window.__BUZZ_E2E_EVENT_LISTENER_COUNT__?.("deep-link-nostr-bind") ??
0) > 0,
);
await emitNostrBind(page, payload);
await expect(page.getByTestId("nostr-bind-page")).toBeVisible();
Expand Down
Loading
Loading