feat(agent): support Responses steering and project-aware tools
Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running turns active while users steer or queue follow-ups and edit pending delivery. Refresh the model catalog before generation and preserve approval mode selection. Integrate scoped investigation messaging/presentation, geographic place selection, current Agent composer styles and supporting workbench contracts. Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { TEST_PROJECT_ID } from "./support/mock-scada-api";
|
||||
import { expectThemeColor } from "./support/theme-colors";
|
||||
|
||||
const first = { id: "place-nanqiao-north", name: "南桥路", address: "上海市奉贤区南桥路1号", longitude: 121.458891, latitude: 30.916188, category: "道路名", district: "奉贤区" };
|
||||
const second = { id: "place-nanqiao-south", name: "南桥路", address: "上海市奉贤区南桥路100号", longitude: 121.459301, latitude: 30.914195, category: "道路名", district: "奉贤区" };
|
||||
const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id", "Access-Control-Allow-Methods": "POST, OPTIONS" };
|
||||
|
||||
async function setup(page: Page) {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
}
|
||||
|
||||
async function openSearch(page: Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const trigger = page.getByRole("button", { name: "地址定位:查询地址并定位到地图" });
|
||||
await expect(trigger).toBeEnabled({ timeout: 20_000 });
|
||||
await trigger.click();
|
||||
return page.getByRole("region", { name: "地址定位", exact: true });
|
||||
}
|
||||
|
||||
for (const width of [1280, 1023, 375, 320]) {
|
||||
test(`place search selects and switches candidates at ${width}px`, async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await setup(page);
|
||||
let calls = 0;
|
||||
await page.route("**/api/v1/place-search-requests", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
|
||||
expect(route.request().postDataJSON()).toEqual({ keyword: "南桥路" });
|
||||
expect(route.request().headers()["x-project-id"]).toBe(TEST_PROJECT_ID);
|
||||
calls += 1;
|
||||
await route.fulfill({ headers, json: { items: [first, second], total: 2, truncated: false, region: "奉贤区" } });
|
||||
});
|
||||
const panel = await openSearch(page);
|
||||
await expect(panel).toContainText("奉贤区 · 天地图地名搜索");
|
||||
const input = panel.getByLabel("查询地址");
|
||||
const query = panel.getByRole("button", { name: "搜索地名" });
|
||||
await expectThemeColor(input, "background-color", "--surface-reading");
|
||||
const [inputBox, queryBox] = await Promise.all([input.boundingBox(), query.boundingBox()]);
|
||||
expect(inputBox!.height).toBe(40);
|
||||
expect(queryBox!.height).toBe(40);
|
||||
expect(inputBox!.y).toBe(queryBox!.y);
|
||||
await expect(query).toBeDisabled();
|
||||
await input.fill(" 南桥路 ");
|
||||
await input.press("Enter");
|
||||
const candidates = panel.getByRole("region", { name: "搜索结果" });
|
||||
await expect(candidates.getByRole("button")).toHaveCount(2);
|
||||
const marker = page.locator('[data-geocoding-marker="true"]');
|
||||
await expect(marker).toHaveCount(0);
|
||||
await expect(panel.getByRole("status")).toContainText("选择一个候选位置");
|
||||
const resultHeader = candidates.locator(":scope > div").first();
|
||||
const measureFrame = () => Promise.all([panel.boundingBox(), input.boundingBox(), resultHeader.boundingBox()]);
|
||||
const listFrame = await measureFrame();
|
||||
const listViewport = await candidates.locator("ul").boundingBox();
|
||||
const listName = candidates.locator("span.font-semibold").first();
|
||||
const nameFontSize = await listName.evaluate((element) => getComputedStyle(element).fontSize);
|
||||
await page.screenshot({ path: test.info().outputPath(`place-candidates-${width}.png`) });
|
||||
await candidates.getByRole("button", { name: "定位 南桥路 上海市奉贤区南桥路1号", exact: true }).click();
|
||||
const selected = panel.getByRole("region", { name: "定位结果" });
|
||||
await expect(selected).toContainText("上海市奉贤区南桥路1号");
|
||||
await expect(selected.getByLabel("经纬度坐标").locator("dd")).toHaveText("121.458891, 30.916188");
|
||||
await expect(marker).toHaveCount(1);
|
||||
await expect(candidates.getByRole("button", { name: "更换位置" })).toBeVisible();
|
||||
await expect(candidates.locator("ul")).toHaveCount(0);
|
||||
const detailFrame = await measureFrame();
|
||||
const detailViewport = await selected.boundingBox();
|
||||
for (let index = 0; index < listFrame.length; index += 1) {
|
||||
expect(detailFrame[index]).toEqual(listFrame[index]);
|
||||
}
|
||||
expect(detailViewport).toEqual(listViewport);
|
||||
await expect(selected.locator("span.font-semibold")).toHaveCSS("font-size", nameFontSize);
|
||||
await candidates.getByRole("button", { name: "更换位置" }).click();
|
||||
await expect(selected).toHaveCount(0);
|
||||
expect(await measureFrame()).toEqual(listFrame);
|
||||
await test.info().attach("list-detail-frame", { body: JSON.stringify({ width, listFrame, detailFrame, listViewport, detailViewport }), contentType: "application/json" });
|
||||
await candidates.getByRole("button", { name: "定位 南桥路 上海市奉贤区南桥路100号", exact: true }).click();
|
||||
await expect(selected).toContainText("上海市奉贤区南桥路100号");
|
||||
await expect(selected.getByLabel("经纬度坐标").locator("dd")).toHaveText("121.459301, 30.914195");
|
||||
await expect(marker).toHaveCount(1);
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const center = window.__waterNetworkMap?.getCenter();
|
||||
return center ? [Number(center.lng.toFixed(6)), Number(center.lat.toFixed(6))] : null;
|
||||
})).toEqual([121.459301, 30.914195]);
|
||||
await selected.getByRole("button", { name: "重新定位" }).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: test.info().outputPath(`place-selected-${width}.png`) });
|
||||
await selected.getByRole("button", { name: "重新定位" }).click();
|
||||
expect(calls).toBe(1);
|
||||
await selected.getByRole("button", { name: "清除结果" }).click();
|
||||
await expect(marker).toHaveCount(0);
|
||||
await expect(candidates).toHaveCount(0);
|
||||
await expect(input).toHaveValue("");
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [1280, 320]) {
|
||||
test(`ten candidates have no paging or checkmark and preserve keyboard focus at ${width}px`, async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.setViewportSize({ width, height: width === 320 ? 812 : 900 });
|
||||
await setup(page);
|
||||
const names = ["南桥饭店", "南桥饭店南楼", "南桥饭店东苑"];
|
||||
const items = Array.from({ length: 10 }, (_, index) => ({
|
||||
...first, id: `hotel-test-${index}`, name: names[index] ?? `南桥饭店测试入口${index + 1}`,
|
||||
address: "南桥路111号", district: "奉贤区", category: "宾馆"
|
||||
}));
|
||||
await page.route("**/api/v1/place-search-requests", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
|
||||
await route.fulfill({ headers, json: { items, total: 10, truncated: true, region: "奉贤区" } });
|
||||
});
|
||||
const panel = await openSearch(page);
|
||||
await panel.getByLabel("查询地址").fill("南桥饭店");
|
||||
await panel.getByLabel("查询地址").press("Enter");
|
||||
const results = panel.getByRole("region", { name: "搜索结果" });
|
||||
await expect(results).toContainText("前 10 个结果");
|
||||
await expect(results).toContainText("最多显示 10 个位置,请细化关键词");
|
||||
await expect(results.getByLabel("搜索结果分页")).toHaveCount(0);
|
||||
await expect(results.getByRole("button", { name: "上一页" })).toHaveCount(0);
|
||||
await expect(results.getByRole("button", { name: "下一页" })).toHaveCount(0);
|
||||
const list = results.locator("ul");
|
||||
await expect(list.locator("li")).toHaveCount(10);
|
||||
await expect(list).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(list).toHaveCSS("border-left-width", "0px");
|
||||
await expect(list).toHaveCSS("border-right-width", "0px");
|
||||
const geometry = await list.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = Array.from(element.querySelectorAll("li")).map((row) => row.getBoundingClientRect());
|
||||
return { visibleRows: rows.filter((row) => row.top >= viewport.top && row.bottom <= viewport.bottom).length,
|
||||
rowHeight: rows[0].height, height: viewport.height, scrollable: element.scrollHeight > element.clientHeight };
|
||||
});
|
||||
expect(geometry.visibleRows).toBeGreaterThanOrEqual(width === 320 ? 2 : 4);
|
||||
expect(geometry.rowHeight).toBeGreaterThanOrEqual(64);
|
||||
expect(geometry.scrollable).toBe(true);
|
||||
const [panelBox, listBox] = await Promise.all([panel.boundingBox(), list.boundingBox()]);
|
||||
expect(listBox!.y + listBox!.height).toBeLessThanOrEqual(panelBox!.y + panelBox!.height - 10);
|
||||
await test.info().attach("candidate-geometry", { body: JSON.stringify(geometry), contentType: "application/json" });
|
||||
await page.screenshot({ path: test.info().outputPath(`place-ten-candidates-${width}.png`) });
|
||||
const target = results.getByRole("button", { name: "定位 南桥饭店 南桥路111号", exact: true });
|
||||
await target.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const change = results.getByRole("button", { name: "更换位置" });
|
||||
await expect(change).toBeFocused();
|
||||
await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("121.458891, 30.916188");
|
||||
const detail = panel.getByRole("region", { name: "定位结果" });
|
||||
await expectThemeColor(detail.locator("span.font-semibold"), "color", "--ink");
|
||||
await expect(detail.locator("span.font-semibold").locator("xpath=../../.."))
|
||||
.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(target).toBeFocused();
|
||||
await expect(target).toHaveAttribute("aria-pressed", "true");
|
||||
await expectThemeColor(target, "background-color", "--action-selection-soft");
|
||||
await expectThemeColor(target.locator("span.font-semibold"), "color", "--action-blue-hover");
|
||||
await expect(target.locator("svg")).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath(`place-color-selected-${width}.png`) });
|
||||
const eighth = results.getByRole("button", { name: `定位 ${items[7].name} ${items[7].address}`, exact: true });
|
||||
await eighth.scrollIntoViewIfNeeded();
|
||||
await eighth.click();
|
||||
await expect(detail).toContainText(items[7].name);
|
||||
await expect(detail.getByLabel("经纬度坐标")).toBeInViewport({ ratio: 1 });
|
||||
const detailBox = await detail.boundingBox();
|
||||
const actionBox = await detail.getByRole("button", { name: "重新定位" }).boundingBox();
|
||||
expect(actionBox!.y + actionBox!.height).toBeLessThanOrEqual(detailBox!.y + detailBox!.height);
|
||||
await expect(detail.getByText("08", { exact: true })).toHaveCount(0);
|
||||
await expectThemeColor(detail.locator("span.font-semibold"), "color", "--ink");
|
||||
await page.screenshot({ path: test.info().outputPath(`place-neutral-detail-${width}.png`) });
|
||||
await change.click();
|
||||
await expect(eighth).toHaveAttribute("aria-pressed", "true");
|
||||
const isSelectedVisible = () => eighth.evaluate((element) => {
|
||||
const viewport = element.closest("ul")!.getBoundingClientRect();
|
||||
const row = element.getBoundingClientRect();
|
||||
return row.top >= viewport.top && row.bottom <= viewport.bottom;
|
||||
});
|
||||
await expect.poll(isSelectedVisible).toBe(true);
|
||||
expect(await list.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||
await eighth.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(change).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(eighth).toBeFocused();
|
||||
await expect.poll(isSelectedVisible).toBe(true);
|
||||
await page.screenshot({ path: test.info().outputPath(`place-return-to-selection-${width}.png`) });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||
});
|
||||
}
|
||||
|
||||
test("place search reports errors and distinguishes an empty Fengxian search", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
await setup(page);
|
||||
let fail = true;
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||
const queries: string[] = [];
|
||||
await page.route("**/api/v1/place-search-requests", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers }); return; }
|
||||
const body = route.request().postDataJSON() as { keyword: string };
|
||||
queries.push(body.keyword);
|
||||
if (fail) {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, headers, json: { detail: "地名搜索服务暂不可用" } });
|
||||
return;
|
||||
}
|
||||
const empty = body.keyword === "未知地名";
|
||||
await route.fulfill({ headers, json: {
|
||||
items: empty ? [] : [first], total: empty ? 0 : 1, truncated: false, region: "奉贤区"
|
||||
} });
|
||||
});
|
||||
const panel = await openSearch(page);
|
||||
const input = panel.getByLabel("查询地址");
|
||||
const query = panel.getByRole("button", { name: "搜索地名" });
|
||||
await input.fill("南桥路");
|
||||
await query.click();
|
||||
await expect(panel.getByRole("status")).toContainText("正在搜索地名");
|
||||
await expect(query).toBeDisabled();
|
||||
release();
|
||||
await expect(panel.getByRole("alert")).toContainText("地名搜索服务暂不可用");
|
||||
fail = false;
|
||||
await query.click();
|
||||
await expect(panel.getByRole("region", { name: "搜索结果" }).locator("li")).toHaveCount(1);
|
||||
expect(queries).toEqual(["南桥路", "南桥路"]);
|
||||
await input.fill("未知地名");
|
||||
await query.click();
|
||||
await expect(panel.getByRole("status")).toContainText("奉贤区内未找到匹配位置");
|
||||
await expect(panel.getByRole("region", { name: "搜索结果" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-geocoding-marker="true"]')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(320);
|
||||
await page.screenshot({ path: test.info().outputPath("place-empty-320.png") });
|
||||
});
|
||||
Reference in New Issue
Block a user