Files
multica/packages/views/modals/feedback.test.tsx
Naiyuan Qing 3b08456264 feat(views): block draft-fixing actions while attachments upload (#5465)
Submitting a composer mid-upload silently lost the file: the editor holds a
`blob:` placeholder that gets stripped during serialization, and the
attachment id does not exist yet, so the send succeeded without the file.
Chat and Quick Create gated this; manual issue create, Feedback's button,
comments, replies and comment edit did not.

Gate every action that FIXES a draft — Send/Create/Save, Cmd/Ctrl+Enter,
Enter on the title, and the manual/agent mode switches — behind one source
of truth: the editor document, which IS the upload queue. ContentEditor
publishes queue transitions via `onUploadingChange` (a transaction listener,
not `onUpdate` — that path is debounced and skips no-change emissions, and a
failed upload leaves byte-identical markdown, so the un-gate would never
fire). `useUploadGate` pairs that render state with an `isBlocked()` re-read
at submit time, since the shortcut paths never consult the button.

The publisher emits its current answer on subscribe rather than only on
flips: hosts outlive editor instances (comment edit remounts on cancel,
chat swaps by key on agent switch), and an editor torn down mid-upload would
otherwise leave submit wedged shut with no pending node left to reopen it.

Also fixes the failure toast that never fired: `uploadWithToast` only
reported errors if a caller passed `onError`, and none did, so failed uploads
removed their placeholder and vanished unexplained. `useEditorUpload` now
supplies it once for every composer.

Per MUL-4808: drops Quick Create's upload-time lock on the attach button
(files can queue), and leaves description autosave ungated by design.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 09:56:24 +08:00

214 lines
7.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { forwardRef, useImperativeHandle, useRef } from "react";
let storedDraftMessage = "saved draft";
let liveEditorMarkdown = "";
const feedbackMocks = vi.hoisted(() => ({ mutateAsync: vi.fn() }));
// Deferred controlling the mock editor's in-flight upload: `reset` arms a new
// pending upload, `resolve` lands it so a test can watch the gate re-open.
const pendingUpload = vi.hoisted(() => {
const deferred = {
promise: Promise.resolve() as Promise<void>,
resolve: () => {},
reset() {
deferred.promise = new Promise<void>((res) => {
deferred.resolve = res;
});
},
};
return deferred;
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key, i18n: { changeLanguage: vi.fn() } }),
Trans: ({ children }: { children: any }) => children,
initReactI18next: { type: "3rdParty", init: vi.fn() },
}));
vi.mock("../i18n", () => ({
useT: () => ({
t: (selector: (resources: any) => string) =>
selector({
feedback: {
title: "Feedback",
github_hint_prefix: "Prefer GitHub? ",
github_hint_link: "Open an issue",
placeholder: "Tell us what happened",
toast_uploading: "Uploading",
toast_too_long: "Too long",
toast_sent: "Sent",
toast_failed: "Failed",
sending: "Sending",
send: "Send",
},
// The `editor` namespace's shared upload-gate copy. This mock ignores
// the namespace argument, so both bundles live in one object.
upload: {
in_progress: "Uploading…",
},
}),
}),
}));
vi.mock("@multica/core/paths", () => ({ useCurrentWorkspace: () => ({ id: "ws1" }) }));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
}));
vi.mock("@multica/core/api", () => ({ api: {} }));
vi.mock("sonner", () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } }));
vi.mock("@multica/core/feedback", () => ({
FEEDBACK_KINDS: ["bug", "feature", "general", "praise"] as const,
useCreateFeedback: () => ({ isPending: false, mutateAsync: feedbackMocks.mutateAsync }),
useFeedbackDraftStore: (selector: any) =>
selector({ draft: { message: storedDraftMessage }, setDraft: vi.fn(), clearDraft: vi.fn() }),
}));
vi.mock("../editor", async () => {
// Real submit gate (pure React) driven by the mock editor's
// `hasActiveUploads` / `onUploadingChange`.
const uploadGate = await vi.importActual<typeof import("../editor/use-upload-gate")>(
"../editor/use-upload-gate",
);
const ContentEditor = forwardRef(({ defaultValue, onSubmit, onUploadingChange }: any, ref) => {
liveEditorMarkdown = defaultValue;
// Mirrors the real editor: the placeholder node is in the doc from before
// the await until the upload settles, and the host hears about it through
// onUploadingChange rather than polling.
const inFlightRef = useRef(0);
useImperativeHandle(ref, () => ({
hasActiveUploads: () => inFlightRef.current > 0,
getMarkdown: () => liveEditorMarkdown,
uploadFile: async () => {
inFlightRef.current += 1;
if (inFlightRef.current === 1) onUploadingChange?.(true);
try {
await pendingUpload.promise;
} finally {
inFlightRef.current -= 1;
if (inFlightRef.current === 0) onUploadingChange?.(false);
}
},
}));
return (
<textarea
aria-label="feedback editor"
defaultValue={defaultValue}
onChange={(event) => { liveEditorMarkdown = event.currentTarget.value; }}
onKeyDown={(event) => {
if (event.key === "Enter" && event.metaKey) void onSubmit?.();
}}
/>
);
});
ContentEditor.displayName = "MockContentEditor";
return {
...uploadGate,
useEditorUpload: () => ({
uploadWithToast: vi.fn(),
upload: vi.fn(),
uploading: false,
}),
ContentEditor,
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
FileDropOverlay: () => null,
FileUploadButton: () => <button type="button">Upload</button>,
};
});
import { FeedbackModal } from "./feedback";
describe("FeedbackModal", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
feedbackMocks.mutateAsync.mockReset().mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("uses a crash-report initialMessage when there is no saved draft", () => {
storedDraftMessage = "";
render(<FeedbackModal onClose={vi.fn()} initialMessage="kind: desktop_route_error" />);
expect(screen.getByLabelText("feedback editor")).toHaveValue("kind: desktop_route_error");
});
it("does not overwrite an existing feedback draft when crash report context is provided", () => {
storedDraftMessage = "saved draft";
render(<FeedbackModal onClose={vi.fn()} initialMessage="kind: desktop_route_error" />);
expect(screen.getByLabelText("feedback editor")).toHaveValue(
"saved draft\n\n---\n\nkind: desktop_route_error",
);
});
it("submits the editor's latest markdown before debounced state catches up", async () => {
storedDraftMessage = "";
render(<FeedbackModal onClose={vi.fn()} />);
const editor = screen.getByLabelText("feedback editor");
fireEvent.change(editor, { target: { value: "fresh feedback" } });
fireEvent.keyDown(editor, { key: "Enter", metaKey: true });
await waitFor(() => {
expect(feedbackMocks.mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ message: "fresh feedback" }),
);
});
});
// MUL-4808 — Feedback refused to submit mid-upload inside the handler, but
// the Send button stayed enabled, so the only signal was a toast fired after
// a click that looked like it should have worked.
describe("upload submit gate", () => {
function startPendingUpload() {
pendingUpload.reset();
// The modal renders through a portal, so its file input lives on
// document.body rather than under render()'s container.
const input = document.body.querySelector('input[type="file"]');
if (!input) throw new Error("Expected a file input to render");
fireEvent.change(input, {
target: { files: [new File(["x"], "shot.png", { type: "image/png" })] },
});
}
it("disables Send and shows Uploading… while an upload is in flight", async () => {
// Seeded through the draft so `message` is non-empty on mount — that
// isolates the disabled state to the upload gate rather than the
// empty-content check.
storedDraftMessage = "here's a screenshot";
render(<FeedbackModal onClose={vi.fn()} />);
startPendingUpload();
const send = await screen.findByRole("button", { name: "Uploading…" });
await waitFor(() => expect(send).toBeDisabled());
expect(send).toHaveAttribute("aria-busy", "true");
await act(async () => { pendingUpload.resolve(); });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled(),
);
});
it("blocks the Cmd+Enter path while an upload is in flight", async () => {
storedDraftMessage = "here's a screenshot";
render(<FeedbackModal onClose={vi.fn()} />);
const editor = screen.getByLabelText("feedback editor");
startPendingUpload();
fireEvent.keyDown(editor, { key: "Enter", metaKey: true });
await Promise.resolve();
expect(feedbackMocks.mutateAsync).not.toHaveBeenCalled();
await act(async () => { pendingUpload.resolve(); });
fireEvent.keyDown(editor, { key: "Enter", metaKey: true });
await waitFor(() => expect(feedbackMocks.mutateAsync).toHaveBeenCalled());
});
});
});