Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions src/profiles/web/src/components/ConfigEditorModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,154 @@ describe("ConfigEditorModal", () => {
});

/**
* Editor feedback tests (issues #102 + #100): editing must clear stale
* Test/validation feedback immediately, and validation must re-run
* automatically ~800ms after the user stops typing.
*/
describe("ConfigEditorModal live validation (issues #102/#100)", () => {
afterEach(() => {
vi.useRealTimers();
});

it("test_editing_clears_stale_validation_feedback", async () => {
validateSpy.mockResolvedValue({ valid: false, issues: [{ severity: "error", message: "boom" }] });
const user = userEvent.setup();
renderModal();

const view = await readyEditorView();
// Run Test: the banner + issue list describe the pre-edit document.
await user.click(screen.getByRole("button", { name: /test/i }));
await waitFor(() => expect(screen.getByTestId("editor-status")).toBeTruthy());
expect(screen.getByTestId("editor-issues")).toBeTruthy();

// One keystroke invalidates that feedback — it must vanish immediately.
await act(async () => {
view.dispatch({ changes: { from: 0, insert: "# edited\n" } });
});
expect(screen.queryByTestId("editor-status")).toBeNull();
expect(screen.queryByTestId("editor-issues")).toBeNull();
});

it("test_live_validation_fires_after_debounce", async () => {
renderModal();
const view = await readyEditorView();
// Freeze time only after the editor is ready: RTL waitFor relies on
// real timers while probing the mounted view.
vi.useFakeTimers();
validateSpy.mockResolvedValue({ valid: true, issues: [] });

await act(async () => {
view.dispatch({ changes: { from: 0, insert: "# edited\n" } });
});
// A pause shorter than the debounce fires nothing.
await act(async () => {
await vi.advanceTimersByTimeAsync(799);
});
expect(validateSpy).not.toHaveBeenCalled();

// At 800ms after the last change, exactly one validation leaves —
// carrying the current document text.
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(validateSpy).toHaveBeenCalledTimes(1);
expect(validateSpy.mock.calls[0][1]).toContain("# edited\n");
});

it("test_live_validation_coalesces_rapid_typing", async () => {
renderModal();
const view = await readyEditorView();
vi.useFakeTimers();
validateSpy.mockResolvedValue({ valid: true, issues: [] });

// Three changes inside the debounce window coalesce into one request.
await act(async () => {
view.dispatch({ changes: { from: 0, insert: "a\n" } });
await vi.advanceTimersByTimeAsync(300);
view.dispatch({ changes: { from: 1, insert: "b\n" } });
await vi.advanceTimersByTimeAsync(300);
view.dispatch({ changes: { from: 2, insert: "c\n" } });
await vi.advanceTimersByTimeAsync(800);
});
expect(validateSpy).toHaveBeenCalledTimes(1);
expect(validateSpy.mock.calls[0][1]).toContain("c\n");
});

it("test_explicit_test_cancels_pending_debounce", async () => {
renderModal();
const view = await readyEditorView();
vi.useFakeTimers();
validateSpy.mockResolvedValue({ valid: true, issues: [] });

await act(async () => {
view.dispatch({ changes: { from: 0, insert: "# edited\n" } });
});
// The explicit Test button short-circuits the pending debounce.
await act(async () => {
screen.getByRole("button", { name: /test/i }).click();
await vi.advanceTimersByTimeAsync(0);
});
expect(validateSpy).toHaveBeenCalledTimes(1);

// The canceled timer must not re-fire later.
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(validateSpy).toHaveBeenCalledTimes(1);
});

it("test_live_validation_rechecks_after_in_flight_completes", async () => {
renderModal();
const view = await readyEditorView();
vi.useFakeTimers();
let resolveFirst!: (value: { valid: boolean; issues: never[] }) => void;
validateSpy.mockImplementationOnce(
() => new Promise((resolve) => { resolveFirst = resolve; }),
);

await act(async () => {
view.dispatch({ changes: { from: 0, insert: "x\n" } });
await vi.advanceTimersByTimeAsync(800);
});
expect(validateSpy).toHaveBeenCalledTimes(1); // in flight

// A second edit while validation is in flight must not be swallowed —
// it re-schedules and re-fires once the round trip lands.
await act(async () => {
view.dispatch({ changes: { from: 0, insert: "y\n" } });
await vi.advanceTimersByTimeAsync(800);
});
expect(validateSpy).toHaveBeenCalledTimes(1);

await act(async () => {
resolveFirst({ valid: true, issues: [] });
await vi.advanceTimersByTimeAsync(800);
});
expect(validateSpy).toHaveBeenCalledTimes(2);
});

it("test_live_validation_skipped_while_gate_parked", async () => {
// The compliance gate (invalid save confirm) is a blocking modal: no
// auto-validation churn behind it.
validateSpy.mockResolvedValue({
valid: false,
issues: [{ severity: "error", message: "boom" }],
});
const user = userEvent.setup();
renderModal();
const view = await readyEditorView();

await user.click(screen.getByRole("button", { name: /^save$/i }));
expect(await screen.findByRole("dialog", { name: /save with validation issues\?/i })).toBeTruthy();

vi.useFakeTimers();
validateSpy.mockResolvedValue({ valid: true, issues: [] });
await act(async () => {
view.dispatch({ changes: { from: 0, insert: "# edited\n" } });
await vi.advanceTimersByTimeAsync(2000);
});
// Only the save pre-validation ran — no live cycle behind the gate.
expect(validateSpy).toHaveBeenCalledTimes(1);
* Editor chrome tests (issues #98 + #99): the CodeMirror surface must be
* solid and token-backed (never the frosted modal glass), the gutter must
* be opaque, and the editor must sit inside a padded bordered card that
Expand Down
85 changes: 84 additions & 1 deletion src/profiles/web/src/components/ConfigEditorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ type PendingAction = "new" | "revert";
/** Kinds of the async toolbar actions — the dispatch runner's key. */
type ToolbarActionKind = "new" | "test" | "revert" | "apply" | "save" | "save-as";

/** Debounce window for live validation after the last document change (issue #100). */
const VALIDATION_DEBOUNCE_MS = 800;

/** Target chosen in the Save As dialog, handed back to the save-as flow. */
type SaveAsTarget = { filename: string; directory: string; overwrite: boolean };

Expand Down Expand Up @@ -193,6 +196,25 @@ export function ConfigEditorModal({ open, onClose, onApplied }: ConfigEditorModa
const actionSeqRef = useRef(0);
/** In-flight registry: action kind → seq of its live dispatch. A kind already registered rejects duplicate dispatches (double-click Apply fires one request). */
const inFlightActionsRef = useRef<Map<ToolbarActionKind, number>>(new Map());
/** Pending live-validation timer (issue #100); null when idle. */
const validationTimerRef = useRef<number | null>(null);
/**
* Doc text of the most recent validation request. The live cycle is
* scheduled only for texts differing from it — coalescing keystrokes
* into one request, and no-op revalidation of an unchanged document.
*/
const lastValidatedTextRef = useRef<string | null>(null);
/**
* True while a blocking dialog (compliance gate / Save As) sits behind
* the editor: no auto-validation churn behind a modal. Latest-ref so
* the CodeMirror listener closure (mounted once per doc) reads live.
*/
const parkedRef = useRef(false);
/** Latest-run indirection for the listener: always dispatches current. */
const runValidationRef = useRef<() => void>(() => {});
/** Latest ref for the scheduling helper itself (see updateListener). */
const scheduleValidationRef = useRef<(text: string) => void>(() => {});
parkedRef.current = gate !== null || saveAsPrompt !== null;

const handleDialogKeyDown = useModalA11y({
isOpen: open,
Expand All @@ -213,6 +235,13 @@ export function ConfigEditorModal({ open, onClose, onApplied }: ConfigEditorModa
setPendingAction(null);
setGate(null);
setSaveAsPrompt(null);
// Drop any pending live-validation cycle (issue #100): a stale timer
// must not fire into a closed session.
if (validationTimerRef.current !== null) {
clearTimeout(validationTimerRef.current);
validationTimerRef.current = null;
}
lastValidatedTextRef.current = null;
// Invalidate in-flight toolbar actions so a resolution that lands
// after close/reopen cannot leak into the next session's state.
actionSeqRef.current += 1;
Expand All @@ -225,6 +254,7 @@ export function ConfigEditorModal({ open, onClose, onApplied }: ConfigEditorModa
if (cancelled) return;
const content = toEditorText(data.content);
loadedTextRef.current = content;
lastValidatedTextRef.current = content;
setLoad({ status: "ready", data: { ...data, content } });
})
.catch((err: unknown) => {
Expand All @@ -237,6 +267,40 @@ export function ConfigEditorModal({ open, onClose, onApplied }: ConfigEditorModa
};
}, [open]);

/** Drop any pending live-validation cycle. */
const cancelPendingValidation = useCallback(() => {
if (validationTimerRef.current !== null) {
clearTimeout(validationTimerRef.current);
validationTimerRef.current = null;
}
}, []);

/**
* Schedule a live validation (issue #100): one deferred cycle
* ``VALIDATION_DEBOUNCE_MS`` after the last change. No-ops are elided:
* text identical to the last validation (coalesced typing), and the
* parked state (no auto-validation churn behind a modal). A cycle
* already in flight rejects duplicate dispatches — in that case the
* schedule RESCHEDULES instead of disappearing, so the latest text is
* still validated after the round trip lands.
*/
const scheduleLiveValidation = useCallback(
(text: string) => {
if (parkedRef.current) return;
if (text === lastValidatedTextRef.current) return;
cancelPendingValidation();
validationTimerRef.current = window.setTimeout(() => {
validationTimerRef.current = null;
if (inFlightActionsRef.current.has("test")) {
scheduleValidationRef.current(text);
return;
}
runValidationRef.current();
}, VALIDATION_DEBOUNCE_MS);
},
[cancelPendingValidation],
);

// Mount the CodeMirror view once the content is on screen; destroy it on
// close/unmount. Depends on `load` so the host div is guaranteed mounted.
useEffect(() => {
Expand All @@ -252,6 +316,12 @@ export function ConfigEditorModal({ open, onClose, onApplied }: ConfigEditorModa
EditorView.updateListener.of((update) => {
if (update.docChanged) {
setDirty(isDirty(update.view, loadedTextRef.current));
// Feedback is invalidated by the very edit that changed the
// document (issue #102): a stale Test/Save banner or issue
// list must never outlive the content it described.
setStatus(null);
setIssues([]);
scheduleLiveValidation(update.state.doc.toString());
}
}),
],
Expand All @@ -261,6 +331,9 @@ export function ConfigEditorModal({ open, onClose, onApplied }: ConfigEditorModa
return () => {
view.destroy();
editorViewRef.current = null;
// A pending live-validation timer would fire into a destroyed view —
// and leak across tests and sessions. Drop it with the mount.
cancelPendingValidation();
};
}, [open, load]);

Expand Down Expand Up @@ -399,8 +472,14 @@ export function ConfigEditorModal({ open, onClose, onApplied }: ConfigEditorModa
() =>
runToolbarAction("test", async (seq) => {
if (load.status !== "ready") return;
// An explicit Test cancels any pending live cycle (its result
// would describe the same document anyway) and records the text
// it is asking about, so the debounce skips a no-op repeat.
cancelPendingValidation();
const text = currentDocText();
lastValidatedTextRef.current = text;
try {
const result = await validateConfigContent(load.data.filename, currentDocText());
const result = await validateConfigContent(load.data.filename, text);
if (seq !== actionSeqRef.current) return; // superseded — drop stale results
setIssues(result.issues);
setStatus(
Expand All @@ -420,6 +499,10 @@ export function ConfigEditorModal({ open, onClose, onApplied }: ConfigEditorModa
[load, runToolbarAction],
);

/** Latest-run indirections for the CodeMirror listener closures. */
runValidationRef.current = () => void runValidation();
scheduleValidationRef.current = scheduleLiveValidation;

/**
* Apply action: trial the current text as the session configuration.
* Success fires `onApplied` with the applied AppConfigData; a 400
Expand Down
Loading