mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Instance settings include an Environments section where operators configure execution environments, each with an environment-variables editor for run-time bindings > - The editor showed a bare "Unsaved changes" banner that never said which variables changed, sometimes appeared the moment a saved config was opened (a lossy round-trip through the editor's emit rules made clean values look dirty), and the environment form let you navigate away without any confirmation, silently dropping the draft > - Operators could not tell what was unsaved, distrusted the phantom banner, and lost half-finished environment edits to a stray click — the agent configuration page already confirms before discarding, so environments behaved inconsistently > - This pull request lists the new/edited/removed variable names under the banner, normalizes both sides of the dirty comparison so saved values no longer look dirty on open, and confirms before cancel, in-app navigation, or tab unload while the form has unsaved changes > - The benefit is that the banner is trustworthy and specific, and unsaved environment edits can no longer be lost without an explicit confirmation ## Linked Issues or Issue Description Related (not fixed by this PR): #8930 introduced the current environment-variables editor and its unsaved-changes banner; #9386 moved environment create/edit from a modal to routed pages, which this PR's navigation guard builds on. No existing public issue for the defects themselves; described per the bug report template: **What happened?** The environment-variables editor in Environments settings showed a bare "Unsaved changes" banner with no indication of which variables changed. For some saved configurations (names with surrounding whitespace, incomplete secret references, duplicate names differing only by whitespace) the banner appeared immediately on opening the edit form, before any user input. Navigating away from the environment form — cancel, an in-app link, or closing the tab — silently discarded the draft with no confirmation. **Expected behavior** The banner should say which variables are new, edited, or removed; a freshly opened saved configuration should show no banner; and leaving the form with unsaved changes should require an explicit confirmation, consistent with the agent configuration page. **Steps to reproduce** 1. Open Settings → Instance settings → Environments and edit an environment whose saved config round-trips lossily (e.g. an env var name stored with trailing whitespace) — the "Unsaved changes" banner appears with no user edits. 2. Add or edit a variable — the banner gives no hint of what is unsaved. 3. With a dirty draft, click any in-app link or Cancel — the draft is dropped with no confirmation. **Deployment mode** Self-hosted (local development instance), reproducible on `master`. ## What Changed - The unsaved-changes banner in `EnvironmentVariablesEditor` now renders a change summary line — `New: … · Edited: … · Removed: …` — showing up to three names per group with a `+N more` overflow and the full list in a `title` tooltip. A rename shows as one addition plus one removal. - Dirty detection normalizes both the committed value and the draft through the same rules the editor uses when emitting values (trimmed names, incomplete secret refs dropped, last-writer-wins on trimmed duplicates), so a saved config that round-trips lossily no longer shows a phantom banner on first open. - The editor exposes an `onDirtyChange` callback and warns via `beforeunload` while its local draft is dirty. - The environment create/edit page (`CompanyEnvironments`) tracks a payload-level baseline fingerprint of the form as initialized and treats the page as having unsaved changes when the current form differs from it or the editor draft is dirty. While dirty it confirms ("Discard unsaved environment changes?") on Cancel, intercepts same-origin in-app link clicks, and warns on tab unload. ## Verification - `node_modules/.bin/vitest run ui/src/pages/CompanyEnvironments.test.tsx ui/src/components/environment-variables-editor/EnvironmentVariablesEditor.test.tsx` — 48 tests pass, including new coverage for: the change-summary banner text, no phantom banner for lossy round-trip values, beforeunload only while dirty, cancel confirmation on the edit page, and unload/link-click warnings after edits are staged into the form. - `tsc -b` in `ui/` passes. - Manual: edit an environment, add/edit/remove variables, observe the summary line; click Cancel or an in-app link and observe the confirmation; save and observe navigation proceeds without prompting. ## Risks - Low risk, UI-only. The click interceptor is scoped to same-origin anchor navigation while the environment form page has unsaved changes and is removed on cleanup; modified-key/middle-button clicks and external links are left alone. - The dirty-normalization intentionally ignores differences the editor could never persist (incomplete secret refs, untrimmed duplicate names); those were previously reported as unsaved changes that could not be saved away. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code, with extended thinking and tool use (code editing, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
952 lines
39 KiB
TypeScript
952 lines
39 KiB
TypeScript
// @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> = {}): 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<HTMLInputElement>('input[aria-label="Variable name"]')];
|
|
}
|
|
|
|
function saveButton() {
|
|
return [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Save")!;
|
|
}
|
|
|
|
it("renders header + a row per binding, no trailing ghost row", () => {
|
|
render(
|
|
<EnvironmentVariablesEditor
|
|
value={{
|
|
NODE_ENV: { type: "plain", value: "production" },
|
|
GH: { type: "secret_ref", secretId: "s1", version: 2 },
|
|
}}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
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(
|
|
<EnvironmentVariablesEditor
|
|
value={{
|
|
RESEND_API_KEY: { type: "secret_ref", secretId: "long", version: "latest" },
|
|
}}
|
|
secrets={[longSecret]}
|
|
onChange={() => {}}
|
|
onCreateSecret={async () => longSecret}
|
|
/>,
|
|
);
|
|
|
|
const combobox = container.querySelector<HTMLElement>('[role="combobox"]')!;
|
|
const selectedLabel = combobox.querySelector<HTMLElement>("[title] span.truncate");
|
|
const versionTag = container.querySelector<HTMLButtonElement>('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(<EnvironmentVariablesEditor value={{}} secrets={secrets} onChange={() => {}} 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(<EnvironmentVariablesEditor value={{}} secrets={secrets} onChange={() => {}} 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(<EnvironmentVariablesEditor value={{}} secrets={secrets} onChange={onChange} onCreateSecret={async () => 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(<EnvironmentVariablesEditor value={{ FOO: { type: "plain", value: "" } }} secrets={secrets} onChange={onChange} onCreateSecret={async () => secrets[0]} />);
|
|
const valueInput = container.querySelector<HTMLInputElement>('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<Record<string, EnvBinding> | undefined> = [];
|
|
|
|
function FormHarness() {
|
|
const [value, setValue] = useState<Record<string, EnvBinding>>({
|
|
FOO: { type: "plain", value: "" },
|
|
});
|
|
return (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
submittedValues.push(value);
|
|
}}
|
|
>
|
|
<EnvironmentVariablesEditor
|
|
value={value}
|
|
secrets={secrets}
|
|
onChange={(next) => setValue(next ?? {})}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>
|
|
<button type="submit">Outer save</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
render(<FormHarness />);
|
|
const valueInput = container.querySelector<HTMLInputElement>('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<HTMLButtonElement>("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<Record<string, EnvBinding>> = [];
|
|
|
|
function SaveButtonHarness() {
|
|
const [value, setValue] = useState<Record<string, EnvBinding>>({
|
|
FOO: { type: "plain", value: "" },
|
|
});
|
|
return (
|
|
<div>
|
|
<EnvironmentVariablesEditor
|
|
value={value}
|
|
secrets={secrets}
|
|
onChange={(next) => setValue(next ?? {})}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>
|
|
<button type="button" onClick={() => savedValues.push(value)}>
|
|
Save settings
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
render(<SaveButtonHarness />);
|
|
const valueInput = container.querySelector<HTMLInputElement>('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<HTMLButtonElement>("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(<EnvironmentVariablesEditor value={{ FOO: { type: "plain", value: "" } }} secrets={secrets} onChange={() => {}} onCreateSecret={async () => secrets[0]} />);
|
|
const valueInput = container.querySelector<HTMLInputElement>('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<HTMLElement>('[role="status"]')].find((node) =>
|
|
node.textContent?.includes("Unsaved changes"),
|
|
);
|
|
const valueCell = valueInput.closest<HTMLDivElement>(".relative.flex");
|
|
const save = saveButton();
|
|
const revert = [...container.querySelectorAll<HTMLButtonElement>("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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ FOO: { type: "plain", value: "old" }, BAR: { type: "plain", value: "keep" } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>,
|
|
);
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
|
|
|
// Edit FOO's value.
|
|
const valueInput = container.querySelector<HTMLInputElement>('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<HTMLButtonElement>('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(
|
|
<EnvironmentVariablesEditor
|
|
value={{
|
|
" PADDED_NAME ": { type: "plain", value: "x" },
|
|
DANGLING_SECRET: { type: "secret_ref", secretId: "", version: "latest" },
|
|
DANGLING_USER_SECRET: { type: "user_secret_ref", key: "", version: "latest", required: true },
|
|
}}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>,
|
|
);
|
|
expect(container.textContent).not.toContain("Unsaved changes");
|
|
});
|
|
|
|
it("warns via beforeunload only while the draft is dirty", async () => {
|
|
render(
|
|
<EnvironmentVariablesEditor
|
|
value={{ FOO: { type: "plain", value: "" } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
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<HTMLInputElement>('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<HTMLButtonElement>("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(<EnvironmentVariablesEditor value={{}} secrets={secrets} onChange={() => {}} 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<string, EnvBinding> = {
|
|
ZED: { type: "plain", value: "z" },
|
|
ALPHA: { type: "plain", value: "a" },
|
|
};
|
|
render(
|
|
<EnvironmentVariablesEditor
|
|
value={savedValue}
|
|
secrets={secrets}
|
|
onChange={onChange}
|
|
onCreateSecret={async () => 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(
|
|
<EnvironmentVariablesEditor
|
|
value={savedValue}
|
|
secrets={secrets}
|
|
onChange={onChange}
|
|
onCreateSecret={async () => 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<string, EnvBinding> = {};
|
|
const savedValue: Record<string, EnvBinding> = { API_TOKEN: { type: "plain", value: "secret-value" } };
|
|
render(
|
|
<EnvironmentVariablesEditor
|
|
value={emptyValue}
|
|
secrets={secrets}
|
|
onChange={onChange}
|
|
onCreateSecret={async () => 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<HTMLInputElement>('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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ API_TOKEN: { type: "plain", value: "secret-value" } }}
|
|
secrets={secrets}
|
|
onChange={onChange}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>,
|
|
);
|
|
await flush();
|
|
|
|
expect(document.activeElement).toBe(valueInput);
|
|
expect(container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')).toBe(valueInput);
|
|
});
|
|
|
|
it("emits undefined when the last binding is removed", async () => {
|
|
const onChange = vi.fn();
|
|
render(<EnvironmentVariablesEditor value={{ FOO: { type: "plain", value: "x" } }} secrets={secrets} onChange={onChange} onCreateSecret={async () => secrets[0]} />);
|
|
const removeButton = container.querySelector<HTMLButtonElement>('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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ FOO: { type: "plain", value: "x" } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
onCreateSecret={async () => secrets[0]}
|
|
disabled
|
|
/>,
|
|
);
|
|
expect(container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!.disabled).toBe(true);
|
|
expect(container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!.disabled).toBe(true);
|
|
});
|
|
|
|
it("renders name warnings as a row spanning the name and value columns", () => {
|
|
render(
|
|
<EnvironmentVariablesEditor
|
|
value={{ PAPERCLIP_PAGE_BASE_URL: { type: "plain", value: "https://pages.paperclip.ing" } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>,
|
|
);
|
|
|
|
const nameInput = container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!;
|
|
const warning = [...container.querySelectorAll<HTMLParagraphElement>("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(<EnvironmentVariablesEditor value={{}} secrets={secrets} onChange={onChange} onCreateSecret={async () => 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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ A: { type: "plain", value: "old" } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
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<HTMLInputElement>('input[aria-label="Variable value"]')!;
|
|
const valueCell = valueInput.closest<HTMLDivElement>(".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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ STRIPE_API_KEY: { type: "plain", value: "supersecretvalue" } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>,
|
|
);
|
|
const valueInput = container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!;
|
|
expect(valueInput.type).toBe("password"); // auto-masked
|
|
const storeButton = container.querySelector<HTMLButtonElement>('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<HTMLInputElement>('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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ GH: { type: "secret_ref", secretId: "s1", version: 2 } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>,
|
|
);
|
|
const combobox = container.querySelector<HTMLElement>('[role="combobox"]')!;
|
|
combobox.focus();
|
|
await flush();
|
|
const createItem = [...document.querySelectorAll<HTMLElement>("[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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ GH: { type: "secret_ref", secretId: "s1", version: 2 } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>,
|
|
);
|
|
const combobox = container.querySelector<HTMLElement>('[role="combobox"]')!;
|
|
combobox.focus();
|
|
await flush();
|
|
const createItem = [...document.querySelectorAll<HTMLElement>("[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<HTMLElement>('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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ API_BASE_URL: { type: "plain", value: "https://example.com" } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
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<HTMLButtonElement>('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<HTMLElement>('[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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ NODE_ENV: { type: "plain", value: "production" } }}
|
|
secrets={secrets}
|
|
onChange={() => {}}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>,
|
|
);
|
|
const sourceButton = container.querySelector<HTMLButtonElement>('button[aria-label="Value source"]');
|
|
expect(sourceButton, "Value source dropdown should render").toBeTruthy();
|
|
pointerClick(sourceButton!);
|
|
settleFakeTimers();
|
|
const secretItem = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((el) =>
|
|
el.textContent?.includes("Company secret"),
|
|
);
|
|
expect(secretItem, "Company 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<HTMLInputElement>('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(
|
|
<EnvironmentVariablesEditor
|
|
value={{ STRIPE_API_KEY: { type: "plain", value: "supersecretvalue" } }}
|
|
secrets={secrets}
|
|
onChange={onChange}
|
|
onCreateSecret={async () => secrets[0]}
|
|
/>,
|
|
);
|
|
const valueInput = container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!;
|
|
expect(valueInput.type).toBe("password"); // auto-masked while the hint shows
|
|
|
|
const dismissButton = container.querySelector<HTMLButtonElement>(
|
|
'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<HTMLInputElement>('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(
|
|
<SecretPicker
|
|
secretId=""
|
|
secrets={[
|
|
makeSecret("provider-key", { name: cloudProviderKey, key: cloudProviderKey }),
|
|
makeSecret("migration-key", { name: cloudMigrationKey, key: cloudMigrationKey }),
|
|
makeSecret("plain-key", { name: "paperclip-page-aws-access-key-id", key: "paperclip-page-aws-access-key-id" }),
|
|
]}
|
|
onSelect={onSelect}
|
|
onCreateNew={() => {}}
|
|
disablePortal
|
|
/>,
|
|
);
|
|
|
|
const combobox = container.querySelector<HTMLElement>('[role="combobox"]')!;
|
|
combobox.focus();
|
|
await flush();
|
|
|
|
const itemByText = (text: string) =>
|
|
[...document.querySelectorAll<HTMLElement>("[cmdk-item]")].find((el) =>
|
|
el.textContent?.includes(text),
|
|
);
|
|
const rowTitle = (item: HTMLElement | undefined) => item?.querySelector<HTMLElement>("[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(
|
|
<SecretPicker
|
|
secretId=""
|
|
secrets={[
|
|
makeSecret("provider-key", {
|
|
name: "/paperclip-cloud/prod/provider/aws-access-key-id",
|
|
key: "/paperclip-cloud/prod/provider/aws-access-key-id",
|
|
}),
|
|
makeSecret("migration-key", { name: cloudMigrationKey, key: cloudMigrationKey }),
|
|
]}
|
|
onSelect={() => {}}
|
|
onCreateNew={() => {}}
|
|
disablePortal
|
|
/>,
|
|
);
|
|
|
|
const combobox = container.querySelector<HTMLElement>('[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<HTMLInputElement>('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<HTMLElement>("[cmdk-item]")].find((el) =>
|
|
el.textContent?.includes(cloudMigrationKey),
|
|
);
|
|
expect(match?.querySelector<HTMLElement>("[title]")?.getAttribute("title")).toBe(cloudMigrationKey);
|
|
});
|
|
});
|