test(e2e): replace unconditional sleeps in session and workflow specs (#2642)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
type E2EStoreWindow = Window & {
|
||||
__KANDEV_E2E_STORE__?: {
|
||||
@@ -104,3 +104,33 @@ export async function seedAvailableCommands(
|
||||
{ sid: sessionId, commandList: commands },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until `sessionId` is the active session AND has stopped changing.
|
||||
*
|
||||
* Clicking a session tab settles asynchronously, and the flicker specs install
|
||||
* their observers straight afterwards: starting to observe mid-settle records
|
||||
* the tail of the switch as if it were oscillation. Requiring two consecutive
|
||||
* agreeing samples gives those specs the quiet baseline the fixed sleeps were
|
||||
* approximating, while returning immediately once the switch is genuinely done.
|
||||
*/
|
||||
export async function waitForStableActiveSession(
|
||||
page: Page,
|
||||
sessionId: string,
|
||||
timeout = 15_000,
|
||||
): Promise<void> {
|
||||
let previous: string | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await page.evaluate(
|
||||
() => (window as E2EStoreWindow).__KANDEV_E2E_STORE__?.getState().tasks.activeSessionId,
|
||||
);
|
||||
const stable = current === sessionId && previous === sessionId;
|
||||
previous = current ?? null;
|
||||
return stable;
|
||||
},
|
||||
{ timeout, message: `active session did not settle on ${sessionId}` },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
@@ -58,11 +58,13 @@ test.describe("Long prepare (slow git fetch)", () => {
|
||||
await expect(fileTreeWaiting).toBeVisible({ timeout: 15_000 });
|
||||
await expect(fileTreeManual).toHaveCount(0);
|
||||
|
||||
// Wait past the pre-fix retry budget (1+2+5+10 = 18s). The file tree
|
||||
// must NOT have transitioned to the "manual" (Load Files) state —
|
||||
// that's the regression. On faster CI runners the 22s fetch may already
|
||||
// have completed by the time this assertion runs, so do not require the
|
||||
// waiting state to still be visible here.
|
||||
// deliberate-sleep(product-timer): waits past the pre-fix retry budget
|
||||
// (1+2+5+10 = 18s). The regression is the file tree transitioning to the
|
||||
// "manual" (Load Files) state when that budget expires, so the budget's
|
||||
// expiry is precisely the thing under test and nothing renders to signal
|
||||
// it. On faster CI runners the 22s fetch may already have completed by
|
||||
// the time this assertion runs, so do not require the waiting state to
|
||||
// still be visible here.
|
||||
await testPage.waitForTimeout(19_000);
|
||||
await expect(fileTreeManual).toHaveCount(0);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { waitForStableActiveSession } from "../../helpers/session-store";
|
||||
|
||||
/**
|
||||
* Regression: opening / switching to a second agent session in a task that
|
||||
@@ -150,7 +151,7 @@ test.describe("Session flicker", () => {
|
||||
);
|
||||
|
||||
await session.sessionTabBySessionId(session1Id).click();
|
||||
await testPage.waitForTimeout(300);
|
||||
await waitForStableActiveSession(testPage, session1Id);
|
||||
|
||||
// Observe every change of the active session over the settle window.
|
||||
await testPage.evaluate(() => {
|
||||
@@ -171,6 +172,10 @@ test.describe("Session flicker", () => {
|
||||
});
|
||||
|
||||
await session.sessionTabBySessionId(session2Id).click();
|
||||
// deliberate-sleep(negative-assertion): this is the observation window the
|
||||
// test exists for. It asserts the ABSENCE of sustained oscillation after a
|
||||
// deliberate switch, so it must sample real elapsed time rather than wait
|
||||
// for any single event.
|
||||
await testPage.waitForTimeout(2_500);
|
||||
|
||||
const activeLog = await testPage.evaluate(
|
||||
@@ -199,7 +204,7 @@ test.describe("Session flicker", () => {
|
||||
);
|
||||
|
||||
await session.sessionTabBySessionId(session1Id).click();
|
||||
await testPage.waitForTimeout(300);
|
||||
await waitForStableActiveSession(testPage, session1Id);
|
||||
|
||||
// Simulate the backend launch race: session #2 ends up with a DIFFERENT
|
||||
// task_environment_id than session #1. Keep re-forcing it so trailing WS
|
||||
@@ -225,15 +230,33 @@ test.describe("Session flicker", () => {
|
||||
if (p.id.startsWith("session:")) removals.push(p.id);
|
||||
});
|
||||
}, session2Id);
|
||||
await testPage.waitForTimeout(100);
|
||||
// The diverge interval above fires every 30ms; wait for its first write to
|
||||
// land in the store rather than assuming one tick has happened.
|
||||
await testPage.waitForFunction(
|
||||
(sid2) =>
|
||||
(
|
||||
window as unknown as {
|
||||
__KANDEV_E2E_STORE__?: {
|
||||
getState: () => { environmentIdBySessionId: Record<string, string> };
|
||||
};
|
||||
}
|
||||
).__KANDEV_E2E_STORE__?.getState().environmentIdBySessionId[sid2] ===
|
||||
"diverged-env-for-session-2",
|
||||
session2Id,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
|
||||
// Switch to session #2 (now on a diverged env) and back — this is what used
|
||||
// to strip the sibling tab and tear the chat down.
|
||||
await session.sessionTabBySessionId(session2Id).click();
|
||||
await testPage.waitForTimeout(800);
|
||||
await waitForStableActiveSession(testPage, session2Id);
|
||||
await session.sessionTabBySessionId(session1Id).click();
|
||||
await testPage.waitForTimeout(800);
|
||||
await waitForStableActiveSession(testPage, session1Id);
|
||||
await session.sessionTabBySessionId(session2Id).click();
|
||||
// deliberate-sleep(negative-assertion): final observation window. The
|
||||
// assertions below are that neither tab was stripped and no session panel
|
||||
// was torn down, which only means something after giving the regression
|
||||
// real time to occur.
|
||||
await testPage.waitForTimeout(1_500);
|
||||
|
||||
// Both tabs must remain present throughout, and the active session's chat
|
||||
|
||||
@@ -72,6 +72,10 @@ test.describe("Session resume — task state stability", () => {
|
||||
0,
|
||||
);
|
||||
polls++;
|
||||
// deliberate-sleep(poll-interval): sampling interval for the fixed probe
|
||||
// window above, not a settle. The assertion is that the task never
|
||||
// flickers into Running, so the loop must keep sampling across the whole
|
||||
// window; the polls >= 50 check below guards against it degenerating.
|
||||
await testPage.waitForTimeout(100);
|
||||
}
|
||||
// Ensure we actually polled meaningfully across the window.
|
||||
|
||||
@@ -116,9 +116,11 @@ test.describe("Session stream budget", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Leave a deliberate five-second observation window after the five task
|
||||
// switches. This is a diagnostic total, not a compression-dependent
|
||||
// correctness threshold.
|
||||
// deliberate-sleep(negative-assertion): a deliberate five-second
|
||||
// observation window after the five task switches, measuring how much
|
||||
// gateway traffic keeps arriving. This is a diagnostic total, not a
|
||||
// compression-dependent correctness threshold, so the window must be real
|
||||
// elapsed time rather than a wait for any particular frame.
|
||||
await testPage.waitForTimeout(5_000);
|
||||
|
||||
const summary = summarizeGatewayTraffic(capture.frames);
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { attachGatewayTrafficCapture } from "../../helpers/ws-traffic";
|
||||
|
||||
/** Wire action the kanban WS handler consumes when a task moves step. */
|
||||
const TASK_UPDATED_ACTION = "task.updated";
|
||||
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
|
||||
@@ -187,6 +191,9 @@ test.describe("Session tab management — close behavior", () => {
|
||||
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// …and stays gone — useAutoSessionTab must not recreate it.
|
||||
// deliberate-sleep(negative-assertion): the regression is a tab being
|
||||
// recreated after removal. There is no event for a recreation that must
|
||||
// never happen, so the check needs real elapsed time to be meaningful.
|
||||
await testPage.waitForTimeout(800);
|
||||
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible();
|
||||
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible();
|
||||
@@ -290,6 +297,9 @@ test.describe("Session tab management — close behavior", () => {
|
||||
// that the remaining session tab is present (and the deleted one didn't come
|
||||
// back), so gate on the surviving session tab instead.
|
||||
await expect(session.sessionTabBySessionId(session2Id)).toBeVisible({ timeout: 15_000 });
|
||||
// deliberate-sleep(negative-assertion): the deleted tab must not come back
|
||||
// after a task round-trip. Nothing is rendered to wait for when the
|
||||
// expected outcome is "no tab ever appears".
|
||||
await testPage.waitForTimeout(800);
|
||||
await expect(session.sessionTabBySessionId(session1Id)).not.toBeVisible();
|
||||
});
|
||||
@@ -344,6 +354,8 @@ test.describe("Session tab management — close behavior", () => {
|
||||
await expect(session.sessionTabBySessionId(sessionB1Id)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// …and neither of task A's session tabs should have followed us in.
|
||||
// deliberate-sleep(negative-assertion): asserts tabs from another task
|
||||
// never leak in, which has no arrival event to wait on.
|
||||
await testPage.waitForTimeout(800);
|
||||
await expect(session.sessionTabBySessionId(sessionA1Id)).not.toBeVisible();
|
||||
await expect(session.sessionTabBySessionId(sessionA2Id)).not.toBeVisible();
|
||||
@@ -365,6 +377,10 @@ test.describe("Session tab management — primary session persistence", () => {
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
|
||||
// Attach before the first navigation so the capture sees the websocket from
|
||||
// the moment it opens.
|
||||
const traffic = attachGatewayTrafficCapture(testPage);
|
||||
|
||||
const { task, session, session1Id, session2Id } = await createTaskWithTwoSessions(
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -398,10 +414,49 @@ test.describe("Session tab management — primary session persistence", () => {
|
||||
// Trigger kanban.update by moving the task to a non-start step.
|
||||
const otherStep = seedData.steps.find((s) => s.id !== seedData.startStepId);
|
||||
if (!otherStep) throw new Error("Workflow needs at least 2 steps to trigger kanban.update");
|
||||
const taskUpdatesBeforeMove = () =>
|
||||
traffic.frames.filter(
|
||||
(frame) => frame.direction === "received" && frame.action === TASK_UPDATED_ACTION,
|
||||
).length;
|
||||
const taskUpdatesAtMove = taskUpdatesBeforeMove();
|
||||
await apiClient.moveTask(task.id, seedData.workflowId, otherStep.id);
|
||||
|
||||
// Give the WS broadcast time to land.
|
||||
await testPage.waitForTimeout(500);
|
||||
// The kanban.update broadcast is what could wrongly move the star, so wait
|
||||
// for the gateway to actually deliver it instead of budgeting for it.
|
||||
// The move is delivered as `task.updated`, which `lib/ws/handlers/kanban.ts`
|
||||
// consumes -- that handler is what this test is named after, and its
|
||||
// regression was moving the star back to session #1. There is no
|
||||
// `kanban.update` frame on the wire; waiting for one times out. Verified by
|
||||
// dumping every received action after moveTask.
|
||||
await expect
|
||||
.poll(taskUpdatesBeforeMove, {
|
||||
timeout: 15_000,
|
||||
message: `no ${TASK_UPDATED_ACTION} frame was delivered after moveTask`,
|
||||
})
|
||||
.toBeGreaterThan(taskUpdatesAtMove);
|
||||
|
||||
// Observing the frame only proves the gateway delivered it, not that the
|
||||
// handler under test ran. Both assertions below are already true before the
|
||||
// move, so asserting in that gap would pass against the pre-move DOM and
|
||||
// miss the regression entirely. Wait for the handler's own effect on the
|
||||
// store -- the task's step in `kanban.tasks` -- before asserting.
|
||||
await testPage.waitForFunction(
|
||||
({ taskId, stepId }) => {
|
||||
const store = (
|
||||
window as Window & {
|
||||
__KANDEV_E2E_STORE__?: {
|
||||
getState: () => { kanban: { tasks: Array<{ id: string; workflowStepId: string }> } };
|
||||
};
|
||||
}
|
||||
).__KANDEV_E2E_STORE__;
|
||||
if (!store) throw new Error("E2E store bridge missing");
|
||||
return (
|
||||
store.getState().kanban.tasks.find((t) => t.id === taskId)?.workflowStepId === stepId
|
||||
);
|
||||
},
|
||||
{ taskId: task.id, stepId: otherStep.id },
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
// Star must still be on session #2 (would jump back to #1 before the kanban.ts fix).
|
||||
await expect(starInTab(session, session2Id)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
@@ -23,14 +23,17 @@ async function pollSessions(
|
||||
expectedCount: number,
|
||||
timeoutMs = 30_000,
|
||||
) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
if (sessions.length >= expectedCount) return sessions;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
return sessions;
|
||||
let latest: Awaited<ReturnType<typeof apiClient.listTaskSessions>>["sessions"] = [];
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
latest = (await apiClient.listTaskSessions(taskId)).sessions;
|
||||
return latest.length;
|
||||
},
|
||||
{ timeout: timeoutMs, message: `task ${taskId} never reached ${expectedCount} session(s)` },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(expectedCount);
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function waitForSessionEnvironmentId(
|
||||
@@ -39,20 +42,33 @@ async function waitForSessionEnvironmentId(
|
||||
agentProfileId: string,
|
||||
timeoutMs = 30_000,
|
||||
) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
const environmentId = sessions.find(
|
||||
(s) => s.agent_profile_id === agentProfileId,
|
||||
)?.task_environment_id;
|
||||
if (environmentId) return environmentId;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
const details = sessions
|
||||
.map((s) => `${s.id}:${s.agent_profile_id}:${s.state}:${s.task_environment_id ?? "none"}`)
|
||||
.join(", ");
|
||||
throw new Error(`session for profile ${agentProfileId} did not get environment id: ${details}`);
|
||||
let environmentId = "";
|
||||
let details = "";
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
// Captured on every attempt so the failure below describes the last
|
||||
// observed state rather than requiring an extra request.
|
||||
details = sessions
|
||||
.map((s) => `${s.id}:${s.agent_profile_id}:${s.state}:${s.task_environment_id ?? "none"}`)
|
||||
.join(", ");
|
||||
environmentId =
|
||||
sessions.find((s) => s.agent_profile_id === agentProfileId)?.task_environment_id ?? "";
|
||||
return environmentId !== "";
|
||||
},
|
||||
{
|
||||
timeout: timeoutMs,
|
||||
message: `session for profile ${agentProfileId} did not get environment id`,
|
||||
},
|
||||
)
|
||||
.toBe(true)
|
||||
.catch((err: Error) => {
|
||||
// expect.poll's own message reports only the final polled value, so the
|
||||
// captured session states have to be re-thrown here to reach the log.
|
||||
throw new Error(`${err.message}; last observed sessions: ${details}`);
|
||||
});
|
||||
return environmentId;
|
||||
}
|
||||
|
||||
async function pollSessionsForEnvironmentInheritance(
|
||||
@@ -62,21 +78,36 @@ async function pollSessionsForEnvironmentInheritance(
|
||||
targetProfileId: string,
|
||||
timeoutMs = 30_000,
|
||||
) {
|
||||
const start = Date.now();
|
||||
let latestSessions: Awaited<ReturnType<typeof pollSessions>> = [];
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
latestSessions = sessions;
|
||||
const sourceSession = sessions.find((s) => s.agent_profile_id === sourceProfileId);
|
||||
const targetSession = sessions.find((s) => s.agent_profile_id === targetProfileId);
|
||||
if (
|
||||
sourceSession?.task_environment_id &&
|
||||
targetSession?.task_environment_id === sourceSession.task_environment_id
|
||||
) {
|
||||
return sessions;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
let lastRequestError: unknown;
|
||||
// Returns the last observed sessions either way: callers assert on the
|
||||
// inheritance themselves, so a timeout here must not throw.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
lastRequestError = undefined;
|
||||
latestSessions = sessions;
|
||||
const sourceSession = sessions.find((s) => s.agent_profile_id === sourceProfileId);
|
||||
const targetSession = sessions.find((s) => s.agent_profile_id === targetProfileId);
|
||||
return Boolean(
|
||||
sourceSession?.task_environment_id &&
|
||||
targetSession?.task_environment_id === sourceSession.task_environment_id,
|
||||
);
|
||||
} catch (err) {
|
||||
// Retry a transient request failure, but remember the last one: the
|
||||
// blanket catch below is meant to tolerate "inheritance not visible
|
||||
// yet", not to turn a dead endpoint into an empty session list.
|
||||
lastRequestError = err;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
.toBe(true)
|
||||
.catch(() => undefined);
|
||||
if (lastRequestError) throw lastRequestError;
|
||||
return latestSessions;
|
||||
}
|
||||
|
||||
@@ -120,8 +151,18 @@ test.describe("Workflow agent profile switching", () => {
|
||||
expect(initialSessions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(initialSessions[0].agent_profile_id).toBe(profileA.id);
|
||||
|
||||
// Wait for agent to be ready before moving
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
// Wait for the agent to actually settle rather than budgeting 3s for it.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return sessions.some(
|
||||
(s) => s.state === "WAITING_FOR_INPUT" || s.state === "IDLE" || s.state === "RUNNING",
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000, message: "first session never became ready" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Move task to Step2 — should create new session with profileB
|
||||
await apiClient.moveTask(task.id, workflow.id, step2.id);
|
||||
@@ -237,11 +278,15 @@ test.describe("Workflow agent profile switching", () => {
|
||||
});
|
||||
|
||||
// Wait for the agent to be ready (WAITING_FOR_INPUT) before moving
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
if (sessions.some((s) => s.state === "WAITING_FOR_INPUT")) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return sessions.some((s) => s.state === "WAITING_FOR_INPUT");
|
||||
},
|
||||
{ timeout: 10_000, message: "no session reached WAITING_FOR_INPUT" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Move task to Step2 — should create new session with profileB
|
||||
await apiClient.moveTask(task.id, workflow.id, step2.id);
|
||||
@@ -366,11 +411,15 @@ test.describe("Workflow agent profile switching", () => {
|
||||
);
|
||||
|
||||
// Wait for Step1 agent to finish
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
if (sessions.some((s) => s.state === "WAITING_FOR_INPUT")) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return sessions.some((s) => s.state === "WAITING_FOR_INPUT");
|
||||
},
|
||||
{ timeout: 10_000, message: "no session reached WAITING_FOR_INPUT" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Move to Step2 — should auto-launch agent despite no auto_start_agent
|
||||
await apiClient.moveTask(task.id, workflow.id, step2.id);
|
||||
@@ -426,11 +475,15 @@ test.describe("Workflow agent profile switching", () => {
|
||||
);
|
||||
|
||||
// Wait for Step1 agent to finish
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
if (sessions.some((s) => s.state === "WAITING_FOR_INPUT")) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return sessions.some((s) => s.state === "WAITING_FOR_INPUT");
|
||||
},
|
||||
{ timeout: 10_000, message: "no session reached WAITING_FOR_INPUT" },
|
||||
)
|
||||
.toBe(true);
|
||||
const step1EnvironmentId = await waitForSessionEnvironmentId(apiClient, task.id, profileA.id);
|
||||
|
||||
// Move to Step2
|
||||
@@ -909,6 +962,9 @@ test.describe("Workflow agent profile switching", () => {
|
||||
expect(stableSessions.length, "no extra session should spawn within stability window").toBe(
|
||||
2,
|
||||
);
|
||||
// deliberate-sleep(poll-interval): sampling interval for the stability
|
||||
// window above. The assertion is that no extra session appears during
|
||||
// it, so the loop must keep sampling across real elapsed time.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
|
||||
@@ -943,7 +999,6 @@ test.describe("Workflow agent profile switching", () => {
|
||||
// Click first step to open config panel
|
||||
const stepNodes = card.locator(".group.relative");
|
||||
await stepNodes.first().click();
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
// Reset context checkbox should be enabled (no agent profile set)
|
||||
const resetCheckbox = card.getByRole("checkbox", { name: "Reset agent context" });
|
||||
@@ -957,7 +1012,6 @@ test.describe("Workflow agent profile switching", () => {
|
||||
const reloadedCard = await page.findWorkflowCard("E2E Workflow");
|
||||
const reloadedSteps = reloadedCard.locator(".group.relative");
|
||||
await reloadedSteps.first().click();
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
// Reset context checkbox should be disabled
|
||||
const reloadedCheckbox = reloadedCard.getByRole("checkbox", {
|
||||
|
||||
@@ -15,7 +15,6 @@ test.describe("Workflow step prompt autocomplete", () => {
|
||||
// Click first step to open config panel
|
||||
const stepNodes = card.locator(".group.relative");
|
||||
await stepNodes.first().click();
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
// Wait for the ScriptEditor (Monaco) to mount inside the step config panel
|
||||
const monacoEditor = card.locator(".monaco-editor");
|
||||
@@ -23,11 +22,16 @@ test.describe("Workflow step prompt autocomplete", () => {
|
||||
|
||||
// Click into the editor to focus it
|
||||
await monacoEditor.click();
|
||||
await testPage.waitForTimeout(300);
|
||||
// Typing before Monaco owns focus silently goes nowhere, so wait for the
|
||||
// focus rather than budget for it. The selector is load-bearing and was
|
||||
// established by probing the live DOM: this Monaco build uses the
|
||||
// EditContext API, so the focus target is `div.native-edit-context`. There
|
||||
// is no `textarea.inputarea`, and the only textarea present is a readonly
|
||||
// `ime-text-area` that never receives focus.
|
||||
await expect(monacoEditor.locator(".native-edit-context")).toBeFocused({ timeout: 5_000 });
|
||||
|
||||
// Type {{ to trigger autocomplete
|
||||
await testPage.keyboard.type("{{");
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
// The Monaco suggest widget should appear with {{task_prompt}}
|
||||
const suggestWidget = testPage.locator(".monaco-editor .suggest-widget");
|
||||
@@ -57,7 +61,6 @@ test.describe("Workflow step prompt autocomplete", () => {
|
||||
// Click first step to open config panel
|
||||
const stepNodes = card.locator(".group.relative");
|
||||
await stepNodes.first().click();
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
// Wait for the ScriptEditor (Monaco) to mount inside the step config panel
|
||||
const monacoEditor = card.locator(".monaco-editor");
|
||||
@@ -65,11 +68,12 @@ test.describe("Workflow step prompt autocomplete", () => {
|
||||
|
||||
// Click into the editor to focus it
|
||||
await monacoEditor.click();
|
||||
await testPage.waitForTimeout(300);
|
||||
// Focus target is `div.native-edit-context` (EditContext API), not a
|
||||
// textarea. See the note in the first test.
|
||||
await expect(monacoEditor.locator(".native-edit-context")).toBeFocused({ timeout: 5_000 });
|
||||
|
||||
// Type @ to trigger the prompt-mention autocomplete
|
||||
await testPage.keyboard.type("@");
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
// The Monaco suggest widget should appear with the seeded prompt
|
||||
const suggestWidget = testPage.locator(".monaco-editor .suggest-widget");
|
||||
@@ -82,7 +86,6 @@ test.describe("Workflow step prompt autocomplete", () => {
|
||||
|
||||
// Accept the suggestion and verify the editor content now contains the mention.
|
||||
await suggestion.first().click();
|
||||
await testPage.waitForTimeout(300);
|
||||
|
||||
await expect(monacoEditor).toContainText(`@${promptName}`);
|
||||
} finally {
|
||||
@@ -108,7 +111,6 @@ test.describe("Workflow step prompt autocomplete", () => {
|
||||
// Click first step to open config panel
|
||||
const stepNodes = card.locator(".group.relative");
|
||||
await stepNodes.first().click();
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
// Find the step agent profile select
|
||||
const agentSelect = card.getByTestId("step-agent-profile-select");
|
||||
@@ -120,10 +122,23 @@ test.describe("Workflow step prompt autocomplete", () => {
|
||||
|
||||
// Click to open the dropdown
|
||||
await agentSelect.click();
|
||||
await testPage.waitForTimeout(300);
|
||||
|
||||
// Select the first non-"none" option (skip "No profile override")
|
||||
// Select the first non-"none" option (skip "No profile override").
|
||||
// `count()` is a one-shot read, not an auto-retrying assertion, so gate on
|
||||
// the listbox being populated before counting instead of sleeping first.
|
||||
//
|
||||
// Visibility of the *first* option is not that gate: "No profile override"
|
||||
// is static markup and is already there while the settings bootstrap is
|
||||
// still loading profiles, so a count() taken on it reads 1 and skips the
|
||||
// test. Poll the count itself, which is the thing the branch below reads.
|
||||
const options = testPage.getByRole("option");
|
||||
await expect(options.first()).toBeVisible({ timeout: 5_000 });
|
||||
await expect
|
||||
.poll(() => options.count(), { timeout: 5_000 })
|
||||
.toBeGreaterThan(1)
|
||||
// A workspace with genuinely no profiles is a legitimate skip, so this
|
||||
// stays tolerant; the poll only removes the race with a slow bootstrap.
|
||||
.catch(() => undefined);
|
||||
const optionCount = await options.count();
|
||||
// Need at least 2 options (none + at least one profile)
|
||||
if (optionCount < 2) {
|
||||
@@ -134,12 +149,12 @@ test.describe("Workflow step prompt autocomplete", () => {
|
||||
const profileOption = options.nth(1);
|
||||
const profileName = await profileOption.textContent();
|
||||
await profileOption.click();
|
||||
await testPage.waitForTimeout(1000);
|
||||
|
||||
// The select should now show the selected profile, not revert to "No profile override"
|
||||
const updatedText = await agentSelect.textContent();
|
||||
expect(updatedText).toContain(profileName?.trim() ?? "");
|
||||
expect(updatedText).not.toContain("No profile override");
|
||||
// The select should now show the selected profile, not revert to "No
|
||||
// profile override". Assert both halves with auto-retrying matchers rather
|
||||
// than sleeping and then taking a single non-retrying textContent sample.
|
||||
await expect(agentSelect).toContainText(profileName?.trim() ?? "", { timeout: 10_000 });
|
||||
await expect(agentSelect).not.toContainText("No profile override");
|
||||
await page.saveChanges();
|
||||
|
||||
// Reload the page and verify it persisted
|
||||
@@ -150,12 +165,10 @@ test.describe("Workflow step prompt autocomplete", () => {
|
||||
// Click the same step again
|
||||
const reloadedSteps = reloadedCard.locator(".group.relative");
|
||||
await reloadedSteps.first().click();
|
||||
await testPage.waitForTimeout(500);
|
||||
|
||||
const reloadedSelect = reloadedCard.getByTestId("step-agent-profile-select");
|
||||
await expect(reloadedSelect).toBeVisible();
|
||||
const persistedText = await reloadedSelect.textContent();
|
||||
expect(persistedText).toContain(profileName?.trim() ?? "");
|
||||
await expect(reloadedSelect).toContainText(profileName?.trim() ?? "", { timeout: 10_000 });
|
||||
|
||||
// Clean up: reset the step agent profile
|
||||
const stepId = seedData.steps[0]?.id;
|
||||
|
||||
Reference in New Issue
Block a user