Open History to see the last commit
1import { AuthOrchestrationOperateScope, EnvironmentId, type EditorId } from "@t3tools/contracts";
2import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
3import { Cause } from "effect";
4import { AsyncResult } from "effect/reactivity";
5import { describe, expect, it, vi } from "vite-plus/test";
6
7import { act, fakeTimers, render as renderUi } from "../../../test/render";
8import type { RemoteOpenState } from "../../remoteOpen";
9
10const state = vi.hoisted(() => ({
11 allowed: new Set<string>(),
12 remote: { mode: "local-exec" } as RemoteOpenState,
13 clientEditors: ["vscode"] as readonly EditorId[],
14 run: vi.fn(),
15 openUrl: vi.fn(),
16 setPreferred: vi.fn(),
17 markHintSeen: vi.fn(),
18 listeners: new Set<() => void>(),
19}));
20
21vi.mock(import("../../state/session"), async (importOriginal) => {
22 const { useSyncExternalStore } = await import("react");
23 const readEnvironmentScope = (environmentId: string, scope: string) =>
24 scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId);
25 return {
26 ...(await importOriginal()),
27 readEnvironmentScope: readEnvironmentScope as never,
28 useEnvironmentScope: ((environmentId: string, scope: string) =>
29 useSyncExternalStore(
30 (listener) => {
31 state.listeners.add(listener);
32 return () => state.listeners.delete(listener);
33 },
34 () => readEnvironmentScope(environmentId, scope),
35 )) as never,
36 };
37});
38vi.mock(import("../../state/use-atom-command"), async (importOriginal) => ({
39 ...(await importOriginal()),
40 useAtomCommand: (() => state.run) as never,
41}));
42vi.mock(import("../../state/environments"), async (importOriginal) => ({
43 ...(await importOriginal()),
44 useEnvironment: (() => ({ label: "Test host" })) as never,
45}));
46vi.mock(import("../../editorPreferences"), async (importOriginal) => {
47 const actual = await importOriginal();
48 return {
49 ...actual,
50 usePreferredEditor: (available: readonly EditorId[]) => {
51 const [preferred, setPreferred] = actual.usePreferredEditor(available);
52 const recordPreferred: typeof setPreferred = (editor) => {
53 state.setPreferred(editor);
54 setPreferred(editor);
55 };
56 return [preferred, recordPreferred] as const;
57 },
58 };
59});
60vi.mock(import("../../remoteOpen"), async (importOriginal) => ({
61 ...(await importOriginal()),
62 useRemoteOpenState: () => state.remote,
63 useRemoteCapableEditors: () => state.clientEditors,
64 useRemoteOpenHint: () => [false, state.markHintSeen] as const,
65 openRemoteEditorUrl: state.openUrl,
66}));
67
68import { OpenInPicker } from "./OpenInPicker";
69import {
70 registerOpenFavoriteEditorCommand,
71 useOpenFavoriteEditorShortcut,
72} from "./OpenInPickerShortcut";
73
74const primary = EnvironmentId.make("primary");
75const selected = EnvironmentId.make("selected");
76const editors: readonly EditorId[] = ["vscode"];
77
78async function setup() {
79 state.allowed.clear();
80 state.remote = { mode: "local-exec" };
81 state.clientEditors = editors;
82 state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined));
83 state.openUrl.mockReset().mockResolvedValue(true);
84 state.setPreferred.mockReset();
85 state.markHintSeen.mockReset();
86 state.listeners.clear();
87 window.localStorage.clear();
88
89 await using stack = new AsyncDisposableStack();
90 const _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux");
91 stack.use(_platform);
92 const _storage = { [Symbol.dispose]: () => window.localStorage.clear() };
93 stack.use(_storage);
94 // The app registers this command at startup (appInit).
95 const _openFavorite = { [Symbol.dispose]: registerOpenFavoriteEditorCommand() };
96 stack.use(_openFavorite);
97 const screen = stack.use(await renderUi(null));
98 async function renderPicker(
99 availableEditors: readonly EditorId[] = editors,
100 panel = false,
101 { openInCwd = "/work/project", revealFileManager = false } = {},
102 ) {
103 await act(async () => {
104 for (const listener of state.listeners) listener();
105 const node = (
106 <OpenInPicker
107 environmentId={selected}
108 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
109 availableEditors={availableEditors}
110 openInCwd={openInCwd}
111 revealFileManager={revealFileManager}
112 displayMode={panel ? "panel" : "toolbar"}
113 compact={!panel}
114 />
115 );
116 await screen.rerender(node);
117 });
118 }
119
120 function byLabel(label: string) {
121 const element = document.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`);
122 expect(element, label).not.toBeNull();
123 return element!;
124 }
125
126 const primaryButton = () => byLabel("Open file in preferred editor");
127 const menuItems = () => [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')];
128
129 async function click(element: HTMLElement) {
130 await act(async () => element.click());
131 }
132
133 async function pressOpenFavorite(mac = false) {
134 const event = new KeyboardEvent("keydown", {
135 key: "o",
136 ctrlKey: !mac,
137 metaKey: mac,
138 bubbles: true,
139 cancelable: true,
140 });
141 await act(async () => document.body.dispatchEvent(event));
142 return event;
143 }
144
145 function FavoriteEditorFallback() {
146 useOpenFavoriteEditorShortcut({
147 enabled: true,
148 environmentId: selected,
149 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
150 availableEditors: editors,
151 openInCwd: "/work/project",
152 });
153 return null;
154 }
155
156 const environment = stack.move();
157 return {
158 screen,
159 renderPicker,
160 byLabel,
161 primaryButton,
162 menuItems,
163 click,
164 pressOpenFavorite,
165 FavoriteEditorFallback,
166 [Symbol.asyncDispose]: () => environment.disposeAsync(),
167 };
168}
169
170describe("host editor access", () => {
171 it("uses the selected grant for both the button and a retained shortcut", async () => {
172 await using fixture = await setup();
173 const { renderPicker, byLabel, primaryButton, click, pressOpenFavorite } = fixture;
174 state.allowed.add(selected);
175 await renderPicker();
176 // Revoke without rerendering to exercise execution-time grant checks.
177 state.allowed.delete(selected);
178 state.allowed.add(primary);
179 await click(primaryButton());
180 // The shortcut is consumed and opens nothing once the grant is gone.
181 expect((await pressOpenFavorite()).defaultPrevented).toBe(true);
182 expect(state.run).not.toHaveBeenCalled();
183 expect(state.setPreferred).not.toHaveBeenCalled();
184
185 await renderPicker();
186 expect(primaryButton().disabled).toBe(true);
187 expect(byLabel("Choose editor").disabled).toBe(true);
188 expect((await pressOpenFavorite()).defaultPrevented).toBe(false);
189 });
190});
191
192describe("remote editor links", () => {
193 it("opens the link from the fallback when the picker is not mounted", async () => {
194 await using fixture = await setup();
195 const { screen, pressOpenFavorite, FavoriteEditorFallback } = fixture;
196 state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } };
197 // A successful open would announce on a real timer that fires in the next test.
198 state.openUrl.mockResolvedValueOnce(false);
199 await act(async () => screen.rerender(<FavoriteEditorFallback />));
200
201 expect((await pressOpenFavorite()).defaultPrevented).toBe(true);
202 expect(state.openUrl).toHaveBeenCalledWith(expect.stringContaining("ssh-remote+sol"));
203 expect(state.run).not.toHaveBeenCalled();
204 });
205
206 it("explains why a phone or tablet browser has no editor to open", async () => {
207 await using fixture = await setup();
208 const { renderPicker, byLabel, primaryButton, menuItems, click, pressOpenFavorite } = fixture;
209 state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } };
210 state.clientEditors = [];
211 await renderPicker();
212
213 expect(primaryButton().disabled).toBe(true);
214 expect((await pressOpenFavorite()).defaultPrevented).toBe(false);
215 await click(byLabel("Choose editor"));
216 await vi.waitFor(() =>
217 expect(menuItems().map((item) => item.textContent)).toEqual([
218 "Open from a computer to edit on Test host",
219 ]),
220 );
221 expect(menuItems()[0]!.getAttribute("aria-disabled")).toBe("true");
222 expect(state.openUrl).not.toHaveBeenCalled();
223 });
224});
225
226describe("open in editor announcement", () => {
227 it("speaks only after the shortcut opened the editor, from the picker or the fallback", async () => {
228 await using fixture = await setup();
229 const { screen, renderPicker, pressOpenFavorite, FavoriteEditorFallback } = fixture;
230 state.allowed.add(selected);
231 await renderPicker();
232 using _timers = fakeTimers();
233 const regions = () => [...document.querySelectorAll('body > [aria-live="polite"]')];
234 const spoken = async () => {
235 await act(async () => vi.runAllTimers());
236 return regions()
237 .map((region) => region.textContent)
238 .join("");
239 };
240 const refuse = () =>
241 state.run.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("No editor"))));
242
243 refuse();
244 await pressOpenFavorite();
245 expect(await spoken()).toBe("");
246 await pressOpenFavorite();
247 expect(await spoken()).toBe("Opened in VS Code");
248
249 for (const region of regions()) region.textContent = "";
250 await act(async () => screen.rerender(<FavoriteEditorFallback />));
251 refuse();
252 await pressOpenFavorite();
253 expect(await spoken()).toBe("");
254 await pressOpenFavorite();
255 expect(await spoken()).toBe("Opened in VS Code");
256 });
257});