Merge develop into main
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful

Consolidate all feature and fix branches into main:
- feat: orchestrator observability + mosaic rails integration (#422)
- fix: post-422 CI and compose env follow-up (#423)
- fix: orchestrator startup provider-key requirements (#425)
- fix: BetterAuth OAuth2 flow and compose wiring (#426)
- fix: BetterAuth UUID ID generation (#427)
- test: web vitest localStorage/file warnings (#428)
- fix: auth frontend remediation + review hardening (#421)
- Plus numerous Docker, deploy, and auth fixes from develop

Lockfile conflict resolved by regenerating from merged package.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 14:40:55 -06:00
142 changed files with 5418 additions and 3594 deletions

View File

@@ -56,6 +56,15 @@ export function LinkAutocomplete({
const mirrorRef = useRef<HTMLDivElement | null>(null);
const cursorSpanRef = useRef<HTMLSpanElement | null>(null);
// Refs for event handler to avoid stale closures when effects re-attach listeners
const stateRef = useRef(state);
const resultsRef = useRef(results);
const selectedIndexRef = useRef(selectedIndex);
const insertLinkRef = useRef<((result: SearchResult) => void) | null>(null);
stateRef.current = state;
resultsRef.current = results;
selectedIndexRef.current = selectedIndex;
/**
* Search for knowledge entries matching the query.
* Accepts an AbortSignal to allow cancellation of in-flight requests,
@@ -254,47 +263,48 @@ export function LinkAutocomplete({
}, [textareaRef, state.isOpen, calculateDropdownPosition, debouncedSearch]);
/**
* Handle keyboard navigation in the dropdown
* Handle keyboard navigation in the dropdown.
* Reads from refs to avoid stale closures when the effect
* that attaches this listener hasn't re-run yet.
*/
const handleKeyDown = useCallback(
(e: KeyboardEvent): void => {
if (!state.isOpen) return;
const handleKeyDown = useCallback((e: KeyboardEvent): void => {
if (!stateRef.current.isOpen) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % results.length);
break;
const currentResults = resultsRef.current;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
break;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % currentResults.length);
break;
case "Enter":
e.preventDefault();
if (results.length > 0 && selectedIndex >= 0) {
const selected = results[selectedIndex];
if (selected) {
insertLink(selected);
}
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + currentResults.length) % currentResults.length);
break;
case "Enter":
e.preventDefault();
if (currentResults.length > 0 && selectedIndexRef.current >= 0) {
const selected = currentResults[selectedIndexRef.current];
if (selected) {
insertLinkRef.current?.(selected);
}
break;
}
break;
case "Escape":
e.preventDefault();
setState({
isOpen: false,
query: "",
position: { top: 0, left: 0 },
triggerIndex: -1,
});
setResults([]);
break;
}
},
[state.isOpen, results, selectedIndex]
);
case "Escape":
e.preventDefault();
setState({
isOpen: false,
query: "",
position: { top: 0, left: 0 },
triggerIndex: -1,
});
setResults([]);
break;
}
}, []);
/**
* Insert the selected link into the textarea
@@ -330,6 +340,7 @@ export function LinkAutocomplete({
},
[textareaRef, state.triggerIndex, onInsert]
);
insertLinkRef.current = insertLink;
/**
* Handle click on a result

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import React from "react";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
@@ -352,10 +351,7 @@ describe("LinkAutocomplete", (): void => {
vi.useRealTimers();
});
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
it.skip("should perform debounced search when typing query", async (): Promise<void> => {
vi.useFakeTimers();
it("should perform debounced search when typing query", async (): Promise<void> => {
const mockResults = {
data: [
{
@@ -395,11 +391,6 @@ describe("LinkAutocomplete", (): void => {
// Should not call API immediately
expect(mockApiRequest).not.toHaveBeenCalled();
// Fast-forward 300ms and let promises resolve
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(mockApiRequest).toHaveBeenCalledWith(
"/api/knowledge/search?q=test&limit=10",
@@ -411,14 +402,9 @@ describe("LinkAutocomplete", (): void => {
await waitFor(() => {
expect(screen.getByText("Test Entry")).toBeInTheDocument();
});
vi.useRealTimers();
});
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
it.skip("should navigate results with arrow keys", async (): Promise<void> => {
vi.useFakeTimers();
it("should navigate results with arrow keys", async (): Promise<void> => {
const mockResults = {
data: [
{
@@ -471,10 +457,6 @@ describe("LinkAutocomplete", (): void => {
fireEvent.input(textarea);
});
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(screen.getByText("Entry One")).toBeInTheDocument();
});
@@ -484,7 +466,9 @@ describe("LinkAutocomplete", (): void => {
expect(firstItem).toHaveClass("bg-blue-50");
// Press ArrowDown
fireEvent.keyDown(textarea, { key: "ArrowDown" });
act(() => {
fireEvent.keyDown(textarea, { key: "ArrowDown" });
});
// Second item should now be selected
await waitFor(() => {
@@ -493,21 +477,18 @@ describe("LinkAutocomplete", (): void => {
});
// Press ArrowUp
fireEvent.keyDown(textarea, { key: "ArrowUp" });
act(() => {
fireEvent.keyDown(textarea, { key: "ArrowUp" });
});
// First item should be selected again
await waitFor(() => {
const firstItem = screen.getByText("Entry One").closest("li");
expect(firstItem).toHaveClass("bg-blue-50");
});
vi.useRealTimers();
});
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
it.skip("should insert link on Enter key", async (): Promise<void> => {
vi.useFakeTimers();
it("should insert link on Enter key", async (): Promise<void> => {
const mockResults = {
data: [
{
@@ -544,10 +525,6 @@ describe("LinkAutocomplete", (): void => {
fireEvent.input(textarea);
});
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(screen.getByText("Test Entry")).toBeInTheDocument();
});
@@ -558,14 +535,9 @@ describe("LinkAutocomplete", (): void => {
await waitFor(() => {
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
});
vi.useRealTimers();
});
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
it.skip("should insert link on click", async (): Promise<void> => {
vi.useFakeTimers();
it("should insert link on click", async (): Promise<void> => {
const mockResults = {
data: [
{
@@ -602,10 +574,6 @@ describe("LinkAutocomplete", (): void => {
fireEvent.input(textarea);
});
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(screen.getByText("Test Entry")).toBeInTheDocument();
});
@@ -616,14 +584,9 @@ describe("LinkAutocomplete", (): void => {
await waitFor(() => {
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
});
vi.useRealTimers();
});
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
it.skip("should close dropdown on Escape key", async (): Promise<void> => {
vi.useFakeTimers();
it("should close dropdown on Escape key", async (): Promise<void> => {
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
@@ -636,28 +599,19 @@ describe("LinkAutocomplete", (): void => {
fireEvent.input(textarea);
});
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
expect(screen.getByText("↑↓ Navigate • Enter Select • Esc Cancel")).toBeInTheDocument();
});
// Press Escape
fireEvent.keyDown(textarea, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
expect(screen.queryByText("↑↓ Navigate • Enter Select • Esc Cancel")).not.toBeInTheDocument();
});
vi.useRealTimers();
});
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
it.skip("should close dropdown when closing brackets are typed", async (): Promise<void> => {
vi.useFakeTimers();
it("should close dropdown when closing brackets are typed", async (): Promise<void> => {
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
@@ -670,12 +624,8 @@ describe("LinkAutocomplete", (): void => {
fireEvent.input(textarea);
});
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
expect(screen.getByText("↑↓ Navigate • Enter Select • Esc Cancel")).toBeInTheDocument();
});
// Type closing brackets
@@ -686,16 +636,11 @@ describe("LinkAutocomplete", (): void => {
});
await waitFor(() => {
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
expect(screen.queryByText("↑↓ Navigate • Enter Select • Esc Cancel")).not.toBeInTheDocument();
});
vi.useRealTimers();
});
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
it.skip("should show 'No entries found' when search returns no results", async (): Promise<void> => {
vi.useFakeTimers();
it("should show 'No entries found' when search returns no results", async (): Promise<void> => {
mockApiRequest.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
@@ -713,32 +658,24 @@ describe("LinkAutocomplete", (): void => {
fireEvent.input(textarea);
});
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(screen.getByText("No entries found")).toBeInTheDocument();
});
vi.useRealTimers();
});
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
it.skip("should show loading state while searching", async (): Promise<void> => {
vi.useFakeTimers();
it("should show loading state while searching", async (): Promise<void> => {
// Mock a slow API response
let resolveSearch: (value: unknown) => void;
const searchPromise = new Promise((resolve) => {
let resolveSearch: (value: {
data: unknown[];
meta: { total: number; page: number; limit: number; totalPages: number };
}) => void = () => undefined;
const searchPromise = new Promise<{
data: unknown[];
meta: { total: number; page: number; limit: number; totalPages: number };
}>((resolve) => {
resolveSearch = resolve;
});
mockApiRequest.mockReturnValue(
searchPromise as Promise<{
data: unknown[];
meta: { total: number; page: number; limit: number; totalPages: number };
}>
);
mockApiRequest.mockReturnValue(searchPromise);
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
@@ -752,16 +689,12 @@ describe("LinkAutocomplete", (): void => {
fireEvent.input(textarea);
});
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(screen.getByText("Searching...")).toBeInTheDocument();
});
// Resolve the search
resolveSearch!({
resolveSearch({
data: [],
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
});
@@ -769,14 +702,9 @@ describe("LinkAutocomplete", (): void => {
await waitFor(() => {
expect(screen.queryByText("Searching...")).not.toBeInTheDocument();
});
vi.useRealTimers();
});
// TODO: Fix async/timer interaction - component works but test has timing issues with fake timers
it.skip("should display summary preview for entries", async (): Promise<void> => {
vi.useFakeTimers();
it("should display summary preview for entries", async (): Promise<void> => {
const mockResults = {
data: [
{
@@ -813,14 +741,8 @@ describe("LinkAutocomplete", (): void => {
fireEvent.input(textarea);
});
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(screen.getByText("This is a helpful summary")).toBeInTheDocument();
});
vi.useRealTimers();
});
});