Skip to content

Commit 8bd83e4

Browse files
test(ui): fix three flaky browser-suite waits (#2612)
--- *🤖 written by Claude (start)* ## Overview - **#2609** — the collapsed nav icon is measured against its own row rather than the rail's centre line, so the assertion no longer reports which engine drew the frame. - **#2610** — the chat fixture now says when a turn has finished, so the specs wait on that instead of on a 5s deadline over a ~3s scripted stream. - **#2611** — the step that deliberately does not wait chooses its namespace from the keyboard rather than by clicking a popup that is hit-tested where it is drawn, and the sibling test reads the address with `toHaveURL` instead of a bare `page.url()` that had no retry at all. - **Cluster setup** — `make create-kind-cluster` no longer races the apiserver it just created. `kind create cluster` waits for the control plane, and the MetalLB step waits on `/readyz` before its first apply — which had been failing with `net/http: TLS handshake timeout`. No timeouts were raised and no retries added; everything runs on Playwright's defaults, and the two writes #2611 exists for still land with nothing waited between them. ## Changelog Three flaky browser-suite waits now watch the signal they were standing in for. Closes #2609 Closes #2610 Closes #2611 ## Testing 1. `cd ui && yarn test:pw`. 2. Drop the `& .ant-menu-inline-collapsed .ant-menu-item` block from `ui/src/components/Structure/AppSidebar.tsx` and rerun `yarn playwright test shell-chrome` — it fails naming the row and both gaps. ## Release note ``` NONE ``` --- *🤖 written by Claude (end)* --------- Signed-off-by: Nicholas Bucher <behappy54321@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bdc3d6f commit 8bd83e4

8 files changed

Lines changed: 271 additions & 40 deletions

File tree

scripts/kind/setup-kind.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ else
3030
export KIND_EXPERIMENTAL_PROVIDER="${CONTAINER_RUNTIME}"
3131
kind create cluster --name "${KIND_CLUSTER_NAME}" \
3232
--config scripts/kind/kind-config.yaml \
33-
--image="kindest/node:v${KIND_IMAGE_VERSION}"
33+
--image="kindest/node:v${KIND_IMAGE_VERSION}" \
34+
--wait 60s
3435
fi
3536

3637
# 3. Add the registry config to the nodes

scripts/kind/setup-metallb.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ set -o nounset
77
METALLB_VERSION=${METALLB_VERSION:-v0.15.3}
88
KIND_CLUSTER_NAME=${KIND_CLUSTER_NAME:-kagent}
99

10+
# A just-created cluster can answer one request and stall on the next.
11+
for attempt in $(seq 1 30); do
12+
kubectl --context "kind-${KIND_CLUSTER_NAME}" get --raw=/readyz >/dev/null 2>&1 && break
13+
if [ "${attempt}" -eq 30 ]; then
14+
echo "ERROR: apiserver for kind-${KIND_CLUSTER_NAME} was still not ready after 60s."
15+
exit 1
16+
fi
17+
sleep 2
18+
done
19+
1020
kubectl --context "kind-${KIND_CLUSTER_NAME}" apply -f https://raw.githubusercontent.com/metallb/metallb/${METALLB_VERSION}/config/manifests/metallb-native.yaml
1121

1222
# Wait for MetalLB to become available.

ui/playwright/helpers/chat.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Driving a turn, and knowing when it is over.
3+
*
4+
* `expect(chat-cancel).toHaveCount(0)` is a wait wearing an assertion's clothes, and
5+
* its report reads "cancel should not be here" when the reply had simply not finished
6+
* (#2610). `MockChatClient` publishes a tally of turns started and finished — as
7+
* `src/mocks/transport.ts` does for RPC calls — so the wait can be on the event, and
8+
* the assertion after it on the page, at the default budget.
9+
*/
10+
11+
import { expect, type Page } from "@playwright/test";
12+
13+
/** Where `src/api/chat/mockChatClient.ts` publishes what it is doing. */
14+
const PROPERTY = "__kagentMockChat";
15+
16+
/** The tally, as the page holds it. */
17+
interface Turns {
18+
started: number;
19+
finished: number;
20+
}
21+
22+
/**
23+
* A sent turn, identified by the count of turns finished before it went — which is
24+
* what keeps the wait below about this turn and not the previous one.
25+
*/
26+
export interface SentTurn {
27+
readonly finishedBefore: number;
28+
}
29+
30+
function readTurns(page: Page): Promise<Turns | null> {
31+
return page.evaluate(
32+
(property) =>
33+
(window as unknown as Record<string, Turns | undefined>)[property] ?? null,
34+
PROPERTY,
35+
);
36+
}
37+
38+
/**
39+
* The token for the turn about to be sent. Polled because the counters are hung off
40+
* the chat client, which is built the first time a conversation is read.
41+
*/
42+
export async function beginTurn(page: Page): Promise<SentTurn> {
43+
await expect
44+
.poll(async () => await readTurns(page), {
45+
message:
46+
`The chat fixture published nothing on window.${PROPERTY}. Either the app is ` +
47+
`not running in mock mode, or this page never opened a conversation.`,
48+
})
49+
.not.toBeNull();
50+
51+
const turns = await readTurns(page);
52+
return { finishedBefore: turns!.finished };
53+
}
54+
55+
/** Types a message and sends it, returning the token its turn is waited on with. */
56+
export async function sendMessage(page: Page, text: string): Promise<SentTurn> {
57+
const turn = await beginTurn(page);
58+
await page.getByTestId("chat-input").fill(text);
59+
await page.getByTestId("chat-send").click();
60+
return turn;
61+
}
62+
63+
/**
64+
* Waits for the turn to stop streaming, then asserts the page has noticed. Both
65+
* halves matter: the first alone races the script, the second alone proves nothing.
66+
*/
67+
export async function expectTurnFinished(page: Page, turn: SentTurn): Promise<void> {
68+
await page.waitForFunction(
69+
({ property, after }) => {
70+
const turns = (window as unknown as Record<string, Turns | undefined>)[property];
71+
return turns !== undefined && turns.finished > after;
72+
},
73+
{ property: PROPERTY, after: turn.finishedBefore },
74+
);
75+
76+
await expect(
77+
page.getByTestId("chat-cancel"),
78+
"the composer should come back once the turn is over",
79+
).toHaveCount(0);
80+
}
81+
82+
/** Sends a message and waits out the turn it starts, asserting nothing in between. */
83+
export async function sendAndAwaitTurn(page: Page, text: string): Promise<void> {
84+
await expectTurnFinished(page, await sendMessage(page, text));
85+
}

ui/playwright/tests/chat/chat-errors.spec.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { test, expect } from "../../fixtures/test";
2+
import { sendAndAwaitTurn } from "../../helpers/chat";
23
import {
34
agentChat,
45
agentNewChat,
@@ -238,9 +239,7 @@ test("chat: a question the agent is waiting on is said, and can be given up", as
238239
// The proof that the turn really closed is a *new* turn running, not an alert
239240
// that disappeared: a task still parked would refuse this.
240241
await page.goto(`${AGENT_CHAT}?chat=ok`);
241-
await page.getByTestId("chat-input").fill("How many pods are running?");
242-
await page.getByTestId("chat-send").click();
243-
await expect(page.getByTestId("chat-cancel")).toHaveCount(0, { timeout: 30_000 });
242+
await sendAndAwaitTurn(page, "How many pods are running?");
244243
await expect(page.getByTestId("chat-turn-error")).toHaveCount(0);
245244
});
246245
});
@@ -273,9 +272,7 @@ test("chat: a question can be discarded instead of answered", async ({ page }) =
273272

274273
await test.step("3. and an unrelated message is accepted again", async () => {
275274
await page.goto(`${AGENT_CHAT}?chat=ok`);
276-
await page.getByTestId("chat-input").fill("How many pods are running?");
277-
await page.getByTestId("chat-send").click();
278-
await expect(page.getByTestId("chat-cancel")).toHaveCount(0, { timeout: 30_000 });
275+
await sendAndAwaitTurn(page, "How many pods are running?");
279276
await expect(page.getByTestId("chat-turn-error")).toHaveCount(0);
280277
});
281278
});

ui/playwright/tests/chat/chat.spec.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { test, expect } from "../../fixtures/test";
22
import { SIBLING_OF_READY, agentChat, instances, loadPage } from "../../helpers/app";
3+
import {
4+
beginTurn,
5+
expectTurnFinished,
6+
sendAndAwaitTurn,
7+
sendMessage,
8+
type SentTurn,
9+
} from "../../helpers/chat";
310

411
/**
512
* Chat — the conversation journey.
@@ -63,6 +70,8 @@ test("chat: history, sending, streaming, and tool rendering", async ({ page }) =
6370
// Scoped by role: the agent quotes the question back in its reply, so a plain
6471
// text filter matches the answer as well as the question that prompted it.
6572
const userMessages = page.locator('[data-testid="chat-message"][data-role="user"]');
73+
// Held across steps because a turn is sent in one and finishes in another.
74+
let turn: SentTurn;
6675

6776
await test.step("1. the page opens on the conversation, ready to be typed into", async () => {
6877
await loadPage(page, AGENT_CHAT);
@@ -123,8 +132,7 @@ test("chat: history, sending, streaming, and tool rendering", async ({ page }) =
123132
});
124133

125134
await test.step("7. sending a message adds it to the transcript immediately", async () => {
126-
await page.getByTestId("chat-input").fill(FIRST_QUESTION);
127-
await page.getByTestId("chat-send").click();
135+
turn = await sendMessage(page, FIRST_QUESTION);
128136

129137
// The reader's own words, before the server has said anything. The fixture
130138
// does not echo them back and neither does the gateway, so this can only pass
@@ -159,8 +167,8 @@ test("chat: history, sending, streaming, and tool rendering", async ({ page }) =
159167
});
160168

161169
await test.step("11. the turn finishes, the composer comes back, and the indicator settles", async () => {
170+
await expectTurnFinished(page, turn);
162171
await expect(page.getByTestId("chat-send")).toBeVisible();
163-
await expect(page.getByTestId("chat-cancel")).toHaveCount(0);
164172
// Nothing reported once the turn is over: the status line belongs to a turn in
165173
// flight, so a finished one leaves it with nothing to say.
166174
await expect(page.getByTestId("chat-status")).toHaveCount(0);
@@ -169,15 +177,14 @@ test("chat: history, sending, streaming, and tool rendering", async ({ page }) =
169177
await test.step("12. a second question behaves exactly like the first", async () => {
170178
// The report was that further questions behaved the same way — only the agent's
171179
// replies appeared. So the second turn is driven, not assumed from the first.
172-
await page.getByTestId("chat-input").fill(SECOND_QUESTION);
173-
await page.getByTestId("chat-send").click();
180+
turn = await sendMessage(page, SECOND_QUESTION);
174181

175182
await expect(
176183
userMessages.filter({ hasText: SECOND_QUESTION }),
177184
"the second question should be on screen as soon as it is sent, like the first",
178185
).toHaveCount(1);
179186

180-
await expect(page.getByTestId("chat-cancel")).toHaveCount(0);
187+
await expectTurnFinished(page, turn);
181188
await expect(
182189
messages.last().getByTestId("chat-message-text"),
183190
"the second reply should assemble exactly once",
@@ -230,9 +237,7 @@ test("chat: history, sending, streaming, and tool rendering", async ({ page }) =
230237
* not something a glance at a still will catch.
231238
*/
232239
await page.setViewportSize({ width: 1440, height: 420 });
233-
await page.getByTestId("chat-input").fill("one more, to make the page scroll");
234-
await page.getByTestId("chat-send").click();
235-
await expect(page.getByTestId("chat-cancel")).toHaveCount(0);
240+
await sendAndAwaitTurn(page, "one more, to make the page scroll");
236241

237242
/*
238243
* The box that scrolls must end above the box you type in.
@@ -388,6 +393,8 @@ test("chat: an agent's Markdown renders as elements, not as characters", async (
388393
// the render, and the assertion below is about the render.
389394
await expect(page.getByTestId("chat-message")).toHaveCount(4);
390395

396+
// Taken before the message is sent, so the wait below is about this turn.
397+
const turn = await beginTurn(page);
391398
await page.getByTestId("chat-input").fill("How many pods are running?");
392399
await expect(page.getByTestId("chat-send")).toBeEnabled();
393400
await page.getByTestId("chat-send").click();
@@ -396,7 +403,7 @@ test("chat: an agent's Markdown renders as elements, not as characters", async (
396403
// transcript before the reply exists, and without the second it can read a half-streamed
397404
// list whose item count is whatever had arrived.
398405
await expect(page.getByTestId("chat-cancel")).toBeVisible();
399-
await expect(page.getByTestId("chat-cancel")).toHaveCount(0);
406+
await expectTurnFinished(page, turn);
400407

401408
const answer = page.getByTestId("chat-message").last().getByTestId("chat-message-text");
402409

ui/playwright/tests/lists/list-filters.spec.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,31 @@ import { dataRows, expectSettled, loadPage, rowNamed, routes } from "../../helpe
3434
/** Opens a filter's popup and ticks one option by the label the reader sees. */
3535
async function chooseFilter(page: Page, filterTestId: string, label: string) {
3636
await page.getByTestId(filterTestId).click();
37-
await page.locator(`.ant-select-item-option[title="${label}"]`).click();
37+
const option = page.locator(`.ant-select-item-option[title="${label}"]`);
38+
await option.click();
39+
// The click, confirmed where it happened: a click on a popup that has moved or
40+
// closed under it selects nothing, silently, and is reported much later as a pill
41+
// that never appeared.
42+
await expect(option).toHaveAttribute("aria-selected", "true");
3843
// Otherwise the popup covers the pill row the next step asserts on.
3944
await page.keyboard.press("Escape");
4045
}
4146

47+
/**
48+
* The same choice, made from the keyboard.
49+
*
50+
* For the step that deliberately does not wait: a popup is hit-tested where it is
51+
* drawn, so clicking one while another write is re-rendering the page underneath is a
52+
* race of the test's own making, on top of the race it is trying to observe. Typing
53+
* and pressing Enter goes to the control's own input, which does not move.
54+
*/
55+
async function typeFilter(page: Page, filterTestId: string, label: string) {
56+
const input = page.getByTestId(filterTestId).locator("input");
57+
await input.fill(label);
58+
await input.press("Enter");
59+
await input.press("Escape");
60+
}
61+
4262
test("lists: a page's filter narrows that page's own rows", async ({ page }) => {
4363
/*
4464
* What the browser is needed to say, and nothing more.
@@ -82,8 +102,19 @@ test("lists: a page's filter narrows that page's own rows", async ({ page }) =>
82102
*/
83103
await loadPage(page, routes.models, { title: "Models" });
84104
await expectSettled(page);
105+
85106
await page.getByTestId("models-filters-search").fill("model");
86-
await chooseFilter(page, "models-filters-filter-ns", "kagent");
107+
await typeFilter(page, "models-filters-filter-ns", "kagent");
108+
109+
// Read from the address first: not a longer wait, a more specific one. Stopping
110+
// here names the write that went missing, where a pill that never appeared could
111+
// be that or a page that failed to draw what it had.
112+
await expect(page, "the search term should survive the filter write").toHaveURL(
113+
/[?&]q=model(&|$)/,
114+
);
115+
await expect(page, "the filter should survive the search write").toHaveURL(
116+
/[?&]ns=kagent(&|$)/,
117+
);
87118

88119
await expect(page.getByTestId("models-filters-pill-search")).toBeVisible();
89120
await expect(page.getByTestId("models-filters-pill-ns-kagent")).toBeVisible();
@@ -97,11 +128,12 @@ test("lists: a narrowed view is an address, so it survives a reload", async ({ p
97128
await expectSettled(page);
98129

99130
await page.getByTestId("models-filters-search").fill("config");
100-
await chooseFilter(page, "models-filters-filter-ns", "kagent");
131+
await typeFilter(page, "models-filters-filter-ns", "kagent");
101132

102-
const url = new URL(page.url());
103-
expect(url.searchParams.get("q")).toBe("config");
104-
expect(url.searchParams.getAll("ns")).toEqual(["kagent"]);
133+
// `page.url()` was a bare read with no retry, so it reported the address as it
134+
// was a tick before the second write reached it.
135+
await expect(page).toHaveURL(/[?&]q=config(&|$)/);
136+
await expect(page).toHaveURL(/[?&]ns=kagent(&|$)/);
105137
});
106138

107139
await test.step("2. sorting is in the address too, and the header shows it", async () => {

0 commit comments

Comments
 (0)