Skip to content
Source
Sign inSign up

CommandPalette.composition.browser.test.tsx

245 lines10.1 KB

1import { describe, expect, it, vi } from "vite-plus/test";
2import { page, userEvent } from "vite-plus/test/browser";
3import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
4import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
5import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts";
6import { act, render } from "../../test/render";
7import { CommandPalette } from "./CommandPalette";
8import { openCommandPalette } from "../commandPaletteBus";
9import { primaryServerKeybindingsAtom } from "../state/server";
10​
11const scopes = vi.hoisted(() => ({
12 denied: undefined as string | undefined,
13 sourceControl: undefined as unknown,
14}));
15const setup = (deniedScope?: string, sourceControl?: unknown) => {
16 scopes.denied = deniedScope;
17 scopes.sourceControl = sourceControl;
18};
19const githubAvailable = {
20 versionControlSystems: [],
21 sourceControlProviders: [
22 {
23 kind: "github",
24 label: "GitHub",
25 status: "available",
26 version: { _tag: "None" },
27 installHint: "Install GitHub CLI",
28 detail: { _tag: "None" },
29 auth: {
30 status: "authenticated",
31 account: { _tag: "Some", value: "demo" },
32 host: { _tag: "None" },
33 detail: { _tag: "None" },
34 },
35 },
36 ],
37};
38​
39vi.mock("@effect/atom-react", async (original) => ({
40 ...(await original<typeof import("@effect/atom-react")>()),
41 useAtomValue: (atom: unknown) =>
42 atom === primaryServerKeybindingsAtom ? DEFAULT_RESOLVED_KEYBINDINGS : [],
43}));
44vi.mock("../hooks/useTheme", () => ({
45 useRenderedAppearance: () => "light",
46 useTheme: () => ({ theme: "t3-chat", resolvedTheme: "light", appearanceMode: "light" }),
47}));
48vi.mock("../hooks/useCustomThemes", () => ({ useCustomThemes: () => [] }));
49vi.mock("../hooks/useEnvironmentTheme", () => ({ useEnvironmentThemeDefinitions: () => [] }));
50vi.mock("../hooks/useSettings", async (original) => ({
51 ...(await original<typeof import("../hooks/useSettings")>()),
52 useClientSettings: () => DEFAULT_CLIENT_SETTINGS,
53 useUpdateEnvironmentSettings: () => () => undefined,
54}));
55vi.mock("../hooks/useScratchProject", () => ({
56 useScratchProject: () => ({ scratchEnvironmentId: () => null }),
57}));
58vi.mock("../hooks/useNewProject", () => ({ useNewProject: () => async () => undefined }));
59vi.mock("../hooks/useHandleNewThread", () => ({
60 useHandleNewThread: () => ({
61 activeDraftThread: null,
62 activeThread: null,
63 defaultProjectRef: null,
64 handleNewThread: async () => undefined,
65 }),
66 useNewThreadHandler: () => async () => undefined,
67}));
68vi.mock("../hooks/useOpenPanelPullRequestUrl", () => ({ useOpenPanelPullRequestUrl: () => null }));
69vi.mock("../connection/useDesktopLocalBootstraps", () => ({ useDesktopLocalBootstraps: () => [] }));
70vi.mock("../state/use-atom-command", () => ({
71 // The add-project destination step starts a clone and follows its project id.
72 useAtomCommand: () => async () => ({ _tag: "Success", value: { projectId: "project-cloned" } }),
73}));
74vi.mock("../state/use-atom-query-runner", () => ({
75 useAtomQueryRunner: () => async () => ({ _tag: "Success", value: undefined }),
76}));
77vi.mock("../state/environments", async (original) => ({
78 ...(await original<typeof import("../state/environments")>()),
79 useEnvironments: () => ({
80 environments: [
81 {
82 environmentId: "local",
83 label: "Local",
84 connection: { phase: "connected" },
85 entry: { target: { _tag: "PrimaryConnectionTarget" } },
86 serverConfig: {
87 environment: { platform: { os: "linux" }, capabilities: { projectCloneTracking: true } },
88 settings: { addProjectBaseDirectory: "/workspace/" },
89 newProjectsRoot: "/workspace/",
90 },
91 },
92 ],
93 }),
94 usePrimaryEnvironmentId: () => "local",
95 useEnvironmentProviders: () => new Map(),
96}));
97vi.mock("../state/entities", async (original) => ({
98 ...(await original<typeof import("../state/entities")>()),
99 useProjects: () => [],
100 useThreadShells: () => [],
101 useVisibleThreadShells: () => [],
102 useHiddenThreadKeys: () => new Set(),
103 useAnyEnvironmentHiding: () => false,
104 useEnvironmentThreadRefs: () => [],
105 useServerConfigs: () => new Map(),
106 waitForProject: async () => undefined,
107}));
108vi.mock("../state/queries", async (original) => ({
109 ...(await original<typeof import("../state/queries")>()),
110 useThreadSearch: (_environmentIds: unknown, query: string) => ({
111 matches: [],
112 query,
113 isPending: false,
114 }),
115}));
116vi.mock("../state/session", async (original) => ({
117 ...(await original<typeof import("../state/session")>()),
118 useEnvironmentScope: (_environmentId: unknown, scope: string) => scope !== scopes.denied,
119 readEnvironmentScope: () => true,
120}));
121vi.mock("../state/filesystem", async (original) => ({
122 ...(await original<typeof import("../state/filesystem")>()),
123 useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }),
124}));
125vi.mock("../state/query", async (original) => ({
126 ...(await original<typeof import("../state/query")>()),
127 useEnvironmentQuery: () => ({ data: scopes.sourceControl, isLoading: false }),
128}));
129vi.mock("./settings/useAvailableSettingsSearchItems", () => ({
130 useAvailableSettingsSearchItems: () => [],
131}));
132​
133// The real palette and Base UI input must agree that confirming composition is typing.
134describe.each(["repository", "folder", "destination"] as const)("add-project %s", (step) => {
135 it.each([
136 { isComposing: true, keyCode: 13 },
137 { isComposing: false, keyCode: 229 },
138 ])("keeps Enter in the input during composition: %j", async (composition) => {
139 setup();
140 await using _screen = await render(<CommandPalette>{null}</CommandPalette>);
141 await act(() => openCommandPalette({ open: "add-project" }));
142 await page.getByText(step === "folder" ? "Local folder" : "Git URL", { exact: true }).click();
143 const input = () => page.getByRole("combobox");
144 if (step === "destination") {
145 await input().fill("https://example.com/team/project.git");
146 await userEvent.keyboard("{Enter}");
147 }
148 const value = step === "repository" ? "https://example.com/team/計画.git" : "/workspace/計画";
149 await input().fill(value);
150 const event = new KeyboardEvent("keydown", {
151 key: "Enter",
152 bubbles: true,
153 cancelable: true,
154 ...composition,
155 });
156 await act(() => input().element().dispatchEvent(event));
157 expect(event.defaultPrevented).toBe(false);
158 await expect.element(input()).toHaveValue(value);
159 if (step === "repository") {
160 await userEvent.keyboard("{Enter}");
161 await expect.element(input()).toHaveValue("/workspace/計画");
162 } else {
163 await userEvent.keyboard("{Enter}");
164 await expect.element(page.getByRole("combobox")).not.toBeInTheDocument();
165 }
166 });
167});
168​
169it.each([
170 { step: "folder", source: "Local folder", keys: "", description: /^(?!.*(Ctrl|⌘)).*Add.*Enter/ },
171 {
172 step: "highlighted folder",
173 source: "Local folder",
174 keys: "{ArrowDown}",
175 description: /Add.*(Ctrl|⌘) Enter/,
176 },
177 { step: "repository", source: "Git URL", keys: "", description: /Continue.*Enter/ },
178 { step: "new project", source: "New project", keys: "", description: /Create.*Enter/ },
179 {
180 step: "permission-blocked folder",
181 source: "Local folder",
182 keys: "",
183 deniedScope: AuthOrchestrationOperateScope,
184 description: "This connection cannot add projects.",
185 },
186 {
187 step: "permission-blocked destination",
188 source: "Git URL",
189 keys: "https://example.com/team/project.git{Enter}",
190 deniedScope: AuthSourceControlWriteScope,
191 description: "This connection needs permission to write source control and add projects.",
192 },
193])(
194 "tells screen readers how to submit the $step step",
195 async ({ source, keys, deniedScope, description }) => {
196 setup(deniedScope);
197 await using _screen = await render(<CommandPalette>{null}</CommandPalette>);
198 await act(() => openCommandPalette({ open: "add-project" }));
199 await page.getByText(source, { exact: true }).click();
200 if (keys) await userEvent.keyboard(keys);
201 await expect.element(page.getByRole("combobox")).toHaveAccessibleDescription(description);
202 },
203);
204​
205it("keeps the caret in place when an edit briefly makes the folder path not a path", async () => {
206 setup();
207 await using _screen = await render(<CommandPalette>{null}</CommandPalette>);
208 await act(() => openCommandPalette({ open: "add-project" }));
209 await page.getByText("Local folder", { exact: true }).click();
210 const input = () => page.getByRole("combobox");
211 await input().fill("/workspace/project");
212 await userEvent.keyboard("{Home}{ArrowRight}{Backspace}");
213 await expect.element(input()).toHaveValue("workspace/project");
214 await userEvent.keyboard("/");
215 await expect.element(input()).toHaveValue("/workspace/project");
216 await userEvent.keyboard("w");
217 await expect.element(input()).toHaveValue("/wworkspace/project");
218});
219​
220it.each([
221 { flow: "Git URL", value: "https://github.com/pingdotgg/t3code.git", submit: "Continue (Enter)" },
222 { flow: "New project", value: "demo-tab-order", submit: /^Create \(/ },
223])("moves focus from the $flow input to its enabled submit button on Tab", async (step) => {
224 await using _screen = await render(<CommandPalette>{null}</CommandPalette>);
225 await act(() => openCommandPalette({ open: "add-project" }));
226 await page.getByText(step.flow, { exact: true }).click();
227 const input = page.getByRole("combobox");
228 await input.fill(step.value);
229 const submit = page.getByRole("button", { name: step.submit });
230 await expect.element(submit).toBeEnabled();
231 await userEvent.keyboard("{Tab}");
232 await expect.element(submit).toHaveFocus();
233});
234​
235it("tells screen readers whether a new project will be published", async () => {
236 setup(undefined, githubAvailable);
237 await using _screen = await render(<CommandPalette>{null}</CommandPalette>);
238 await act(() => openCommandPalette({ open: "add-project" }));
239 await page.getByText("New project", { exact: true }).click();
240 await page.getByRole("combobox").fill("demo-project");
241 const option = page.getByRole("option", { name: /Create private repository/ });
242 await expect.element(option).toHaveAttribute("aria-checked", "false");
243 await option.click();
244 await expect.element(option).toHaveAttribute("aria-checked", "true");
245});