tests/e2e/flows-pane-keyboard-autosave.spec.ts
Rama main · réplica de lectura del repositorio original.
import { expect, test } from "@playwright/test";
import {
stubFlowsPage,
primaryModifier,
} from "./helpers/flows-pane-keyboard-fixtures";
import type { FlowNode } from "../../lib/types";
// 100 ms past the 900 ms autosave debounce — enough to confirm no save fires.
const autosaveSettleMs = 1000;
test("keyboard shortcuts save from text fields without hijacking text input selection", async ({
page,
}) => {
const { getFlow } = await stubFlowsPage(page);
await page.goto("/alex/workflows");
await page.waitForLoadState("networkidle");
await page
.locator(".react-flow__node")
.filter({ hasText: "Reviewer A" })
.click();
const notes = page.getByPlaceholder(
"Capture intent, guardrails, and context for this flow."
);
await notes.click();
await page.keyboard.press(`${primaryModifier}+A`);
await page.keyboard.type("Updated flow notes");
await page.keyboard.press(`${primaryModifier}+S`);
await expect.poll(() => getFlow().notes).toBe("Updated flow notes");
await expect(
page.locator(".react-flow__node.selected").filter({ hasText: "Reviewer A" })
).toHaveCount(1);
});
test("draft edits autosave and surface save status without a manual save click", async ({
page,
}) => {
const { getFlow } = await stubFlowsPage(page, {
// Keep the PUT in flight past the 900 ms debounce so transient states are observable.
flowUpdateDelayMs: 1200,
});
await page.goto("/alex/workflows");
await page.waitForLoadState("networkidle");
await page
.locator(".react-flow__node")
.filter({ hasText: "Reviewer A" })
.click();
const notes = page.getByPlaceholder(
"Capture intent, guardrails, and context for this flow."
);
await notes.fill("Autosaved flow notes");
const liveStatus = page.getByTestId("flow-save-status-live");
await expect(page.getByTestId("flow-save-status")).toContainText(
/Autosave queued|Autosaving/
);
await expect(liveStatus).toBeEmpty();
await expect.poll(() => getFlow().notes).toBe("Autosaved flow notes");
const status = page.getByTestId("flow-save-status");
await expect(status).toContainText("Saved");
await expect(liveStatus).toHaveText("Saved");
});
test("clean saved status stays visually quiet", async ({ page }) => {
await stubFlowsPage(page);
await page.goto("/alex/workflows");
await page.waitForLoadState("networkidle");
const status = page.getByTestId("flow-save-status");
await expect(status).toHaveText("Saved");
const liveStatus = page.getByTestId("flow-save-status-live");
await expect(liveStatus).toHaveAttribute("role", "status");
await expect(liveStatus).toBeEmpty();
const presentation = await status.evaluate((node) => {
const style = getComputedStyle(node);
return {
backgroundColor: style.backgroundColor,
borderTopWidth: style.borderTopWidth,
};
});
expect(presentation.backgroundColor).toBe("rgba(0, 0, 0, 0)");
expect(presentation.borderTopWidth).toBe("0px");
});
test("canvas viewport changes do not autosave without graph edits", async ({
page,
}) => {
await page.clock.install({ time: new Date("2026-04-30T12:00:00Z") });
let flowUpdateCount = 0;
await stubFlowsPage(page, {
onFlowUpdate: () => {
flowUpdateCount += 1;
},
});
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/alex/workflows");
await page.waitForLoadState("networkidle");
await expect(page.locator(".react-flow__node")).toHaveCount(4);
// Advance past the 900ms autosave debounce after initial fit-view adjustments.
await page.clock.runFor(autosaveSettleMs);
// The primary regression assertion: no PUT must have fired for a
// viewport-only change. The "Saved" text reflects the initial load state
// rendered by stubFlowsPage (no dirty edits outstanding at page load).
expect(flowUpdateCount).toBe(0);
await expect(page.getByTestId("flow-save-status")).toContainText("Saved");
await page.locator(".react-flow").click({ position: { x: 520, y: 300 } });
// A canvas click that only changes the viewport must not trigger autosave.
await page.clock.runFor(autosaveSettleMs);
expect(flowUpdateCount).toBe(0);
await expect(page.getByTestId("flow-save-status")).toContainText("Saved");
});
test("autosave does not replay an older server snapshot over newer local edits", async ({
page,
}) => {
let flowUpdateCount = 0;
const { getFlow } = await stubFlowsPage(page, {
flowUpdateDelayMs: 1800,
onFlowUpdate: () => {
flowUpdateCount += 1;
},
});
await page.goto("/alex/workflows");
await page.waitForLoadState("networkidle");
await page
.locator(".react-flow__node")
.filter({ hasText: "Reviewer A" })
.click();
const notes = page.getByPlaceholder(
"Capture intent, guardrails, and context for this flow."
);
await notes.fill("First autosave snapshot");
await expect.poll(() => flowUpdateCount).toBe(1);
await notes.fill("Second local edit should win");
await expect(page.getByTestId("flow-save-status")).toContainText(
/Autosaving|Autosave queued/
);
await expect
.poll(async () => notes.inputValue())
.toBe("Second local edit should win");
// Confirm the persisted server payload — not just the UI field — ends on the newer edit.
// Without this the test would pass even if a stale server echo transiently overwrote the draft,
// as long as React re-rendered the latest value back.
await expect.poll(() => flowUpdateCount).toBeGreaterThanOrEqual(2);
await expect.poll(() => getFlow().notes).toBe("Second local edit should win");
});
test("adding a condition node does not let the first autosave overwrite follow-up inspector edits", async ({
page,
}) => {
let flowUpdateCount = 0;
const { getFlow } = await stubFlowsPage(page, {
flowUpdateDelayMs: 1800,
onFlowUpdate: () => {
flowUpdateCount += 1;
},
});
await page.goto("/alex/workflows");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /If branch/ }).click();
await expect(page.getByLabel("Source field")).toBeVisible();
await expect.poll(() => flowUpdateCount).toBe(1);
const labelInput = page.getByLabel("Label");
await labelInput.fill("Branch on repo source");
await expect
.poll(async () => labelInput.inputValue())
.toBe("Branch on repo source");
// Verify the persisted server payload reflects the follow-up inspector edit.
// This is the regression guard: a broken build where the first autosave echo overwrites
// the condition-node label would still render the local input correctly but would persist
// the original "Condition" label to the server.
await expect.poll(() => flowUpdateCount).toBeGreaterThanOrEqual(2);
const getConditionNodeLabel = () => {
const nodes = (getFlow().draft_graph?.nodes ?? []) as FlowNode[];
for (const node of nodes) {
if (node.type === "condition") return node.data.label;
}
return null;
};
await expect.poll(getConditionNodeLabel).toBe("Branch on repo source");
});