FN-8533: add mobile planning comment actions
Make contextual plan comments reachable from the mobile action rail and restore focus after editing. - Add responsive desktop and mobile comment triggers with contextual styling and documentation. - Preserve the selected quote and restore the remounted trigger after canceling or adding a comment. - Cover action placement, focus restoration, and browser interaction behavior. Files changed: .changeset/fn-8533-mobile-planning-comments.md | 7 ++ docs/dashboard-guide.md | 2 +- .../dashboard/app/components/PlanningModeModal.css | 20 ++++ .../dashboard/app/components/PlanningModeModal.tsx | 44 ++++++- .../__tests__/PlanningModeModal.css.test.ts | 10 ++ .../PlanningModeModal.planning-flow.test.tsx | 40 +++++-- .../PlanningModeModal.ui-interactions.test.tsx | 5 + .../dashboard/app/planning-browser-e2e-fixture.tsx | 3 +- .../src/__tests__/planning-browser-e2e.test.ts | 133 +++++++++++++++++++-- packages/dashboard/vitest.config.ts | 11 +- scripts/lib/test-quarantine.json | 5 - 11 files changed, 242 insertions(+), 38 deletions(-) Fusion-Task-Id: FN-8533 Fusion-Task-Lineage: e0d561be-ecc8-456e-807e-a1dc677b5d9c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8533-mobile-planning-comments.md
Normal file
7
.changeset/fn-8533-mobile-planning-comments.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep Planning Mode selected-text comments reachable in the mobile action rail.
|
||||
category: fix
|
||||
dev: Mobile uses the plan footer at up to 768px; wider layouts retain the selection-adjacent trigger.
|
||||
@@ -2257,4 +2257,4 @@ Custom workflow authors can add optional explanatory copy beneath each column na
|
||||
|
||||
## Planning Mode contextual comments
|
||||
|
||||
In plan review, select text inside the rendered plan and choose **Add comment to selection**. Enter a suggestion to capture the selected quote and suggestion as a pending contextual comment. You can remove individual comments before choosing **Submit comments**; Fusion sends the ordered batch through the existing Planning Mode revision generation, so the agent revises the quoted areas while preserving unaffected plan content. A successful revised-plan update clears the batch; a failed submission retains it for retry.
|
||||
In plan review, select text inside the rendered plan and choose **Add comment to selection**. On mobile widths through 768px, the selection action appears in the bottom plan-action rail beside **Refine** and **Proceed with plan**; at 769px and wider it stays beside the selected plan content. Enter a suggestion to capture the selected quote and suggestion as a pending contextual comment. You can remove individual comments before choosing **Submit comments**; Fusion sends the ordered batch through the existing Planning Mode revision generation, so the agent revises the quoted areas while preserving unaffected plan content. A successful revised-plan update clears the batch; a failed submission retains it for retry.
|
||||
|
||||
@@ -1774,6 +1774,10 @@ its established Refine and Proceed action hierarchy.
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.planning-add-comment--mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.planning-comment-editor,
|
||||
.planning-comment-tray {
|
||||
display: flex;
|
||||
@@ -1880,6 +1884,22 @@ Refine/Proceed actions share one compact bottom baseline; equal footer/button he
|
||||
plan actions, and a token-sized bottom inset keep all three controls inline without extra space.
|
||||
*/
|
||||
@media (max-width: 768px) {
|
||||
/*
|
||||
FNXC:PlanningComments 2026-07-31-00:00:
|
||||
A selected quote needs a reachable mobile action beside Refine and Proceed, not a control lost
|
||||
in the scrollable document. At 769px and above the selection-adjacent document trigger stays
|
||||
canonical. These established 768px/1024px media boundaries are permitted; new dimensions use
|
||||
only existing design tokens.
|
||||
*/
|
||||
.planning-add-comment--document {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.planning-add-comment--mobile {
|
||||
display: inline-flex;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.planning-comment-tray li {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
@@ -667,6 +667,24 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const planDocumentRef = useRef<HTMLDivElement>(null);
|
||||
const commentInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const addCommentTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const mobileAddCommentTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const restoreCommentTriggerFocusRef = useRef(false);
|
||||
|
||||
const focusAddCommentTrigger = useCallback(() => {
|
||||
const isMobile = window.matchMedia?.("(max-width: 768px)").matches ?? false;
|
||||
(isMobile ? mobileAddCommentTriggerRef : addCommentTriggerRef).current?.focus();
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:PlanningComments 2026-07-23-09:30:
|
||||
Closing the conditional comment editor unmounts its trigger during the state transition. Restore focus only in the post-render effect, after Cancel or Add comment remounts the desktop or mobile trigger.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isCommentEditorOpen && restoreCommentTriggerFocusRef.current) {
|
||||
restoreCommentTriggerFocusRef.current = false;
|
||||
focusAddCommentTrigger();
|
||||
}
|
||||
}, [focusAddCommentTrigger, isCommentEditorOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// A batch belongs to one visible session; never carry comments into another plan.
|
||||
@@ -2617,10 +2635,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
if (!quote || !suggestion) return;
|
||||
setContextualComments((comments) => [...comments, { quote, suggestion }]);
|
||||
setCommentDraft("");
|
||||
setSelectedPlanQuote(null);
|
||||
// FNXC:PlanningComments 2026-07-23-09:30: Keep the quote while closing the editor so its conditional trigger remounts and can receive restored focus after adding a comment.
|
||||
restoreCommentTriggerFocusRef.current = true;
|
||||
setIsCommentEditorOpen(false);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
addCommentTriggerRef.current?.focus();
|
||||
}, [commentDraft, selectedPlanQuote]);
|
||||
|
||||
const handleSubmitContextualComments = useCallback(async () => {
|
||||
@@ -2909,7 +2927,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
<button
|
||||
ref={addCommentTriggerRef}
|
||||
type="button"
|
||||
className="btn planning-add-comment"
|
||||
className="btn planning-add-comment planning-add-comment--document"
|
||||
onClick={() => setIsCommentEditorOpen(true)}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
@@ -2924,7 +2942,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
<textarea ref={commentInputRef} className="input" value={commentDraft} onChange={(event) => setCommentDraft(event.target.value)} />
|
||||
</label>
|
||||
<div className="planning-refine-menu-actions">
|
||||
<button type="button" className="btn" onClick={() => { setCommentDraft(""); setIsCommentEditorOpen(false); addCommentTriggerRef.current?.focus(); }}>{t("common.cancel", "Cancel")}</button>
|
||||
<button type="button" className="btn" onClick={() => { setCommentDraft(""); restoreCommentTriggerFocusRef.current = true; setIsCommentEditorOpen(false); }}>{t("common.cancel", "Cancel")}</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!commentDraft.trim()} onClick={handleAddContextualComment}>{t("planning.addComment", "Add comment")}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2932,6 +2950,24 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</article>
|
||||
</div>
|
||||
<div className="planning-actions planning-summary-actions planning-plan-actions" data-testid="planning-plan-actions">
|
||||
{/*
|
||||
FNXC:PlanningComments 2026-07-31-00:00:
|
||||
FN-8533 keeps the selection-adjacent control at 769px and wider, but mobile's reachable
|
||||
action rail owns its counterpart. The two variants share the same editor transition and
|
||||
CSS makes exactly one visible/focusable; only established 768px/1024px breakpoint literals
|
||||
are allowed here, while all other dimensions remain design-token based.
|
||||
*/}
|
||||
{selectedPlanQuote && !isCommentEditorOpen && (
|
||||
<button
|
||||
ref={mobileAddCommentTriggerRef}
|
||||
type="button"
|
||||
className="btn planning-add-comment planning-add-comment--mobile"
|
||||
onClick={() => setIsCommentEditorOpen(true)}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
{t("planning.addComment", "Add comment to selection")}
|
||||
</button>
|
||||
)}
|
||||
{contextualComments.length > 0 && (
|
||||
<div className="planning-comment-tray" data-testid="planning-comment-tray">
|
||||
<ul>
|
||||
|
||||
@@ -166,6 +166,16 @@ describe("PlanningModeModal CSS responsive action contract", () => {
|
||||
expectSomeRule(tabletCss, ".planning-plan-actions", /flex-wrap\s*:\s*nowrap\s*;/);
|
||||
});
|
||||
|
||||
it("shows exactly one contextual comment trigger in the mobile plan footer", () => {
|
||||
const css = loadPlanningCss();
|
||||
const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n");
|
||||
|
||||
expect(findRule(css, ".planning-add-comment--mobile")).toMatch(/display\s*:\s*none\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-add-comment--document")).toMatch(/display\s*:\s*none\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-add-comment--mobile")).toMatch(/display\s*:\s*inline-flex\s*;/);
|
||||
expect(findRule(mobileCss, ".planning-add-comment--mobile")).toMatch(/margin-top\s*:\s*0\s*;/);
|
||||
});
|
||||
|
||||
it("keeps the mobile sessions list scrolling above the bottom-pinned New session footer", () => {
|
||||
const css = loadPlanningCss();
|
||||
const mobileShellCss = getMediaBlocks(css, MOBILE_PLANNING_SHELL_QUERY).join("\n");
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock("../../api", () => {
|
||||
});
|
||||
|
||||
const base = { id: "session-1", title: "Secure plan", projectId: "project-1", updatedAt: new Date().toISOString(), archived: false, conversationHistory: "[]", thinkingOutput: "" };
|
||||
function renderSession() { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />); }
|
||||
function renderSession(sessionId = "session-1") { return render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" resumeSessionId={sessionId} />); }
|
||||
const summaryWithRefinements = {
|
||||
...mockSummary,
|
||||
description: "Build a **reviewed** recovery workflow with an operator [runbook](https://example.com/runbook).",
|
||||
@@ -71,8 +71,10 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
subsequently reports its durable error dispatch the existing retry endpoint automatically.
|
||||
*/
|
||||
it("automatically retries a persisted error when returning to Planning", async () => {
|
||||
const sessionId = "persisted-error-session";
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
id: sessionId,
|
||||
status: "error",
|
||||
error: "The planning stream was interrupted",
|
||||
currentQuestion: null,
|
||||
@@ -80,25 +82,28 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
inputPayload: "{}",
|
||||
});
|
||||
|
||||
renderSession();
|
||||
renderSession(sessionId);
|
||||
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-1", "project-1"));
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith(sessionId, "project-1"));
|
||||
expect(screen.queryByText("The planning stream was interrupted")).toBeNull();
|
||||
});
|
||||
|
||||
it("automatically retries a stream error after returning to a generating session", async () => {
|
||||
const sessionId = "resumed-stream-error-session";
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
id: sessionId,
|
||||
status: "generating",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: JSON.stringify({ generationPurpose: "plan_update" }),
|
||||
});
|
||||
renderSession();
|
||||
renderSession(sessionId);
|
||||
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
|
||||
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
id: sessionId,
|
||||
status: "error",
|
||||
error: "The resumed stream failed",
|
||||
currentQuestion: null,
|
||||
@@ -107,13 +112,15 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
});
|
||||
mockConnectPlanningStream.mock.calls[0]?.[2]?.onError?.("The resumed stream failed");
|
||||
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-1", "project-1"));
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledWith(sessionId, "project-1"));
|
||||
expect(screen.queryByText("The resumed stream failed")).toBeNull();
|
||||
});
|
||||
|
||||
it("retries all bounded attempts before surfacing a returned stream error", async () => {
|
||||
const sessionId = "bounded-retry-session";
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
id: sessionId,
|
||||
status: "error",
|
||||
error: "The planning stream was interrupted",
|
||||
currentQuestion: null,
|
||||
@@ -122,7 +129,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
});
|
||||
mockRetryPlanningSession.mockRejectedValue(new Error("Temporary retry outage"));
|
||||
|
||||
renderSession();
|
||||
renderSession(sessionId);
|
||||
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(3));
|
||||
expect(await screen.findByText("Temporary retry outage")).toBeInTheDocument();
|
||||
@@ -134,19 +141,21 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
});
|
||||
|
||||
it("coalesces overlapping stream errors into one retry request", async () => {
|
||||
const sessionId = "coalesced-retry-session";
|
||||
let resolveRetry!: (value: { success: true }) => void;
|
||||
mockRetryPlanningSession.mockReturnValue(new Promise((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
}));
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
id: sessionId,
|
||||
status: "error",
|
||||
error: "The planning stream was interrupted",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
renderSession();
|
||||
renderSession(sessionId);
|
||||
await waitFor(() => expect(mockRetryPlanningSession).toHaveBeenCalledTimes(1));
|
||||
|
||||
mockConnectPlanningStream.mock.calls[0]?.[2]?.onError?.("Duplicate stream error");
|
||||
@@ -228,21 +237,24 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
});
|
||||
|
||||
it("automatically retries a resumed error discovered by the loading poll", async () => {
|
||||
const sessionId = "polled-error-session";
|
||||
const intervalSpy = vi.spyOn(globalThis, "setInterval");
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
id: sessionId,
|
||||
status: "generating",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: JSON.stringify({ generationPurpose: "plan_update" }),
|
||||
});
|
||||
renderSession();
|
||||
renderSession(sessionId);
|
||||
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledTimes(1));
|
||||
|
||||
const poll = intervalSpy.mock.calls.find(([, delay]) => delay === 8000)?.[0];
|
||||
expect(poll).toBeTypeOf("function");
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
...base,
|
||||
id: sessionId,
|
||||
status: "error",
|
||||
error: "Poll observed stream error",
|
||||
currentQuestion: null,
|
||||
@@ -253,7 +265,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
await (poll as () => Promise<void>)();
|
||||
});
|
||||
|
||||
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-1", "project-1");
|
||||
expect(mockRetryPlanningSession).toHaveBeenCalledWith(sessionId, "project-1");
|
||||
expect(screen.queryByText("Poll observed stream error")).toBeNull();
|
||||
intervalSpy.mockRestore();
|
||||
});
|
||||
@@ -322,6 +334,15 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
fireEvent.mouseUp(documentNode);
|
||||
};
|
||||
selectQuote("Build authentication system");
|
||||
const actionBar = screen.getByTestId("planning-plan-actions");
|
||||
const documentTrigger = document.querySelector(".planning-add-comment--document");
|
||||
const mobileTrigger = document.querySelector(".planning-add-comment--mobile");
|
||||
expect(documentTrigger?.closest(".planning-plan-document")).toContainElement(documentTrigger);
|
||||
expect(actionBar).toContainElement(mobileTrigger);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add comment to selection" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(document.activeElement).toBe(document.querySelector(".planning-add-comment--document")));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add comment to selection" }));
|
||||
const suggestionInput = screen.getByLabelText("Suggestion");
|
||||
fireEvent.change(suggestionInput, { target: { value: "Explain the audit path." } });
|
||||
@@ -329,6 +350,7 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
suggestionInput.setSelectionRange(0, suggestionInput.value.length);
|
||||
fireEvent.mouseUp(suggestionInput);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add comment" }));
|
||||
await waitFor(() => expect(document.activeElement).toBe(document.querySelector(".planning-add-comment--document")));
|
||||
expect(screen.getByTestId("planning-comment-tray")).toHaveTextContent("Explain the audit path.");
|
||||
expect(screen.getByRole("button", { name: "Refine" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
|
||||
|
||||
@@ -17,6 +17,9 @@ describe("PlanningModeModal sequential layout", () => {
|
||||
expect(component).toContain("root.contains(selection.anchorNode)");
|
||||
expect(component).toContain("root.contains(selection.focusNode)");
|
||||
expect(component).toContain("Add comment to selection");
|
||||
expect(component).toContain("planning-add-comment--document");
|
||||
expect(component).toContain("planning-add-comment--mobile");
|
||||
expect(component).toContain("mobileAddCommentTriggerRef");
|
||||
expect(component).toContain("contextualComments");
|
||||
expect(component).toContain("setContextualComments([])");
|
||||
});
|
||||
@@ -28,5 +31,7 @@ describe("PlanningModeModal sequential layout", () => {
|
||||
expect(css).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.planning-plan-actions\s*\{[^}]*display\s*:\s*grid\s*;[^}]*grid-template-columns\s*:\s*repeat\(2, minmax\(0, 1fr\)\)\s*;[^}]*gap\s*:\s*var\(--space-md\)\s*;[^}]*calc\(var\(--space-sm\) \+ env\(safe-area-inset-bottom\)\)/);
|
||||
expect(css).toMatch(/@media \(max-width: 1024px\)[\s\S]*?\.planning-plan-actions \.btn\s*\{[^}]*width\s*:\s*100%\s*;/);
|
||||
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-plan-actions\s*\{[^}]*gap\s*:\s*var\(--space-md\)\s*;[^}]*calc\(var\(--space-sm\) \+ env\(safe-area-inset-bottom\)\)/);
|
||||
expect(css).toMatch(/\.planning-add-comment--mobile\s*\{[^}]*display\s*:\s*none\s*;/);
|
||||
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.planning-add-comment--document\s*\{[^}]*display\s*:\s*none\s*;[\s\S]*?\.planning-add-comment--mobile\s*\{[^}]*display\s*:\s*inline-flex\s*;/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ const summary = {
|
||||
const fixtureParams = new URLSearchParams(window.location.search);
|
||||
if (fixtureParams.has("reset")) localStorage.clear();
|
||||
const showPlanReview = fixtureParams.get("surface") === "plan-review";
|
||||
const presentation = fixtureParams.get("presentation") === "modal" ? "modal" : "embedded";
|
||||
|
||||
const questions = [
|
||||
{
|
||||
@@ -149,7 +150,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
onTaskCreated={(task) => { document.body.dataset.createdTask = task.id; }}
|
||||
onTasksCreated={() => undefined}
|
||||
tasks={[]}
|
||||
presentation="embedded"
|
||||
presentation={presentation}
|
||||
resumeSessionId={showPlanReview ? "planning-browser-e2e" : undefined}
|
||||
/>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -25,6 +25,8 @@ type Page = {
|
||||
waitForTimeout(timeout: number): Promise<void>;
|
||||
evaluate<T>(pageFunction: () => T): Promise<T>;
|
||||
on(event: "console" | "pageerror", handler: (event: { text?(): string; message?: string }) => void): void;
|
||||
screenshot(options: { path: string }): Promise<void>;
|
||||
keyboard: { press(key: string): Promise<void> };
|
||||
};
|
||||
type Locator = {
|
||||
getByRole(role: string, options: { name: string | RegExp }): Locator;
|
||||
@@ -60,7 +62,12 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createServer({ root: process.cwd(), server: { host: "127.0.0.1", port: 0 }, logLevel: "error" });
|
||||
/*
|
||||
FNXC:PlanningModeBrowserE2E 2026-07-31-09:05:
|
||||
This static fixture has no reload contract. Disable file watching so an fsevents watcher cannot
|
||||
outlive Chromium and block the required responsive browser lane during teardown.
|
||||
*/
|
||||
server = await createServer({ root: process.cwd(), server: { host: "127.0.0.1", port: 0, watch: null }, logLevel: "error" });
|
||||
await server.listen();
|
||||
baseUrl = server.resolvedUrls?.local[0] ?? "";
|
||||
browser = await chromium.launch({ executablePath, headless: true });
|
||||
@@ -68,13 +75,18 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
// Vite's close() also awaits its module graph workers, which are not part of this
|
||||
// browser assertion and can remain alive after the fixture's mocked SSE channel.
|
||||
// Close the actual listening socket and HMR channel directly instead.
|
||||
server?.ws.close();
|
||||
server?.httpServer?.closeAllConnections?.();
|
||||
/*
|
||||
FNXC:PlanningModeBrowserE2E 2026-07-31-09:05:
|
||||
Bound watcher shutdown prevents a native fsevents close callback from holding this mandatory
|
||||
browser lane open after the fixture listener is gone, while still releasing it before Vitest exits.
|
||||
*/
|
||||
await Promise.race([
|
||||
server.watcher.close(),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
|
||||
]);
|
||||
server.ws.close();
|
||||
server.httpServer?.closeAllConnections?.();
|
||||
await new Promise<void>((resolve, reject) => server.httpServer?.close((error) => error ? reject(error) : resolve()));
|
||||
await server.watcher.close();
|
||||
await server.pluginContainer.close();
|
||||
}, 10_000);
|
||||
|
||||
@@ -101,8 +113,9 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
async function verifyResponsiveWorkspace(viewport: { width: number; height: number }, mobile: boolean): Promise<void> {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto(`${baseUrl}app/planning-browser-e2e-fixture.html?surface=plan-review&reset=1`);
|
||||
if (mobile) await page.getByRole("tab", { name: "Plan preview" }).click();
|
||||
await expectVisible(page.locator("[data-testid='planning-plan-markdown'] h1"));
|
||||
await expectVisible(page.getByText("Which user outcome matters most?"));
|
||||
if (!mobile) await expectVisible(page.getByText("Which user outcome matters most?"));
|
||||
await expectVisible(page.getByRole("button", { name: "Proceed with plan" }));
|
||||
|
||||
const layout = await page.evaluate(() => {
|
||||
@@ -141,9 +154,9 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
|
||||
expect(layout).toMatchObject({
|
||||
planVisible: true,
|
||||
questionVisible: true,
|
||||
planRightOfQuestion: !mobile,
|
||||
planAboveQuestion: mobile,
|
||||
questionVisible: !mobile,
|
||||
planRightOfQuestion: true,
|
||||
planAboveQuestion: false,
|
||||
panesInsideWorkspace: true,
|
||||
actionsInsideScroll: false,
|
||||
actionsAtBottom: true,
|
||||
@@ -151,7 +164,7 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
scrollable: true,
|
||||
scrollOwnerConfigured: true,
|
||||
markdownRendered: true,
|
||||
flushPaneInsets: !mobile,
|
||||
flushPaneInsets: true,
|
||||
desktopActionRowsAligned: mobile ? false : true,
|
||||
actionTopDelta: mobile ? expect.any(Number) : 0,
|
||||
actionBottomDelta: mobile ? expect.any(Number) : 0,
|
||||
@@ -160,5 +173,99 @@ describe.runIf(executablePath)("Planning Mode browser E2E", () => {
|
||||
}
|
||||
|
||||
it("keeps the Markdown plan right of the question on desktop", () => verifyResponsiveWorkspace({ width: 1440, height: 900 }, false), 30_000);
|
||||
it("keeps the Markdown plan above the question on mobile", () => verifyResponsiveWorkspace({ width: 390, height: 568 }, true), 30_000);
|
||||
it("keeps the Markdown plan reachable through the mobile workspace tab", () => verifyResponsiveWorkspace({ width: 390, height: 568 }, true), 30_000);
|
||||
|
||||
async function selectPlanQuote(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const markdown = document.querySelector<HTMLElement>("[data-testid='planning-plan-markdown']")!;
|
||||
const walker = document.createTreeWalker(markdown, NodeFilter.SHOW_TEXT);
|
||||
let textNode = walker.nextNode();
|
||||
while (textNode && !textNode.textContent?.trim()) textNode = walker.nextNode();
|
||||
if (!textNode) throw new Error("fixture did not render selectable plan text");
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(textNode);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
window.getSelection()?.addRange(range);
|
||||
markdown.dispatchEvent(new Event("touchend", { bubbles: true }));
|
||||
});
|
||||
await expectVisible(page.getByRole("button", { name: "Add comment to selection" }));
|
||||
}
|
||||
|
||||
async function verifyContextualCommentPlacement(
|
||||
viewport: { width: number; height: number },
|
||||
inFooter: boolean,
|
||||
presentation: "embedded" | "modal",
|
||||
): Promise<void> {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto(`${baseUrl}app/planning-browser-e2e-fixture.html?surface=plan-review&presentation=${presentation}&reset=1`);
|
||||
if (viewport.width <= 768) await page.getByRole("tab", { name: "Plan preview" }).click();
|
||||
await expectVisible(page.locator("[data-testid='planning-plan-markdown'] h1"));
|
||||
await selectPlanQuote(page);
|
||||
|
||||
const placement = await page.evaluate(() => {
|
||||
const buttons = [...document.querySelectorAll<HTMLButtonElement>(".planning-add-comment")];
|
||||
const actions = document.querySelector<HTMLElement>("[data-testid='planning-plan-actions']")!;
|
||||
const visibleButtons = buttons.filter((button) => {
|
||||
const style = getComputedStyle(button);
|
||||
return style.display !== "none" && style.visibility !== "hidden" && !button.disabled;
|
||||
});
|
||||
const visibleButton = visibleButtons[0];
|
||||
const focusable = [...document.querySelectorAll<HTMLElement>("button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])")]
|
||||
.filter((element) => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden");
|
||||
const triggerIndex = visibleButton ? focusable.indexOf(visibleButton) : -1;
|
||||
/*
|
||||
FNXC:PlanningComments 2026-07-31-09:05:
|
||||
Start at the preceding tab stop, then send actual Tab keys. Programmatic focus alone would
|
||||
incorrectly accept a tabIndex=-1 contextual-comment trigger as keyboard reachable.
|
||||
*/
|
||||
focusable[triggerIndex - 1]?.focus();
|
||||
return {
|
||||
totalButtons: buttons.length,
|
||||
visibleButtons: visibleButtons.length,
|
||||
visibleInActions: Boolean(visibleButton && actions.contains(visibleButton)),
|
||||
triggerHasPreviousTabStop: triggerIndex > 0,
|
||||
hiddenButtonsTabbable: buttons.filter((button) => button !== visibleButton && button.tabIndex >= 0 && getComputedStyle(button).display !== "none").length,
|
||||
actionLabels: [...actions.querySelectorAll<HTMLButtonElement>("button")]
|
||||
.filter((button) => getComputedStyle(button).display !== "none")
|
||||
.map((button) => button.textContent?.trim()),
|
||||
};
|
||||
});
|
||||
|
||||
expect(placement).toMatchObject({
|
||||
totalButtons: 2,
|
||||
visibleButtons: 1,
|
||||
visibleInActions: inFooter,
|
||||
triggerHasPreviousTabStop: true,
|
||||
hiddenButtonsTabbable: 0,
|
||||
});
|
||||
if (inFooter) expect(placement.actionLabels).toEqual(expect.arrayContaining(["Add comment to selection", "Refine", "Proceed with plan"]));
|
||||
else expect(placement.actionLabels).not.toContain("Add comment to selection");
|
||||
|
||||
let reachedTriggerByTab = false;
|
||||
for (let tabCount = 0; tabCount < 8; tabCount += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
reachedTriggerByTab = await page.evaluate(() => document.activeElement?.classList.contains("planning-add-comment") ?? false);
|
||||
if (reachedTriggerByTab) break;
|
||||
}
|
||||
expect(reachedTriggerByTab).toBe(true);
|
||||
|
||||
if (inFooter && presentation === "embedded" && process.env.FUSION_CAPTURE_DIR) await page.screenshot({ path: `${process.env.FUSION_CAPTURE_DIR}/planning-comment-mobile-selection.png` });
|
||||
await page.getByRole("button", { name: "Add comment to selection" }).click();
|
||||
await expectVisible(page.getByLabel("Add plan comment"));
|
||||
if (inFooter && presentation === "embedded" && process.env.FUSION_CAPTURE_DIR) await page.screenshot({ path: `${process.env.FUSION_CAPTURE_DIR}/planning-comment-mobile-editor.png` });
|
||||
const afterOpen = await page.evaluate(() => ({
|
||||
addCommentButtons: document.querySelectorAll(".planning-add-comment").length,
|
||||
actionChildren: document.querySelector("[data-testid='planning-plan-actions']")?.querySelectorAll(".planning-add-comment").length,
|
||||
}));
|
||||
expect(afterOpen).toEqual({ addCommentButtons: 0, actionChildren: 0 });
|
||||
await page.close();
|
||||
}
|
||||
|
||||
it("places the sole contextual comment trigger by viewport in embedded and modal Planning", async () => {
|
||||
for (const presentation of ["embedded", "modal"] as const) {
|
||||
await verifyContextualCommentPlacement({ width: 768, height: 900 }, true, presentation);
|
||||
await verifyContextualCommentPlacement({ width: 769, height: 900 }, false, presentation);
|
||||
await verifyContextualCommentPlacement({ width: 1280, height: 900 }, false, presentation);
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -337,12 +337,13 @@ The array stays empty; add new entries here only with a matching ledger row.
|
||||
FNXC:DashboardTestQuarantine 2026-07-16-09:00:
|
||||
FN-8077 removed routes-system.test.ts from this list and the ledger in lockstep. Its test now explicitly advances a fake Date-only clock between CPU samples, so unrelated route clock reads cannot stretch elapsed time under the loaded API lane; assertions and timeout policy are unchanged.
|
||||
*/
|
||||
/*
|
||||
FNXC:DashboardTestQuarantine 2026-07-31-00:00:
|
||||
FN-8533 restores planning-browser-e2e after its teardown now closes Vite's listening socket,
|
||||
HMR channel, watcher, and plugin container deterministically. Browser viewport assertions are a
|
||||
required responsive acceptance lane, so the test must not remain excluded from dashboard-api.
|
||||
*/
|
||||
const quarantinedDashboardTests: string[] = [
|
||||
/*
|
||||
FNXC:DashboardTestQuarantine 2026-07-21-09:30:
|
||||
planning-browser-e2e completed its desktop assertions but timed out twice while tearing down Chromium/Vite. Quarantine the file without widening teardown timeouts or retries; delete it on 2026-08-04 unless a root-cause rescue restores deterministic teardown.
|
||||
*/
|
||||
"src/__tests__/planning-browser-e2e.test.ts",
|
||||
/*
|
||||
FNXC:DashboardTestQuarantine 2026-07-17-16:50:
|
||||
FN-8245 re-admits all three UI files with their ledger rows removed in lockstep.
|
||||
|
||||
@@ -5,11 +5,6 @@
|
||||
"file": "packages/engine/src/__tests__/executor-task-done-invariant.test.ts",
|
||||
"reason": "FN-5241 handoff pair skipped in-file (it.skip): graph fails at steps#0:step-execute under PG mock-agent fixture after U10b; same class red on origin/main full-suite https://github.com/Runfusion/Fusion/actions/runs/29805577293. Rewrite graph completion harness before rescue. quarantinedAt 2026-07-20.",
|
||||
"quarantinedAt": "2026-07-20"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/src/__tests__/planning-browser-e2e.test.ts",
|
||||
"reason": "Chromium/Vite teardown timed out after the desktop assertions passed during [local Codex run 2026-07-21 09:29 PDT](local://codex/2026-07-21/planning-browser-e2e-teardown); repeated once in the same session.",
|
||||
"quarantinedAt": "2026-07-21"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user