Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/test/composer-interactions-dom.test.ts

typescript415 行查看原文
import { readFileSync } from "node:fs";
import * as Menu from "@radix-ui/react-dropdown-menu";
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ChatComposer, Conversation } from "../../ui/src/components/Conversation.tsx";
import { SelectionReferenceChip } from "../../ui/src/components/ConversationSelections.tsx";
import { ComposerDraftStore } from "../../ui/src/components/InputContext.tsx";
import { ActionHint } from "../../ui/src/components/MessageActions.tsx";
import { UserQuestionCard } from "../src/renderer/UserQuestionCard.tsx";

let root: Root;
let container: HTMLDivElement;
const frames = new Map<number, FrameRequestCallback>();
const observers: {
  callback: ResizeObserverCallback;
  targets: Element[];
  disconnect: ReturnType<typeof vi.fn>;
}[] = [];
beforeEach(() => {
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
  frames.clear();
  observers.length = 0;
  vi.stubGlobal("CSS", { ...CSS, highlights: new Map() });
  let frameId = 0;
  vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
    frames.set(++frameId, callback);
    return frameId;
  });
  vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
  vi.stubGlobal(
    "ResizeObserver",
    class {
      targets: Element[] = [];
      disconnect = vi.fn();
      constructor(callback: ResizeObserverCallback) {
        observers.push({ callback, targets: this.targets, disconnect: this.disconnect });
      }
      observe(element: Element) {
        this.targets.push(element);
      }
      unobserve() {}
    },
  );
  container = document.createElement("div");
  document.body.append(container);
  root = createRoot(container);
});
afterEach(async () => {
  await act(async () => root.unmount());
  document.body.replaceChildren();
  for (const element of document.head.querySelectorAll("style[data-audit-test]")) element.remove();
  vi.useRealTimers();
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});
async function frame() {
  await act(async () => {
    const pending = [...frames.values()];
    frames.clear();
    for (const callback of pending) callback(0);
  });
}
async function settleFocus() {
  // Radix FocusScope restores focus in its deferred unmount cleanup.
  await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
}
function element(selector: string, parent: ParentNode = container) {
  const result = parent.querySelector<HTMLElement>(selector);
  if (!result) throw new Error(`Missing ${selector}`);
  return result;
}

it.each(["hidden", "inert", "aria-hidden", "visibility", "visible"])(
  "composer autofocus respects only visible question dialogs (%s)",
  async (mode) => {
    // happy-dom has no layout; supply a rendered rect so the visibility guard is exercised.
    vi.spyOn(HTMLElement.prototype, "getClientRects").mockImplementation(
      () => [new DOMRect(0, 0, 100, 100)] as unknown as DOMRectList,
    );
    const question = createElement(UserQuestionCard, {
      sessionId: "retained-child",
      visible: false,
      request: {
        id: "question",
        questions: [
          {
            question: "选择方案",
            header: "方案",
            multiSelect: false,
            options: [
              { label: "一", description: "第一个方案" },
              { label: "二", description: "第二个方案" },
            ],
          },
        ],
      },
    });
    await act(async () =>
      root.render(
        createElement(
          "div",
          null,
          createElement(
            "aside",
            {
              hidden: mode === "hidden",
              inert: mode === "inert",
              "aria-hidden": mode === "aria-hidden" ? true : undefined,
              style: { visibility: mode === "visibility" ? "hidden" : "visible" },
            },
            question,
          ),
          createElement(ChatComposer, {
            sessionId: "selected-main",
            busy: false,
            onSubmit: async () => {},
            onStop() {},
            draftStorage: new ComposerDraftStore(),
          }),
        ),
      ),
    );
    expect(container.querySelector('[role="dialog"]')).not.toBeNull();
    await frame();
    const editor = container.querySelector(".mention-editor");
    expect(editor).not.toBeNull();
    if (mode === "visible") expect(document.activeElement).not.toBe(editor);
    else expect(document.activeElement).toBe(editor);
  },
);

async function commentChip() {
  await act(async () =>
    root.render(
      createElement(SelectionReferenceChip, {
        references: [
          {
            path: "/file.ts",
            text: "source",
            sourceKey: 'file-comment:{"start":1,"end":1,"comment":"note"}',
          },
        ],
        onChange() {},
        onFile() {},
      }),
    ),
  );
  return element("[data-file-comment-count]");
}
async function hover(pill: HTMLElement) {
  await act(async () => {
    pill.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
  });
}
it.each([false, true])(
  "comment Escape restores the chip after keyboard activation (hover first=%s)",
  async (hoverFirst) => {
    const pill = await commentChip();
    if (hoverFirst) await hover(pill);
    await act(async () => {
      pill.focus();
      pill.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
    });
    const button = element('[aria-label="评论内容"] button', document);
    await act(async () => {
      button.focus();
      button.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
    });
    await settleFocus();
    expect(document.querySelector('[aria-label="评论内容"]')).toBeNull();
    expect(document.activeElement).toBe(pill);
  },
);
it("a hover-only comment preview does not steal the editor's focus when dismissed", async () => {
  const pill = await commentChip();
  const editor = document.createElement("textarea");
  document.body.append(editor);
  editor.focus();
  await hover(pill);
  await act(async () => {
    editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
  });
  await settleFocus();
  expect(document.querySelector('[aria-label="评论内容"]')).toBeNull();
  expect(document.activeElement).toBe(editor);
});
it("closing an interactive comment by moving focus outside preserves the new focus target", async () => {
  const pill = await commentChip();
  await hover(pill);
  await act(async () => {
    pill.focus();
    pill.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
  });
  const outside = document.createElement("textarea");
  document.body.append(outside);
  await act(async () => outside.focus());
  await settleFocus();
  expect(document.querySelector('[aria-label="评论内容"]')).toBeNull();
  expect(document.activeElement).toBe(outside);
});

it("the project menu stays visible when its tooltip has a closed data-state", async () => {
  const css = readFileSync(new URL("../src/renderer/workbench.css", import.meta.url), "utf8");
  const style = document.createElement("style");
  style.dataset.auditTest = "sidebar";
  style.textContent = css.slice(
    css.indexOf(".sidebar .project-row-action {"),
    css.indexOf(".sidebar .project-row-action:hover {"),
  );
  document.head.append(style);
  const renderMenu = (open: boolean) =>
    createElement(
      "div",
      { className: "sidebar" },
      createElement(
        Menu.Root,
        { open },
        createElement(ActionHint, {
          label: "更多",
          appearance: "control",
          // biome-ignore lint/correctness/noChildrenProp: ActionHint requires children in its props type.
          children: createElement(Menu.Trigger, { className: "project-row-action" }, "More"),
        }),
        createElement(
          Menu.Portal,
          null,
          createElement(Menu.Content, null, createElement(Menu.Item, null, "移除")),
        ),
      ),
    );
  await act(async () => root.render(renderMenu(true)));
  const trigger = element(".project-row-action");
  expect(trigger.getAttribute("aria-expanded")).toBe("true");
  expect(trigger.getAttribute("data-state")).toBe("closed");
  expect(getComputedStyle(trigger).opacity).toBe("1");
  await act(async () => root.render(renderMenu(false)));
  expect(getComputedStyle(trigger).opacity).toBe("0");
});

it.each([true, false])(
  "conversation shares real column bounds only with its sibling composer (%s)",
  async (composer) => {
    await act(async () =>
      root.render(
        createElement(
          "section",
          null,
          createElement(Conversation, {
            view: { sessionId: "layout", title: "Layout", seq: 0, runs: [] },
            onLink() {},
            persistReading: false,
          }),
          composer ? createElement("div", { className: "composer-container" }) : null,
        ),
      ),
    );
    const owner = element("section");
    const viewport = element(".conversation");
    const column = element(".conversation-inner");
    const observer = observers.find((candidate) => candidate.targets.includes(column));
    if (!observer) throw new Error("Column resize observer not registered");
    let width = 819;
    let gutter = 15;
    vi.spyOn(viewport, "offsetWidth", "get").mockImplementation(() => 900 + gutter);
    vi.spyOn(viewport, "clientWidth", "get").mockReturnValue(900);
    vi.spyOn(column, "getBoundingClientRect").mockImplementation(() => new DOMRect(0, 0, width, 100));
    column.style.marginLeft = "48px";
    const notify = () => act(async () => observer.callback([], {} as ResizeObserver));
    await notify();
    const writes = vi.spyOn(owner.style, "setProperty");
    await notify();
    expect(writes).not.toHaveBeenCalled();
    expect(owner.style.getPropertyValue("--conversation-column-width")).toBe(composer ? "819px" : "");
    expect(owner.style.getPropertyValue("--conversation-scrollbar-width")).toBe(composer ? "15px" : "");
    expect(owner.style.getPropertyValue("--conversation-column-left")).toBe("");
    // Hidden retained panes keep the last usable bounds; showing/resizing measures anew.
    width = 0;
    await notify();
    expect(owner.style.getPropertyValue("--conversation-column-width")).toBe(composer ? "819px" : "");
    width = 834;
    gutter = 0;
    column.style.marginLeft = "0px";
    await notify();
    expect(owner.style.getPropertyValue("--conversation-column-width")).toBe(composer ? "834px" : "");
    expect(owner.style.getPropertyValue("--conversation-scrollbar-width")).toBe(composer ? "0px" : "");
    await act(async () => root.render(null));
    expect(observer.disconnect).toHaveBeenCalledOnce();
    expect(owner.style.getPropertyValue("--conversation-column-width")).toBe("");
    expect(owner.style.getPropertyValue("--conversation-scrollbar-width")).toBe("");
  },
);

it("bot suggestions work without a file service and ignore IME Enter until selection is committed", async () => {
  const drafts = new ComposerDraftStore();
  const onSubmit = vi.fn(async () => {});
  await act(async () =>
    root.render(
      createElement(ChatComposer, {
        sessionId: "bot-mention",
        busy: false,
        draftStorage: drafts,
        suggestions: [{ name: "reviewer", description: "检查代码", insert: "@reviewer ", group: "子智能体" }],
        onSubmit,
        onStop() {},
      }),
    ),
  );
  await frame();
  const editor = element(".mention-editor");
  await act(async () => {
    editor.textContent = "@rev";
    const range = document.createRange();
    range.selectNodeContents(editor);
    range.collapse(false);
    window.getSelection()?.removeAllRanges();
    window.getSelection()?.addRange(range);
    editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
  });
  expect(element('[role="option"]').textContent).toBe("reviewer检查代码");
  await act(async () => {
    editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true }));
  });
  expect(drafts.get("bot-mention")?.text).toBe("@rev");
  expect(onSubmit).not.toHaveBeenCalled();
  await act(async () => {
    editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
  });
  expect(drafts.get("bot-mention")?.text).toBe("@reviewer ");
  expect(drafts.get("bot-mention")?.fileReferences).toEqual([]);
  expect(container.querySelector('[role="listbox"]')).toBeNull();
  expect(element(".inline-mention.subagent").dataset.markdown).toBe("@reviewer");
  expect(onSubmit).not.toHaveBeenCalled();
});

it("normal chat keeps warning timers while typing and clears warnings on expiry or send", async () => {
  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
  const drafts = new ComposerDraftStore();
  const draft = { text: "保留草稿", attachments: [], fileReferences: [], pending: 0, error: "附件太多" };
  drafts.set("notice", draft);
  const sent = Promise.withResolvers<void>();
  const onSubmit = vi.fn(() => sent.promise);
  await act(async () =>
    root.render(
      createElement(ChatComposer, {
        sessionId: "notice",
        busy: false,
        draftStorage: drafts,
        onSubmit,
        onStop() {},
      }),
    ),
  );
  expect(container.querySelectorAll(".composer-actions .input-notice button")).toHaveLength(1);
  const editor = element(".mention-editor");
  const type = (text: string) =>
    act(async () => {
      editor.textContent = text;
      editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
    });
  await act(async () => vi.advanceTimersByTime(1000));
  await type("保留草稿a");
  expect(drafts.get("notice")?.text).toBe("保留草稿a");
  expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
  expect(container.querySelector(".input-notice-trigger")).not.toBeNull();
  await act(async () => vi.advanceTimersByTime(2000));
  expect(document.querySelector('[role="tooltip"]')).toBeNull();
  await act(async () => vi.advanceTimersByTime(1000));
  await type("保留草稿ab");
  expect(document.querySelector('[role="tooltip"]')).toBeNull();
  expect(container.querySelector(".input-notice-trigger")).not.toBeNull();
  await act(async () => vi.advanceTimersByTime(3999));
  expect(container.querySelector(".input-notice-trigger")).not.toBeNull();
  await act(async () => vi.advanceTimersByTime(1));
  expect(container.querySelector(".input-notice")).toBeNull();
  expect(drafts.get("notice")?.text).toBe("保留草稿ab");
  await act(async () => drafts.set("notice", { ...draft }));
  expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
  await act(async () => container.querySelector<HTMLButtonElement>("button.send")?.click());
  expect(onSubmit).toHaveBeenCalledOnce();
  expect(container.querySelector(".input-notice")).toBeNull();
  await act(async () => sent.resolve());
});

it("repeated normal-chat send failures reopen the details for a fresh interval", async () => {
  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
  const drafts = new ComposerDraftStore();
  drafts.set("retry-notice", { text: "重试", attachments: [], fileReferences: [], pending: 0 });
  const onSubmit = async () => {
    const draft = drafts.get("retry-notice");
    if (draft) drafts.set("retry-notice", { ...draft, error: "发送失败" });
    throw new Error("发送失败");
  };
  await act(async () =>
    root.render(
      createElement(ChatComposer, {
        sessionId: "retry-notice",
        busy: false,
        draftStorage: drafts,
        onSubmit,
        onStop() {},
      }),
    ),
  );
  const send = () => act(async () => container.querySelector<HTMLButtonElement>("button.send")?.click());
  await send();
  await act(async () => vi.advanceTimersByTime(3000));
  expect(document.querySelector('[role="tooltip"]')).toBeNull();
  await send();
  expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
  await act(async () => vi.advanceTimersByTime(3000));
  expect(document.querySelector('[role="tooltip"]')).toBeNull();
  expect(drafts.get("retry-notice")?.error).toBe("发送失败");
});