mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 02:15:26 -06:00
475d20a034
* fix(ui): stop the model picker from nagging about refresh failures A failed background catalog refresh rendered a "Couldn't refresh models" banner plus a Retry button on top of a complete, working model list, and replaced the composer trigger's model name with that error text. The picker already keeps the last-known catalog and re-requests it on every open, so the operator was being asked to press a button for something the UI does by itself. The error stays recorded on the host — it is what drops the stale availability gate so the composer remains usable — but it is no longer surfaced while there are models to show. Only a genuinely empty catalog still says "Models unavailable". The new-session picker gains the chat picker's open-triggers-revalidate behavior so re-opening it is the retry there too, instead of dead-ending until a page reload. Two adjacent simplifications in the same surface: - Model rows reserve their provider-icon slot as an invisible stem, so names line up with the provider heading label (same 34px stem grouped and filtered, nothing shifts while typing). - The "Using agent default" footer is gone: the default row already carries a DEFAULT badge and the checkmark. Typing "default" in the picker search now matches the default model instead. Proof: scripts/capture-model-picker-proof.mts captures the open picker against a mocked gateway; alignment delta 0px (was -24px), search "default" matches the default row (was nothing), and a failed models.list leaves no catalog-state element with all rows intact. * fix(ci): register the model-picker proof script and await its picker revalidation The unused-file scan needs every scripts/ entry point referenced, like the sibling ui:proof:* recipes. The catalog-reconnect assertion also has to wait for the picker's own metadata request instead of reading the log the moment the rows render. * test(ui): split model-catalog scenarios out of the new-session e2e file The catalog-reconnect file hit the 1000-line cap. Its model-catalog metadata failure/recovery pair is a separate surface from CLI-agent targets, terminal start, and draft reconnect, so it moves to its own file rather than earning a max-lines suppression. * test(audit): give the pinned-reader contract test a realistic timeout It adds a pinned-SHA git worktree and cold-compiles the audit and state modules under tsx, which takes minutes on a contended runner. The 120s default made it fail by construction; it timed out on an unrelated PR shard while passing locally at ~55s.
155 lines
5.4 KiB
TypeScript
155 lines
5.4 KiB
TypeScript
#!/usr/bin/env node
|
|
// Captures chat model-picker proof shots: the open picker inheriting the agent
|
|
// default, the same picker with a session override, and the picker after a
|
|
// failed catalog refresh. Also reports the x offset between the provider
|
|
// heading label and the model row name (the row-alignment change).
|
|
import { mkdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { chromium } from "playwright";
|
|
import {
|
|
canRunPlaywrightChromium,
|
|
installMockGateway,
|
|
resolvePlaywrightChromiumExecutablePath,
|
|
startControlUiE2eServer,
|
|
} from "../ui/src/test-helpers/control-ui-e2e.ts";
|
|
|
|
function readOption(name: string): string | undefined {
|
|
const prefix = `--${name}=`;
|
|
const inline = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
|
|
if (inline) {
|
|
return inline.slice(prefix.length);
|
|
}
|
|
const index = process.argv.indexOf(`--${name}`);
|
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
}
|
|
|
|
const outputDir = path.resolve(
|
|
readOption("output-dir") ?? ".artifacts/control-ui-e2e/model-picker-proof",
|
|
);
|
|
const label = readOption("label") ?? "after";
|
|
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
|
if (!canRunPlaywrightChromium(executablePath)) {
|
|
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
|
|
}
|
|
|
|
const models = [
|
|
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai", contextWindow: 400_000 },
|
|
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai", contextWindow: 1_000_000 },
|
|
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai", contextWindow: 1_000_000 },
|
|
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", provider: "openai", contextWindow: 1_000_000 },
|
|
{
|
|
id: "claude-sonnet-4-6",
|
|
name: "Claude Sonnet 4.6",
|
|
provider: "anthropic",
|
|
contextWindow: 200_000,
|
|
},
|
|
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic", contextWindow: 200_000 },
|
|
];
|
|
|
|
await mkdir(outputDir, { recursive: true });
|
|
const server = await startControlUiE2eServer(undefined, { source: true });
|
|
const browser = await chromium.launch({ executablePath });
|
|
const context = await browser.newContext({
|
|
colorScheme: "dark",
|
|
viewport: { width: 1280, height: 900 },
|
|
});
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30_000);
|
|
|
|
try {
|
|
const gateway = await installMockGateway(page, { agentModel: "openai/gpt-5.5", models });
|
|
await page.goto(`${server.baseUrl}chat`);
|
|
await gateway.waitForRequest("chat.startup");
|
|
|
|
const composer = page.locator(".agent-chat__input");
|
|
await composer.waitFor({ state: "visible" });
|
|
const trigger = composer.locator('[data-chat-model-select="true"]');
|
|
const menu = composer.locator(".chat-controls__model-menu");
|
|
|
|
const openPicker = async () => {
|
|
await trigger.click();
|
|
await menu.waitFor({ state: "visible" });
|
|
await page.waitForTimeout(500);
|
|
};
|
|
const closePicker = async () => {
|
|
await page.keyboard.press("Escape");
|
|
await page.waitForTimeout(250);
|
|
};
|
|
const shoot = async (name: string) => {
|
|
const box = await menu.boundingBox();
|
|
if (!box) {
|
|
throw new Error(`model menu has no layout box for ${name}`);
|
|
}
|
|
await page.screenshot({
|
|
path: path.join(outputDir, `${label}-${name}.png`),
|
|
clip: {
|
|
x: Math.max(0, box.x - 8),
|
|
y: Math.max(0, box.y - 8),
|
|
width: box.width + 16,
|
|
height: box.height + 16,
|
|
},
|
|
});
|
|
};
|
|
|
|
await openPicker();
|
|
await shoot("default");
|
|
|
|
const headingLabel = menu.locator(".chat-controls__provider-heading span").last();
|
|
const rowName = menu.locator(".chat-controls__model-option-name").first();
|
|
const headingBox = await headingLabel.boundingBox();
|
|
const rowBox = await rowName.boundingBox();
|
|
|
|
const search = menu.locator("[data-chat-model-search]");
|
|
await search.fill("default");
|
|
await page.waitForTimeout(400);
|
|
await shoot("search-default");
|
|
const searchMatches = await menu
|
|
.locator("[data-chat-model-option]:not([hidden])")
|
|
.evaluateAll((rows) => rows.map((row) => row.textContent?.replace(/\s+/gu, " ").trim() ?? ""));
|
|
await search.fill("");
|
|
await page.waitForTimeout(250);
|
|
|
|
const override = menu.locator('[data-chat-model-option="openai/gpt-5.6-terra"]');
|
|
await override.click();
|
|
await page.waitForTimeout(600);
|
|
await openPicker();
|
|
await shoot("override");
|
|
await closePicker();
|
|
|
|
// A fresh page drops the per-client catalog cache, so the picker's own load
|
|
// reaches the mock gateway and fails there.
|
|
await gateway.setMethodResponse("models.list", {
|
|
__mockError: { code: "UNAVAILABLE", message: "mock catalog refresh failed" },
|
|
});
|
|
await page.reload();
|
|
await composer.waitFor({ state: "visible" });
|
|
await page.waitForTimeout(1000);
|
|
await openPicker();
|
|
await page.waitForTimeout(1500);
|
|
await shoot("refresh-failure");
|
|
const catalogStateText = await menu.locator("[data-chat-model-catalog-state]").allTextContents();
|
|
const rowsAfterFailure = await menu.locator("[data-chat-model-option]").count();
|
|
await closePicker();
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
label,
|
|
outputDir,
|
|
providerHeadingLabelX: headingBox?.x ?? null,
|
|
modelRowNameX: rowBox?.x ?? null,
|
|
alignmentDeltaPx: headingBox && rowBox ? rowBox.x - headingBox.x : null,
|
|
searchDefaultMatches: searchMatches,
|
|
catalogStateAfterRefreshFailure: catalogStateText,
|
|
modelRowsAfterRefreshFailure: rowsAfterFailure,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
} finally {
|
|
await context.close();
|
|
await browser.close();
|
|
await server.close();
|
|
}
|