mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Render question bodies and option text with the shared Markdown renderer. Opening links or selecting question and description text leaves answers unchanged. Labels remain answer controls, and double-clicking a label advances only once. Add browser regression coverage and stabilize Markdown WASM loading in the test harness. Closes #3164. Co-authored-by: Alessio Re <NoSync@users.noreply.github.com>
157 lines
5.2 KiB
TypeScript
157 lines
5.2 KiB
TypeScript
import React, { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { within } from "@testing-library/dom";
|
|
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { i18n as testI18n } from "@/i18n/i18next";
|
|
import type { PendingPermission } from "@/types/shared";
|
|
import { QuestionFormCard } from "./question-form-card";
|
|
|
|
// Load translations so controls expose their real accessible names.
|
|
void testI18n;
|
|
|
|
// The Markdown engine does not load in this harness; question and option text are covered by
|
|
// e2e/browser/question-prompt-pagination.spec.ts. Plain text keeps these tests on the answers.
|
|
vi.mock("@/components/markdown/renderer", () => ({
|
|
MarkdownRenderer: ({ text }: { text: string }) => <span>{text}</span>,
|
|
}));
|
|
|
|
// App sources compile against the classic JSX runtime, which expects React on the global.
|
|
beforeEach(() => vi.stubGlobal("React", React));
|
|
|
|
/**
|
|
* A real browser with the real web `EditingTextInput`, because the bug under test lives in the
|
|
* gap between that input and React state: the input owns its text and never replays state, so a
|
|
* card that drops the Other text from state alone keeps showing it while submit ignores it.
|
|
*/
|
|
|
|
interface Mounted {
|
|
root: Root;
|
|
container: HTMLDivElement;
|
|
}
|
|
|
|
const mounted: Mounted[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const entry of mounted.splice(0)) {
|
|
act(() => entry.root.unmount());
|
|
entry.container.remove();
|
|
}
|
|
});
|
|
|
|
function buildPermission(question: Record<string, unknown>): PendingPermission {
|
|
return {
|
|
key: "perm-1",
|
|
agentId: "agent-1",
|
|
request: {
|
|
id: "perm-1",
|
|
provider: "claude",
|
|
name: "AskUserQuestion",
|
|
kind: "question",
|
|
input: { questions: [question] },
|
|
},
|
|
};
|
|
}
|
|
|
|
function mountCard(question: Record<string, unknown>) {
|
|
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
act(() =>
|
|
root.render(
|
|
<QuestionFormCard
|
|
permission={buildPermission(question)}
|
|
onRespond={onRespond}
|
|
isResponding={false}
|
|
responseError={null}
|
|
/>,
|
|
),
|
|
);
|
|
mounted.push({ root, container });
|
|
|
|
const view = within(container);
|
|
const optionRole = question.multiSelect ? "checkbox" : "radio";
|
|
const otherInput = () =>
|
|
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
|
|
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
|
|
const type = (text: string) => {
|
|
const input = otherInput();
|
|
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
if (!valueSetter) throw new Error("HTML input value setter is unavailable");
|
|
act(() => {
|
|
valueSetter.call(input, text);
|
|
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
|
|
});
|
|
};
|
|
const submit = () => act(() => view.getByRole("button", { name: "Submit" }).click());
|
|
const submittedAnswers = (): Record<string, string> => {
|
|
const response = onRespond.mock.calls[0]?.[0];
|
|
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
|
|
return (response.updatedInput as { answers: Record<string, string> }).answers;
|
|
};
|
|
return { check, type, otherInput, submit, submittedAnswers };
|
|
}
|
|
|
|
const multiSelectQuestion = {
|
|
question: "Which fruits do you like?",
|
|
header: "Fruits",
|
|
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
|
|
multiSelect: true,
|
|
allowOther: true,
|
|
};
|
|
|
|
const singleSelectQuestion = {
|
|
question: "Which provider?",
|
|
header: "Provider",
|
|
options: [{ label: "Claude Code" }, { label: "Codex" }],
|
|
multiSelect: false,
|
|
allowOther: true,
|
|
};
|
|
|
|
describe("QuestionFormCard other answers", () => {
|
|
it("keeps checked options when the other answer is typed afterwards (multi-select)", () => {
|
|
const card = mountCard(multiSelectQuestion);
|
|
|
|
card.check("Apple");
|
|
card.check("Cherry");
|
|
card.type("durian");
|
|
card.submit();
|
|
|
|
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Cherry, durian" });
|
|
});
|
|
|
|
it("keeps the typed other answer when options are checked afterwards (multi-select)", () => {
|
|
const card = mountCard(multiSelectQuestion);
|
|
|
|
card.type("durian");
|
|
card.check("Apple");
|
|
card.check("Banana");
|
|
|
|
expect(card.otherInput().value).toBe("durian");
|
|
card.submit();
|
|
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Banana, durian" });
|
|
});
|
|
|
|
it("replaces the selected option with the typed other answer (single-select)", () => {
|
|
const card = mountCard(singleSelectQuestion);
|
|
|
|
card.check("Codex");
|
|
card.type("OpenCode");
|
|
card.submit();
|
|
|
|
expect(card.submittedAnswers()).toEqual({ Provider: "OpenCode" });
|
|
});
|
|
|
|
it("clears the typed other answer on screen when an option is picked afterwards (single-select)", () => {
|
|
const card = mountCard(singleSelectQuestion);
|
|
|
|
card.type("OpenCode");
|
|
card.check("Codex");
|
|
|
|
expect(card.otherInput().value).toBe("");
|
|
card.submit();
|
|
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
|
|
});
|
|
});
|