// @vitest-environment jsdom import { useState } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanySecret, EnvBinding } from "@paperclipai/shared"; import { EnvironmentVariablesEditor } from "./index"; import { SecretPicker } from "./SecretPicker"; // Radix (DropdownMenu/Popover) relies on Pointer Capture APIs that jsdom omits. const OriginalPointerEvent = globalThis.PointerEvent; beforeAll(() => { if (!globalThis.PointerEvent) { // eslint-disable-next-line @typescript-eslint/no-explicit-any globalThis.PointerEvent = MouseEvent as any; } Element.prototype.setPointerCapture ??= () => {}; Element.prototype.releasePointerCapture ??= () => {}; Element.prototype.hasPointerCapture ??= () => false; if (!globalThis.ResizeObserver) { // eslint-disable-next-line @typescript-eslint/no-explicit-any globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; } }); afterAll(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any globalThis.PointerEvent = OriginalPointerEvent as any; }); function makeSecret(id: string, overrides: Partial = {}): CompanySecret { return { id, companyId: "co", scope: "company", ownerUserId: null, userSecretDefinitionId: null, key: id, name: id.toUpperCase(), provider: "local_encrypted", status: "active", managedMode: "paperclip_managed", externalRef: null, providerConfigId: null, providerMetadata: null, latestVersion: 3, description: null, lastResolvedAt: null, lastRotatedAt: null, deletedAt: null, createdByAgentId: null, createdByUserId: null, createdAt: new Date(0), updatedAt: new Date(0), ...overrides, }; } async function flush() { for (let i = 0; i < 5; i += 1) { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); } flushSync(() => {}); } // Radix DropdownMenu opens/selects on the pointerdown→pointerup sequence, not a // bare click; drive both so jsdom exercises the real dismissal-layer path. function pointerClick(el: Element) { el.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 })); el.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, cancelable: true, button: 0 })); (el as HTMLElement).click(); } // Deterministic settle for the nested DropdownMenu/combobox → Popover deferred- // open regression tests. Those flows open the anchored popover from inside a // closing menu via `window.setTimeout(…, 0)` (PAP-12476/12477/12478). Under real // timers, jsdom orders the menu's focus-return (a `focusin` that Radix reads as // `focusOutside` → dismiss) against that deferred open non-deterministically, so // the popover *sometimes* opens-then-instantly-closes — flaky. `vi.useFakeTimers` // makes that ordering deterministic (matching the real-browser path where the // focus-return settles before the macrotask), while still running the deferred // `setTimeout` — so a *synchronous* (unfixed) open would still be dismissed here. function settleFakeTimers() { flushSync(() => {}); vi.runAllTimers(); flushSync(() => {}); } describe("EnvironmentVariablesEditor", () => { let container: HTMLDivElement; let root: Root | null = null; function render(node: React.ReactNode) { root = createRoot(container); flushSync(() => root!.render(node)); } function rerender(node: React.ReactNode) { flushSync(() => root!.render(node)); } beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); }); afterEach(async () => { // Restore real timers first (the PAP-12478 test swaps in fake timers and may // exit without restoring if it throws) so unmount cleanup runs on real timers // and gets drained below. vi.useRealTimers(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } flushSync(() => root?.unmount()); root = null; container.remove(); // Drain any pending macrotasks (deferred popover-open timers, Radix's // focus-restoration timers) so a timer scheduled by this test can't fire // mid-way through the next one and dismiss its freshly-opened popover — that // cross-test leak is what made the real-timer sibling regression tests flaky. await flush(); for (const child of [...document.body.children]) { child.remove(); } document.body.style.pointerEvents = ""; vi.restoreAllMocks(); }); const secrets = [makeSecret("s1", { name: "GITHUB_TOKEN", latestVersion: 3 })]; function nameInputs() { return [...container.querySelectorAll('input[aria-label="Variable name"]')]; } function saveButton() { return [...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save")!; } it("renders header + a row per binding, no trailing ghost row", () => { render( {}} onCreateSecret={async () => secrets[0]} />, ); expect(nameInputs()).toHaveLength(2); expect(container.textContent).toContain("Name"); expect(container.textContent).toContain("Value"); // Version tag reflects the actual bound version (not a static "latest"). const versionTag = container.querySelector('button[aria-label="Version"]'); expect(versionTag?.textContent).toBe("v2"); }); it("keeps long secret names clear of the latest version control", () => { const longSecret = makeSecret("long", { name: "/paperclip-cloud/prod/provider/resend/api-key-with-a-very-long-name", latestVersion: 4, }); render( {}} onCreateSecret={async () => longSecret} />, ); const combobox = container.querySelector('[role="combobox"]')!; const selectedLabel = combobox.querySelector("[title] span.truncate"); const versionTag = container.querySelector('button[aria-label="Version"]')!; expect(versionTag.textContent).toBe("latest"); expect(combobox.className).toContain("has-[>svg]:!pr-24"); expect(selectedLabel?.textContent).toBe(longSecret.name); expect(selectedLabel?.className).toContain("flex-1"); }); it("shows the empty state with no bindings", () => { render( {}} onCreateSecret={async () => secrets[0]} />); expect(container.textContent).toContain("No environment variables"); expect(nameInputs()).toHaveLength(0); }); it("appends a row when + Add variable is clicked", async () => { render( {}} onCreateSecret={async () => secrets[0]} />); const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; addButton.click(); await flush(); expect(nameInputs()).toHaveLength(1); }); it("does not emit when + Add variable only creates an empty draft row", async () => { const onChange = vi.fn(); render( secrets[0]} />); const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; addButton.click(); await flush(); expect(nameInputs()).toHaveLength(1); expect(onChange).not.toHaveBeenCalled(); }); it("saves plain bindings only when Save is clicked", async () => { const onChange = vi.fn(); render( secrets[0]} />); const valueInput = container.querySelector('input[aria-label="Variable value"]')!; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; setter.call(valueInput, "bar"); valueInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); expect(onChange).not.toHaveBeenCalled(); expect(container.textContent).toContain("Unsaved changes"); saveButton().click(); await flush(); expect(onChange).toHaveBeenLastCalledWith({ FOO: { type: "plain", value: "bar" } }); }); it("flushes unsaved editor changes before an enclosing form submits", async () => { const submittedValues: Array | undefined> = []; function FormHarness() { const [value, setValue] = useState>({ FOO: { type: "plain", value: "" }, }); return (
{ event.preventDefault(); submittedValues.push(value); }} > setValue(next ?? {})} onCreateSecret={async () => secrets[0]} /> ); } render(); const valueInput = container.querySelector('input[aria-label="Variable value"]')!; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; setter.call(valueInput, "bar"); valueInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); const outerSave = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Outer save"), )!; outerSave.click(); await flush(); expect(submittedValues).toEqual([{ FOO: { type: "plain", value: "bar" } }]); }); it("flushes unsaved editor changes before an external save button reads parent state", async () => { const savedValues: Array> = []; function SaveButtonHarness() { const [value, setValue] = useState>({ FOO: { type: "plain", value: "" }, }); return (
setValue(next ?? {})} onCreateSecret={async () => secrets[0]} />
); } render(); const valueInput = container.querySelector('input[aria-label="Variable value"]')!; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; setter.call(valueInput, "bar"); valueInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); const outerSave = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Save settings"), )!; outerSave.click(); await flush(); expect(savedValues).toEqual([{ FOO: { type: "plain", value: "bar" } }]); }); it("makes unsaved fields and save controls prominent while editing", async () => { render( {}} onCreateSecret={async () => secrets[0]} />); const valueInput = container.querySelector('input[aria-label="Variable value"]')!; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; setter.call(valueInput, "bar"); valueInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); const unsavedBar = [...container.querySelectorAll('[role="status"]')].find((node) => node.textContent?.includes("Unsaved changes"), ); const valueCell = valueInput.closest(".relative.flex"); const save = saveButton(); const revert = [...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Revert")!; expect(unsavedBar?.className).toContain("bg-amber-500/10"); expect(valueCell?.className).toContain("border-amber-500/70"); expect(save.className).toContain("h-9"); expect(save.className).toContain("px-4"); expect(revert.className).toContain("h-9"); }); it("lists which variables changed in the unsaved-changes banner", async () => { render( {}} onCreateSecret={async () => secrets[0]} />, ); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; // Edit FOO's value. const valueInput = container.querySelector('input[aria-label="Variable value"]')!; setter.call(valueInput, "new"); valueInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); expect(container.textContent).toContain("Edited: FOO"); // Remove BAR. const removeButtons = [...container.querySelectorAll('button[aria-label^="Remove"]')]; removeButtons.at(-1)!.click(); await flush(); expect(container.textContent).toContain("Removed: BAR"); // Add a brand-new variable. const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; addButton.click(); await flush(); const newNameInput = nameInputs().at(-1)!; setter.call(newNameInput, "API_TOKEN"); newNameInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); expect(container.textContent).toContain("New: API_TOKEN"); expect(container.textContent).toContain("Edited: FOO"); expect(container.textContent).toContain("Removed: BAR"); }); it("does not show a phantom unsaved-changes banner for a saved value that round-trips lossily", () => { // Incomplete refs and unpadded names are dropped by the editor's emit // semantics — the committed baseline must drop them too, or the banner // shows the moment the form opens with no user edits. render( {}} onCreateSecret={async () => secrets[0]} />, ); expect(container.textContent).not.toContain("Unsaved changes"); }); it("warns via beforeunload only while the draft is dirty", async () => { render( {}} onCreateSecret={async () => secrets[0]} />, ); function dispatchBeforeUnload(): boolean { const event = new Event("beforeunload", { cancelable: true }); window.dispatchEvent(event); return event.defaultPrevented; } expect(dispatchBeforeUnload()).toBe(false); const valueInput = container.querySelector('input[aria-label="Variable value"]')!; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; setter.call(valueInput, "bar"); valueInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); expect(dispatchBeforeUnload()).toBe(true); const revert = [...container.querySelectorAll("button")].find( (button) => button.textContent?.trim() === "Revert", )!; revert.click(); await flush(); expect(dispatchBeforeUnload()).toBe(false); }); it("marks a newly typed variable name as unsaved before saving", async () => { render( {}} onCreateSecret={async () => secrets[0]} />); const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; addButton.click(); await flush(); const nameInput = nameInputs()[0]!; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; setter.call(nameInput, "API_TOKEN"); nameInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); expect(nameInput.className).toContain("border-amber-500/70"); expect(container.textContent).toContain("Unsaved changes"); }); it("does not emit or remount while typing a new variable before manual save", async () => { const onChange = vi.fn(); const savedValue: Record = { ZED: { type: "plain", value: "z" }, ALPHA: { type: "plain", value: "a" }, }; render( secrets[0]} />, ); const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; addButton.click(); await flush(); const newNameInput = nameInputs().at(-1)!; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; setter.call(newNameInput, "ca"); newNameInput.dispatchEvent(new Event("input", { bubbles: true })); newNameInput.focus(); await flush(); expect(onChange).not.toHaveBeenCalled(); expect(document.activeElement).toBe(newNameInput); rerender( secrets[0]} />, ); await flush(); expect(onChange).not.toHaveBeenCalled(); expect(document.activeElement).toBe(newNameInput); expect(nameInputs().at(-1)).toBe(newNameInput); setter.call(newNameInput, "carol"); newNameInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); expect(onChange).not.toHaveBeenCalled(); saveButton().click(); await flush(); expect(onChange).toHaveBeenLastCalledWith({ ZED: { type: "plain", value: "z" }, ALPHA: { type: "plain", value: "a" }, carol: { type: "plain", value: "" }, }); }); it("keeps the active row mounted when a manual save echo returns an equivalent value", async () => { const onChange = vi.fn(); const emptyValue: Record = {}; const savedValue: Record = { API_TOKEN: { type: "plain", value: "secret-value" } }; render( secrets[0]} />, ); const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; addButton.click(); await flush(); const [nameInput] = nameInputs(); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; setter.call(nameInput, "API_TOKEN"); nameInput.dispatchEvent(new Event("input", { bubbles: true })); await flush(); const valueInput = container.querySelector('input[aria-label="Variable value"]')!; setter.call(valueInput, "secret-value"); valueInput.dispatchEvent(new Event("input", { bubbles: true })); valueInput.focus(); await flush(); expect(document.activeElement).toBe(valueInput); expect(onChange).not.toHaveBeenCalled(); saveButton().click(); await flush(); expect(onChange).toHaveBeenLastCalledWith(savedValue); rerender( secrets[0]} />, ); await flush(); expect(document.activeElement).toBe(valueInput); expect(container.querySelector('input[aria-label="Variable value"]')).toBe(valueInput); }); it("emits undefined when the last binding is removed", async () => { const onChange = vi.fn(); render( secrets[0]} />); const removeButton = container.querySelector('button[aria-label^="Remove"]')!; removeButton.click(); await flush(); expect(onChange).not.toHaveBeenCalled(); saveButton().click(); await flush(); expect(onChange).toHaveBeenLastCalledWith(undefined); }); it("disables inputs in read-only mode", () => { render( {}} onCreateSecret={async () => secrets[0]} disabled />, ); expect(container.querySelector('input[aria-label="Variable name"]')!.disabled).toBe(true); expect(container.querySelector('input[aria-label="Variable value"]')!.disabled).toBe(true); }); it("renders name warnings as a row spanning the name and value columns", () => { render( {}} onCreateSecret={async () => secrets[0]} />, ); const nameInput = container.querySelector('input[aria-label="Variable name"]')!; const warning = [...container.querySelectorAll("p")].find((node) => node.textContent?.includes("Reserved prefix"), ); expect(warning, "reserved-prefix warning should render").toBeTruthy(); expect(nameInput.getAttribute("aria-describedby")).toBe(warning!.id); expect(warning!.parentElement?.contains(nameInput), "warning should stay in the row grid").toBe(true); expect(warning!.parentElement).not.toBe(nameInput.parentElement); expect(warning!.className).toContain("col-span-2"); expect(warning!.className).toContain("@[40rem]/env:row-start-2"); }); it("bulk-imports a dotenv paste into an empty name field", async () => { const onChange = vi.fn(); render( secrets[0]} />); // Add an empty row to paste into. const addButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Add variable"))!; addButton.click(); await flush(); const nameInput = nameInputs()[0]!; const clipboardData = { getData: () => "A=1\nB=2\nC=3" } as unknown as DataTransfer; const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as unknown as ClipboardEvent; Object.defineProperty(pasteEvent, "clipboardData", { value: clipboardData }); nameInput.dispatchEvent(pasteEvent); await flush(); expect(onChange).not.toHaveBeenCalled(); saveButton().click(); await flush(); expect(onChange).toHaveBeenLastCalledWith({ A: { type: "plain", value: "1" }, B: { type: "plain", value: "2" }, C: { type: "plain", value: "3" }, }); }); it("bulk-imports dotenv updates without mutating the committed row baseline", async () => { render( {}} onCreateSecret={async () => secrets[0]} />, ); const addButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Add variable"), )!; addButton.click(); await flush(); const targetNameInput = nameInputs().at(-1)!; const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as unknown as ClipboardEvent; Object.defineProperty(pasteEvent, "clipboardData", { value: { getData: () => "A=new" } as unknown as DataTransfer, }); targetNameInput.dispatchEvent(pasteEvent); await flush(); const valueInput = container.querySelector('input[aria-label="Variable value"]')!; const valueCell = valueInput.closest(".relative.flex"); expect(valueInput.value).toBe("new"); expect(valueCell?.className).toContain("border-amber-500/70"); }); it("auto-detects a sensitive value and offers a value-preserving Store-as-secret popover", async () => { // A sensitive KEY (matches the shared regex) surfaces the ShieldAlert // affordance and auto-masks the value input (§6.6). render( {}} onCreateSecret={async () => secrets[0]} />, ); const valueInput = container.querySelector('input[aria-label="Variable value"]')!; expect(valueInput.type).toBe("password"); // auto-masked const storeButton = container.querySelector('button[title^="This value looks sensitive"]'); expect(storeButton, "sensitive Store-as-secret affordance should render").toBeTruthy(); storeButton!.click(); await flush(); // The store popover carries the typed value forward (not discarded). expect(document.body.textContent).toContain("Store value as secret"); const secretValueField = document.querySelector('input[aria-label="Secret value"]'); expect(secretValueField?.value).toBe("supersecretvalue"); }); it("opens the create-secret popover from the picker's + Create item (§6.4, PAP-12476)", async () => { // Regression: selecting the picker's `+ Create secret` item closes the // combobox popover and (in the same tick) opens the anchored create-secret // popover. The two Radix popovers must not race — the create popover has to // survive the combobox's focus-return instead of being dismissed instantly. render( {}} onCreateSecret={async () => secrets[0]} />, ); const combobox = container.querySelector('[role="combobox"]')!; combobox.focus(); await flush(); const createItem = [...document.querySelectorAll("[cmdk-item]")].find((el) => el.textContent?.includes("Create"), ); expect(createItem, "create item should be present in the open picker").toBeTruthy(); createItem!.click(); await flush(); // The create-secret popover is open (heading rendered) and stays open. expect(document.body.textContent, "create-secret popover should open").toContain("Create secret"); expect( document.querySelector('input[aria-label="Secret name"]'), "create-secret name field should render", ).toBeTruthy(); }); it("keeps the create-secret popover open when focus returns to a control inside the value cell (§6.4, PAP-12492)", async () => { // Regression for the *fragile* part of the picker → create transition. // The picker's combobox and the anchored create popover share the value // cell as their anchor. When the picker tears down, Radix returns focus to // the combobox trigger *inside* that anchor — and because the anchor sits // outside the create popover's content, Radix reads that focus-return as a // `focusOutside` and dismisses the just-opened create popover. The picker's // close animation can delay that focus-return past the `setTimeout(0)` open // defer, so a timing-based fix is not enough; the popover must survive an // in-anchor focus-return whenever it lands. jsdom does not reproduce the // animation-delayed focus-return on its own, so we drive it explicitly: // open the create popover, then fire a late `focusin` on an in-anchor // control and assert the popover is still open. render( {}} onCreateSecret={async () => secrets[0]} />, ); const combobox = container.querySelector('[role="combobox"]')!; combobox.focus(); await flush(); const createItem = [...document.querySelectorAll("[cmdk-item]")].find((el) => el.textContent?.includes("Create"), ); expect(createItem, "create item should be present in the open picker").toBeTruthy(); createItem!.click(); await flush(); expect(document.body.textContent, "create-secret popover should open").toContain("Create secret"); // Simulate the picker's animation-delayed focus-return landing on a control // that lives inside the value cell (the source-switch trigger is a stable // in-anchor target that doesn't reopen the picker). Moving focus fires a // blur off the create popover's content (flipping Radix's "focus inside" // flag) and then a focusin on the in-anchor control — exactly the sequence // Radix reads as a `focusOutside`. Without the guard this dismisses the // create popover; with it, the popover survives. const sourceButton = container.querySelector('button[aria-label="Value source"]')!; expect(sourceButton, "in-anchor Value source control should exist").toBeTruthy(); sourceButton.focus(); await flush(); expect( document.body.textContent, "create-secret popover must survive the in-anchor focus-return", ).toContain("Create secret"); expect( document.querySelector('input[aria-label="Secret name"]'), "create-secret name field should still render", ).toBeTruthy(); }); it("opens the store-as-secret popover from the ⋯ overflow menu (PAP-12477)", async () => { // Regression: the ⋯ overflow → "Store as secret…" item is the same nested // shape as the picker's + Create item — a DropdownMenu whose onSelect opens // the row's anchored Popover. The menu closing returns focus to its trigger // (which lives outside the Popover), which must not land as a focusOutside // that dismisses the just-opened popover. A non-sensitive text row surfaces // the ⋯ menu (the sensitive-value inline button is a separate path). render( {}} onCreateSecret={async () => secrets[0]} />, ); // The value is non-sensitive, so no inline Store-as-secret button — only ⋯. expect(container.querySelector('button[title^="This value looks sensitive"]')).toBeNull(); const overflow = container.querySelector('button[aria-label="More actions"]'); expect(overflow, "⋯ overflow menu should render for a non-sensitive text row").toBeTruthy(); pointerClick(overflow!); await flush(); const storeItem = [...document.querySelectorAll('[role="menuitem"]')].find((el) => el.textContent?.includes("Store as secret"), ); expect(storeItem, "Store as secret… menu item should be present").toBeTruthy(); pointerClick(storeItem!); await flush(); // The store popover is open (heading rendered) and stays open — it must not // be dismissed by the menu's focus-return. expect(document.body.textContent, "store-as-secret popover should open").toContain( "Store value as secret", ); expect( document.querySelector('input[aria-label="Secret value"]'), "store-as-secret value field should render", ).toBeTruthy(); }); it("opens the store popover from the source dropdown Text→Company secret (with a value) and keeps it open (PAP-12478)", () => { // Regression: switching a *non-empty* Text row to "Company secret" via the // in-field Value source dropdown must open the anchored store-as-secret // popover (§6.3) — value preserved, not discarded. This is the same nested // DropdownMenu→Popover open-while-closing race as the ⋯ and picker paths // (PAP-12476/12477): opening the popover synchronously inside the menu-item's // onSelect lets the menu's focus-return dismiss it in the same tick. With a // *synchronous* open this assertion fails; with the deferred open it passes. vi.useFakeTimers(); render( {}} onCreateSecret={async () => secrets[0]} />, ); const sourceButton = container.querySelector('button[aria-label="Value source"]'); expect(sourceButton, "Value source dropdown should render").toBeTruthy(); pointerClick(sourceButton!); settleFakeTimers(); const secretItem = [...document.querySelectorAll('[role="menuitem"]')].find((el) => el.textContent?.includes("Organization secret"), ); expect(secretItem, "Organization secret menu item should be present").toBeTruthy(); pointerClick(secretItem!); settleFakeTimers(); // The store popover is open (heading rendered) and stays open — it must not // be dismissed by the menu's focus-return. expect(document.body.textContent, "store-as-secret popover should open").toContain( "Store value as secret", ); // The current value is carried forward (masked), not discarded. const secretValueField = document.querySelector('input[aria-label="Secret value"]'); expect(secretValueField, "store-as-secret value field should render").toBeTruthy(); expect(secretValueField!.value).toBe("production"); }); it("lets the user dismiss the sensitive-value hint, unmasking the value and keeping it plain (§6.6)", async () => { const onChange = vi.fn(); render( secrets[0]} />, ); const valueInput = container.querySelector('input[aria-label="Variable value"]')!; expect(valueInput.type).toBe("password"); // auto-masked while the hint shows const dismissButton = container.querySelector( 'button[aria-label="Dismiss sensitive-value suggestion"]', ); expect(dismissButton, "dismiss affordance should render alongside the hint").toBeTruthy(); dismissButton!.click(); await flush(); // Hint + its dismiss control are gone, and the value is no longer masked. expect( container.querySelector('button[title^="This value looks sensitive"]'), "store-as-secret hint should be dismissed", ).toBeNull(); expect( container.querySelector('button[aria-label="Dismiss sensitive-value suggestion"]'), ).toBeNull(); expect( container.querySelector('input[aria-label="Variable value"]')!.type, ).toBe("text"); // Dismissal is a local UI concern — the emitted plain value is unchanged. const lastEmit = onChange.mock.calls.at(-1)?.[0]; if (lastEmit) { expect(lastEmit).toEqual({ STRIPE_API_KEY: { type: "plain", value: "supersecretvalue" } }); } }); it("lets slash-delimited secrets be browsed by folder and exposes full paths on hover", async () => { const cloudProviderKey = "/paperclip-cloud/prod/provider/aws-access-key-id"; const cloudMigrationKey = "/paperclip-cloud/prod/migration/postgres-url"; const onSelect = vi.fn(); render( {}} disablePortal />, ); const combobox = container.querySelector('[role="combobox"]')!; combobox.focus(); await flush(); const itemByText = (text: string) => [...document.querySelectorAll("[cmdk-item]")].find((el) => el.textContent?.includes(text), ); const rowTitle = (item: HTMLElement | undefined) => item?.querySelector("[title]")?.getAttribute("title"); expect(rowTitle(itemByText("paperclip-cloud"))).toBe("/paperclip-cloud"); expect(rowTitle(itemByText("paperclip-page-aws-access-key-id"))).toBe( "paperclip-page-aws-access-key-id", ); itemByText("paperclip-cloud")!.click(); await flush(); expect(rowTitle(itemByText("prod"))).toBe("/paperclip-cloud/prod"); itemByText("prod")!.click(); await flush(); expect(rowTitle(itemByText("provider"))).toBe("/paperclip-cloud/prod/provider"); expect(rowTitle(itemByText("migration"))).toBe("/paperclip-cloud/prod/migration"); itemByText("provider")!.click(); await flush(); const leaf = itemByText("aws-access-key-id"); expect(rowTitle(leaf)).toBe(cloudProviderKey); leaf!.click(); await flush(); expect(onSelect).toHaveBeenCalledWith("provider-key"); }); it("keeps secret search global while browsing starts at slash folders", async () => { const cloudMigrationKey = "/paperclip-cloud/prod/migration/postgres-url"; render( {}} onCreateNew={() => {}} disablePortal />, ); const combobox = container.querySelector('[role="combobox"]')!; combobox.focus(); await flush(); expect(document.body.textContent).toContain("paperclip-cloud"); expect(document.body.textContent).not.toContain("postgres-url"); const search = document.querySelector('input[placeholder="Search secrets…"]')!; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; setter.call(search, "postgres"); search.dispatchEvent(new Event("input", { bubbles: true })); await flush(); const match = [...document.querySelectorAll("[cmdk-item]")].find((el) => el.textContent?.includes(cloudMigrationKey), ); expect(match?.querySelector("[title]")?.getAttribute("title")).toBe(cloudMigrationKey); }); });