feat(FN-2075): merge fusion/fn-2075
This commit is contained in:
@@ -23,18 +23,19 @@ describe("terminal helper textarea CSS contract", () => {
|
||||
expect(ruleBody).toMatch(/height:\s*1px\b/);
|
||||
});
|
||||
|
||||
it("does not disable pointer events on the helper textarea", () => {
|
||||
it("anchors the helper textarea inside the terminal bounds", () => {
|
||||
const ruleBody = findHelperTextareaRule();
|
||||
expect(ruleBody).not.toMatch(/pointer-events\s*:\s*none\b/);
|
||||
expect(ruleBody).toMatch(/top:\s*0\b/);
|
||||
expect(ruleBody).toMatch(/left:\s*0\b/);
|
||||
});
|
||||
|
||||
it("positions the helper textarea off-screen", () => {
|
||||
it("prevents direct pointer interaction with the helper textarea", () => {
|
||||
const ruleBody = findHelperTextareaRule();
|
||||
expect(ruleBody).toMatch(/top:\s*-9999px\b/);
|
||||
expect(ruleBody).toMatch(/pointer-events\s*:\s*none\b/);
|
||||
});
|
||||
|
||||
it("keeps the helper textarea invisible", () => {
|
||||
it("keeps the helper textarea effectively invisible", () => {
|
||||
const ruleBody = findHelperTextareaRule();
|
||||
expect(ruleBody).toMatch(/opacity:\s*0\b/);
|
||||
expect(ruleBody).toMatch(/opacity:\s*0\.01\b/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -679,6 +679,34 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
}
|
||||
}, [connectionStatus]);
|
||||
|
||||
/**
|
||||
* On mobile browsers, opening the soft keyboard requires focus to happen
|
||||
* within a real user gesture. Programmatic focus in async effects is often
|
||||
* ignored even though xterm stays connected and receives output.
|
||||
*/
|
||||
const handleTerminalGestureFocus = useCallback(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
// Ensure xterm updates its own focus state first.
|
||||
xtermRef.current?.focus();
|
||||
|
||||
const helperTextarea = terminalRef.current.querySelector(
|
||||
".xterm-helper-textarea",
|
||||
) as HTMLTextAreaElement | undefined;
|
||||
|
||||
if (!helperTextarea) return;
|
||||
|
||||
try {
|
||||
helperTextarea.focus({ preventScroll: true });
|
||||
} catch {
|
||||
helperTextarea.focus();
|
||||
}
|
||||
|
||||
// Keep caret at end so subsequent key presses append naturally.
|
||||
const caretPos = helperTextarea.value.length;
|
||||
helperTextarea.setSelectionRange(caretPos, caretPos);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Auto-recover when the server reports the session is invalid (code 4004).
|
||||
*
|
||||
@@ -953,6 +981,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
|
||||
ref={terminalRef}
|
||||
className="terminal-xterm"
|
||||
data-testid="terminal-xterm"
|
||||
onPointerDown={handleTerminalGestureFocus}
|
||||
onTouchStart={handleTerminalGestureFocus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3602,6 +3602,45 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("focuses xterm helper textarea on user pointer gesture", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||||
const helperTextarea = document.createElement("textarea");
|
||||
helperTextarea.className = "xterm-helper-textarea";
|
||||
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||||
const setSelectionRangeSpy = vi.spyOn(helperTextarea, "setSelectionRange");
|
||||
terminalDiv.appendChild(helperTextarea);
|
||||
|
||||
fireEvent.pointerDown(terminalDiv);
|
||||
|
||||
expect(mockTerminalInstance.focus).toHaveBeenCalled();
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
expect(setSelectionRangeSpy).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
it("focuses xterm helper textarea on touch gesture", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||||
const helperTextarea = document.createElement("textarea");
|
||||
helperTextarea.className = "xterm-helper-textarea";
|
||||
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||||
terminalDiv.appendChild(helperTextarea);
|
||||
|
||||
fireEvent.touchStart(terminalDiv);
|
||||
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- FN-1765: Project-context propagation ---
|
||||
|
||||
@@ -9908,12 +9908,10 @@ body {
|
||||
}
|
||||
|
||||
/*
|
||||
* Override xterm's hidden textarea positioning so mobile browsers show the
|
||||
* virtual keyboard when the terminal is tapped. The upstream CSS sets
|
||||
* width:0, height:0 and left:-9999em which causes many mobile browsers to
|
||||
* skip showing the soft keyboard entirely. We give the textarea minimal
|
||||
* (1×1 px) dimensions and move it above the viewport instead of to the
|
||||
* left — this keeps it invisible while remaining a valid focus target.
|
||||
* Keep xterm's helper textarea tiny and hidden off-screen.
|
||||
*
|
||||
* The textarea is still focusable via pointer/touch handlers in TerminalModal,
|
||||
* which is required for mobile keyboard activation.
|
||||
*/
|
||||
.terminal-xterm .xterm .xterm-helper-textarea {
|
||||
left: 0 !important;
|
||||
@@ -9921,7 +9919,7 @@ body {
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
opacity: 0 !important;
|
||||
z-index: -1 !important;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.terminal-loading {
|
||||
|
||||
@@ -1597,6 +1597,219 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("retry-cleanup reset failure after attempt 1 is logged and merge continues to attempt 2", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
const warnSpy = vi.spyOn(mergerLog, "warn");
|
||||
const resetFailureMessage = "attempt-1 cleanup reset failed";
|
||||
let mergeSquashCalls = 0;
|
||||
let resetCalls = 0;
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
|
||||
if (cmdStr.includes("show --shortstat")) return "1 file changed, 1 insertion(+), 0 deletions(-)";
|
||||
|
||||
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
|
||||
mergeSquashCalls++;
|
||||
if (mergeSquashCalls === 1) {
|
||||
throw new Error("Merge conflict");
|
||||
}
|
||||
return Buffer.from("");
|
||||
}
|
||||
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "0";
|
||||
|
||||
if (cmdStr.includes("reset --merge")) {
|
||||
resetCalls++;
|
||||
if (resetCalls === 1) {
|
||||
throw new Error(resetFailureMessage);
|
||||
}
|
||||
return Buffer.from("");
|
||||
}
|
||||
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.attemptsMade).toBe(2);
|
||||
expect(mergeSquashCalls).toBe(2);
|
||||
|
||||
const cleanupWarnMessages = warnSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.filter((message) => message.includes("git reset --merge cleanup failed"));
|
||||
|
||||
expect(cleanupWarnMessages.some((message) => message.includes("during attempt 1"))).toBe(true);
|
||||
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("build-retry reset failure is logged when build verification fails", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
buildRetryCount: 1,
|
||||
verificationFixRetries: 0,
|
||||
});
|
||||
|
||||
const warnSpy = vi.spyOn(mergerLog, "warn");
|
||||
const buildFailureMessage = "Build verification failed: tsc error";
|
||||
const resetFailureMessage = "build-retry reset failed";
|
||||
let resetCalls = 0;
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
|
||||
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "";
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1";
|
||||
|
||||
if (cmdStr.includes("reset --merge")) {
|
||||
resetCalls++;
|
||||
if (resetCalls === 1) {
|
||||
throw new Error(resetFailureMessage);
|
||||
}
|
||||
return Buffer.from("");
|
||||
}
|
||||
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockRejectedValue(new Error(buildFailureMessage)),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect((thrown as Error).message).toContain(buildFailureMessage);
|
||||
expect((thrown as Error).message).not.toContain(resetFailureMessage);
|
||||
|
||||
const cleanupWarnMessages = warnSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.filter((message) => message.includes("git reset --merge cleanup failed"));
|
||||
|
||||
expect(cleanupWarnMessages.some((message) => message.includes("build-retry"))).toBe(true);
|
||||
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("error-path retry cleanup reset failure is logged and merge still retries", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
const warnSpy = vi.spyOn(mergerLog, "warn");
|
||||
const resetFailureMessage = "retry cleanup reset failed";
|
||||
let mergeSquashCalls = 0;
|
||||
let resetCalls = 0;
|
||||
let usedTheirsStrategy = false;
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed";
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)";
|
||||
|
||||
if (cmdStr.includes("merge --squash") && !cmdStr.includes("-X")) {
|
||||
mergeSquashCalls++;
|
||||
if (mergeSquashCalls === 1) {
|
||||
throw new Error("Merge conflict");
|
||||
}
|
||||
return Buffer.from("");
|
||||
}
|
||||
|
||||
if (cmdStr.includes("merge -X theirs --squash")) {
|
||||
usedTheirsStrategy = true;
|
||||
return Buffer.from("");
|
||||
}
|
||||
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) {
|
||||
if (!usedTheirsStrategy && mergeSquashCalls === 2) {
|
||||
return "src/complex.ts\n";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
if (cmdStr.includes("diff-tree")) {
|
||||
const error = new Error("exit code 1") as any;
|
||||
error.stdout = "+const value = 2;\n-const value = 1;";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1";
|
||||
|
||||
if (cmdStr.includes("git commit")) return Buffer.from("");
|
||||
|
||||
if (cmdStr.includes("reset --merge")) {
|
||||
resetCalls++;
|
||||
if (resetCalls === 2) {
|
||||
throw new Error(resetFailureMessage);
|
||||
}
|
||||
return Buffer.from("");
|
||||
}
|
||||
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockRejectedValue(new Error("Agent failed on attempt 2")),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.attemptsMade).toBe(3);
|
||||
expect(mergeSquashCalls).toBe(2);
|
||||
|
||||
const cleanupWarnMessages = warnSpy.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.filter((message) => message.includes("git reset --merge cleanup failed"));
|
||||
|
||||
expect(cleanupWarnMessages.some((message) => message.includes("retry cleanup (attempt 2)"))).toBe(true);
|
||||
expect(cleanupWarnMessages.some((message) => message.includes(resetFailureMessage))).toBe(true);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("tracks resolutionStrategy as 'ai' when attempt 1 succeeds even with autoResolve enabled", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
|
||||
@@ -1546,7 +1546,10 @@ export async function aiMergeTask(
|
||||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||
// Audit trail: record git reset for merge cleanup (FN-1404)
|
||||
await audit.git({ type: "reset:hard", target: branch, metadata: { purpose: "merge-cleanup", attempt: attemptNum } });
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`${taskId}: git reset --merge cleanup failed during attempt ${attemptNum}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1663,7 +1666,10 @@ export async function aiMergeTask(
|
||||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||
// Audit trail: record git reset for build retry (FN-1404)
|
||||
await audit.git({ type: "reset:hard", target: branch, metadata: { purpose: "build-retry" } });
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`${taskId}: git reset --merge cleanup failed during build-retry: ${msg}`);
|
||||
}
|
||||
return false; // Retry
|
||||
}
|
||||
throw error; // No retries left — fatal
|
||||
@@ -1676,7 +1682,10 @@ export async function aiMergeTask(
|
||||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||
// Audit trail: record git reset for retry (FN-1404)
|
||||
await audit.git({ type: "reset:hard", target: branch, metadata: { purpose: "merge-retry", attempt: attemptNum } });
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`${taskId}: git reset --merge cleanup failed during retry cleanup (attempt ${attemptNum}): ${msg}`);
|
||||
}
|
||||
return false; // Allow retry
|
||||
}
|
||||
throw error; // Last attempt or auto-resolve disabled - propagate error
|
||||
|
||||
Reference in New Issue
Block a user