|
| 1 | +import { describe, it, expect, vi, afterEach } from "vitest"; |
| 2 | +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; |
| 3 | +import userEvent from "@testing-library/user-event"; |
| 4 | +import KeyFileInput from "../KeyFileInput"; |
| 5 | + |
| 6 | +afterEach(cleanup); |
| 7 | + |
| 8 | +const noop = () => {}; |
| 9 | +const alwaysValid = () => true; |
| 10 | + |
| 11 | +interface Props { |
| 12 | + value?: string; |
| 13 | + onChange?: (v: string) => void; |
| 14 | + validate?: (t: string) => boolean; |
| 15 | + disabled?: boolean; |
| 16 | + error?: string | null; |
| 17 | + label?: string; |
| 18 | + id?: string; |
| 19 | + hint?: string; |
| 20 | + disabledHint?: string; |
| 21 | + loadedLabel?: string; |
| 22 | + emptyLabel?: string; |
| 23 | + onFileName?: (name: string) => void; |
| 24 | +} |
| 25 | + |
| 26 | +function renderComponent(props: Props = {}) { |
| 27 | + const { |
| 28 | + value = "", |
| 29 | + onChange = noop, |
| 30 | + validate = alwaysValid, |
| 31 | + label = "Public Key", |
| 32 | + ...rest |
| 33 | + } = props; |
| 34 | + return render( |
| 35 | + <KeyFileInput |
| 36 | + label={label} |
| 37 | + value={value} |
| 38 | + onChange={onChange} |
| 39 | + validate={validate} |
| 40 | + {...rest} |
| 41 | + />, |
| 42 | + ); |
| 43 | +} |
| 44 | + |
| 45 | +function mockFileReader(content: string) { |
| 46 | + const original = globalThis.FileReader; |
| 47 | + |
| 48 | + class MockFileReader extends EventTarget { |
| 49 | + result: string | null = null; |
| 50 | + onload: (() => void) | null = null; |
| 51 | + |
| 52 | + readAsText() { |
| 53 | + Promise.resolve().then(() => { |
| 54 | + this.result = content; |
| 55 | + if (this.onload) this.onload(); |
| 56 | + }); |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + // @ts-expect-error - partial mock |
| 61 | + globalThis.FileReader = MockFileReader; |
| 62 | + return () => { |
| 63 | + globalThis.FileReader = original; |
| 64 | + }; |
| 65 | +} |
| 66 | + |
| 67 | +describe("KeyFileInput", () => { |
| 68 | + describe("label", () => { |
| 69 | + it("renders the label text", () => { |
| 70 | + renderComponent({ label: "Private Key" }); |
| 71 | + expect(screen.getByText("Private Key")).toBeInTheDocument(); |
| 72 | + }); |
| 73 | + }); |
| 74 | + |
| 75 | + describe("mode toggle", () => { |
| 76 | + it("shows File and Text buttons when not disabled", () => { |
| 77 | + renderComponent(); |
| 78 | + expect(screen.getByRole("button", { name: "File" })).toBeInTheDocument(); |
| 79 | + expect(screen.getByRole("button", { name: "Text" })).toBeInTheDocument(); |
| 80 | + }); |
| 81 | + |
| 82 | + it("hides mode toggle buttons when disabled", () => { |
| 83 | + renderComponent({ disabled: true }); |
| 84 | + expect(screen.queryByRole("button", { name: "File" })).not.toBeInTheDocument(); |
| 85 | + expect(screen.queryByRole("button", { name: "Text" })).not.toBeInTheDocument(); |
| 86 | + }); |
| 87 | + |
| 88 | + it("shows the drop zone by default (file mode)", () => { |
| 89 | + renderComponent(); |
| 90 | + // Drop zone is identifiable by the empty-state label |
| 91 | + expect( |
| 92 | + screen.getByText("Drop key file, paste, or browse"), |
| 93 | + ).toBeInTheDocument(); |
| 94 | + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); |
| 95 | + }); |
| 96 | + |
| 97 | + it("switches to textarea when Text button is clicked", async () => { |
| 98 | + renderComponent(); |
| 99 | + await userEvent.click(screen.getByRole("button", { name: "Text" })); |
| 100 | + expect(screen.getByRole("textbox")).toBeInTheDocument(); |
| 101 | + }); |
| 102 | + |
| 103 | + it("switches back to drop zone when File button is clicked after Text", async () => { |
| 104 | + renderComponent(); |
| 105 | + await userEvent.click(screen.getByRole("button", { name: "Text" })); |
| 106 | + await userEvent.click(screen.getByRole("button", { name: "File" })); |
| 107 | + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); |
| 108 | + expect( |
| 109 | + screen.getByText("Drop key file, paste, or browse"), |
| 110 | + ).toBeInTheDocument(); |
| 111 | + }); |
| 112 | + }); |
| 113 | + |
| 114 | + describe("drop zone — empty state", () => { |
| 115 | + it("renders the empty label", () => { |
| 116 | + renderComponent(); |
| 117 | + expect( |
| 118 | + screen.getByText("Drop key file, paste, or browse"), |
| 119 | + ).toBeInTheDocument(); |
| 120 | + }); |
| 121 | + |
| 122 | + it("renders a custom emptyLabel", () => { |
| 123 | + renderComponent({ emptyLabel: "Upload your public key" }); |
| 124 | + expect(screen.getByText("Upload your public key")).toBeInTheDocument(); |
| 125 | + }); |
| 126 | + }); |
| 127 | + |
| 128 | + describe("drop zone — loaded state", () => { |
| 129 | + it("renders the loaded label when a value is present", () => { |
| 130 | + renderComponent({ value: "ssh-rsa AAAA" }); |
| 131 | + expect(screen.getByText("Key loaded")).toBeInTheDocument(); |
| 132 | + }); |
| 133 | + |
| 134 | + it("renders a custom loadedLabel", () => { |
| 135 | + renderComponent({ value: "ssh-rsa AAAA", loadedLabel: "Key ready" }); |
| 136 | + expect(screen.getByText("Key ready")).toBeInTheDocument(); |
| 137 | + }); |
| 138 | + |
| 139 | + it("renders a Clear button when key is loaded", () => { |
| 140 | + renderComponent({ value: "ssh-rsa AAAA" }); |
| 141 | + expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); |
| 142 | + }); |
| 143 | + |
| 144 | + it("calls onChange('') when Clear is clicked", async () => { |
| 145 | + const onChange = vi.fn(); |
| 146 | + renderComponent({ value: "ssh-rsa AAAA", onChange }); |
| 147 | + await userEvent.click(screen.getByRole("button", { name: "Clear" })); |
| 148 | + expect(onChange).toHaveBeenCalledWith(""); |
| 149 | + }); |
| 150 | + }); |
| 151 | + |
| 152 | + describe("drop zone — dragging state", () => { |
| 153 | + it("sets dragging visual when dragOver fires on the drop zone", () => { |
| 154 | + const { container } = renderComponent(); |
| 155 | + // Find the drop zone div by a stable child element |
| 156 | + const dropZone = container.querySelector( |
| 157 | + "[ondragover], .border-dashed", |
| 158 | + ) as HTMLElement | null; |
| 159 | + if (!dropZone) throw new Error("drop zone not found"); |
| 160 | + |
| 161 | + fireEvent.dragOver(dropZone, { preventDefault: () => {} }); |
| 162 | + // After dragOver the border class shifts to primary color |
| 163 | + expect(dropZone.className).toMatch(/border-primary/); |
| 164 | + }); |
| 165 | + |
| 166 | + it("removes dragging visual on dragLeave", () => { |
| 167 | + const { container } = renderComponent(); |
| 168 | + const dropZone = container.querySelector(".border-dashed") as HTMLElement; |
| 169 | + fireEvent.dragOver(dropZone); |
| 170 | + fireEvent.dragLeave(dropZone); |
| 171 | + expect(dropZone.className).not.toMatch(/bg-primary\/5/); |
| 172 | + }); |
| 173 | + }); |
| 174 | + |
| 175 | + describe("drop zone — error state", () => { |
| 176 | + it("renders the error message", () => { |
| 177 | + renderComponent({ error: "Invalid key format" }); |
| 178 | + expect(screen.getByText("Invalid key format")).toBeInTheDocument(); |
| 179 | + }); |
| 180 | + |
| 181 | + it("does not render an error message when error is null", () => { |
| 182 | + renderComponent({ error: null }); |
| 183 | + expect(screen.queryByText(/invalid/i)).not.toBeInTheDocument(); |
| 184 | + }); |
| 185 | + }); |
| 186 | + |
| 187 | + describe("drag and drop", () => { |
| 188 | + it("calls onChange with file content after a valid drop", async () => { |
| 189 | + const restore = mockFileReader("ssh-rsa AAAAB3NzaC1"); |
| 190 | + const onChange = vi.fn(); |
| 191 | + const { container } = renderComponent({ onChange }); |
| 192 | + const dropZone = container.querySelector(".border-dashed") as HTMLElement; |
| 193 | + |
| 194 | + const file = new File(["ssh-rsa AAAAB3NzaC1"], "id_rsa.pub", { |
| 195 | + type: "text/plain", |
| 196 | + }); |
| 197 | + const dataTransfer = { files: [file] }; |
| 198 | + fireEvent.drop(dropZone, { dataTransfer }); |
| 199 | + |
| 200 | + await waitFor(() => expect(onChange).toHaveBeenCalledWith("ssh-rsa AAAAB3NzaC1")); |
| 201 | + restore(); |
| 202 | + }); |
| 203 | + |
| 204 | + it("does not call onChange for files over 512 KB", async () => { |
| 205 | + const onChange = vi.fn(); |
| 206 | + const { container } = renderComponent({ onChange }); |
| 207 | + const dropZone = container.querySelector(".border-dashed") as HTMLElement; |
| 208 | + |
| 209 | + const bigContent = new Uint8Array(513 * 1024); |
| 210 | + const bigFile = new File([bigContent], "big.pem", { type: "text/plain" }); |
| 211 | + fireEvent.drop(dropZone, { dataTransfer: { files: [bigFile] } }); |
| 212 | + |
| 213 | + // Give any async processing time to run |
| 214 | + await new Promise((r) => setTimeout(r, 20)); |
| 215 | + expect(onChange).not.toHaveBeenCalled(); |
| 216 | + }); |
| 217 | + }); |
| 218 | + |
| 219 | + describe("file input via browse", () => { |
| 220 | + it("clicking the drop zone triggers the hidden file input", async () => { |
| 221 | + const { container } = renderComponent(); |
| 222 | + const fileInput = container.querySelector( |
| 223 | + 'input[type="file"]', |
| 224 | + ) as HTMLInputElement; |
| 225 | + const clickSpy = vi.spyOn(fileInput, "click"); |
| 226 | + const dropZone = container.querySelector(".border-dashed") as HTMLElement; |
| 227 | + await userEvent.click(dropZone); |
| 228 | + expect(clickSpy).toHaveBeenCalled(); |
| 229 | + }); |
| 230 | + |
| 231 | + it("calls onChange after selecting a file through the file input", async () => { |
| 232 | + const restore = mockFileReader("key text"); |
| 233 | + const onChange = vi.fn(); |
| 234 | + const { container } = renderComponent({ onChange }); |
| 235 | + const fileInput = container.querySelector( |
| 236 | + 'input[type="file"]', |
| 237 | + ) as HTMLInputElement; |
| 238 | + |
| 239 | + const file = new File(["key text"], "k.pem", { type: "text/plain" }); |
| 240 | + fireEvent.change(fileInput, { target: { files: [file] } }); |
| 241 | + |
| 242 | + await waitFor(() => expect(onChange).toHaveBeenCalledWith("key text")); |
| 243 | + restore(); |
| 244 | + }); |
| 245 | + }); |
| 246 | + |
| 247 | + describe("disabled state", () => { |
| 248 | + it("renders a textarea (not the drop zone) when disabled", () => { |
| 249 | + renderComponent({ disabled: true }); |
| 250 | + expect(screen.getByRole("textbox")).toBeInTheDocument(); |
| 251 | + expect(screen.queryByText("Drop key file, paste, or browse")).not.toBeInTheDocument(); |
| 252 | + }); |
| 253 | + |
| 254 | + it("textarea is disabled", () => { |
| 255 | + renderComponent({ disabled: true }); |
| 256 | + expect(screen.getByRole("textbox")).toBeDisabled(); |
| 257 | + }); |
| 258 | + |
| 259 | + it("renders disabledHint when disabled", () => { |
| 260 | + renderComponent({ disabled: true, disabledHint: "Cannot edit now" }); |
| 261 | + expect(screen.getByText("Cannot edit now")).toBeInTheDocument(); |
| 262 | + }); |
| 263 | + |
| 264 | + it("does not render hint when disabled", () => { |
| 265 | + renderComponent({ disabled: true, hint: "Upload a key" }); |
| 266 | + expect(screen.queryByText("Upload a key")).not.toBeInTheDocument(); |
| 267 | + }); |
| 268 | + }); |
| 269 | + |
| 270 | + describe("hint", () => { |
| 271 | + it("renders the hint when not disabled", () => { |
| 272 | + renderComponent({ hint: "Paste or drag your key file" }); |
| 273 | + expect(screen.getByText("Paste or drag your key file")).toBeInTheDocument(); |
| 274 | + }); |
| 275 | + |
| 276 | + it("does not render disabledHint when not disabled", () => { |
| 277 | + renderComponent({ disabledHint: "Read only" }); |
| 278 | + expect(screen.queryByText("Read only")).not.toBeInTheDocument(); |
| 279 | + }); |
| 280 | + }); |
| 281 | + |
| 282 | + describe("accessibility", () => { |
| 283 | + it("associates the label with the textarea via id in text mode", async () => { |
| 284 | + renderComponent({ id: "pub-key" }); |
| 285 | + await userEvent.click(screen.getByRole("button", { name: "Text" })); |
| 286 | + expect(screen.getByRole("textbox")).toHaveAttribute("id", "pub-key"); |
| 287 | + expect(screen.getByLabelText("Public Key")).toBeInTheDocument(); |
| 288 | + }); |
| 289 | + |
| 290 | + it("marks the textarea aria-invalid when error is provided (text mode)", async () => { |
| 291 | + renderComponent({ error: "Bad key", id: "pub-key" }); |
| 292 | + await userEvent.click(screen.getByRole("button", { name: "Text" })); |
| 293 | + const textarea = screen.getByRole("textbox"); |
| 294 | + expect(textarea).toHaveAttribute("aria-invalid", "true"); |
| 295 | + }); |
| 296 | + |
| 297 | + it("links the error paragraph via aria-describedby when id is provided (text mode)", async () => { |
| 298 | + renderComponent({ error: "Bad key", id: "pub-key" }); |
| 299 | + await userEvent.click(screen.getByRole("button", { name: "Text" })); |
| 300 | + const textarea = screen.getByRole("textbox"); |
| 301 | + expect(textarea).toHaveAttribute("aria-describedby", "pub-key-error"); |
| 302 | + }); |
| 303 | + }); |
| 304 | + |
| 305 | + describe("text mode textarea", () => { |
| 306 | + it("reflects the current value", async () => { |
| 307 | + renderComponent({ value: "ssh-rsa ABC" }); |
| 308 | + await userEvent.click(screen.getByRole("button", { name: "Text" })); |
| 309 | + expect(screen.getByRole("textbox")).toHaveValue("ssh-rsa ABC"); |
| 310 | + }); |
| 311 | + |
| 312 | + it("calls onChange when the user types", async () => { |
| 313 | + const onChange = vi.fn(); |
| 314 | + renderComponent({ onChange }); |
| 315 | + await userEvent.click(screen.getByRole("button", { name: "Text" })); |
| 316 | + await userEvent.type(screen.getByRole("textbox"), "a"); |
| 317 | + expect(onChange).toHaveBeenCalled(); |
| 318 | + }); |
| 319 | + }); |
| 320 | +}); |
0 commit comments