Files
fathah--hermes-desktop/scripts/drive-live-regression-suite.js
2026-07-13 13:36:53 +08:00

1115 lines
37 KiB
JavaScript

/**
* Full live visual regression suite for Hermes One.
*
* This drives the running Electron app through Chrome DevTools Protocol and
* checks the rendered UI. It intentionally uses the real chat composer,
* sessions screen, model picker, file input, and screenshots instead of
* database-only probes.
*
* Start the app first:
*
* $env:ENABLE_CDP = "1"; $env:CDP_PORT = "19333"; npm run dev:sandbox
*
* Then run:
*
* node scripts/drive-live-regression-suite.js
*
* Useful options:
*
* --modes=local,remote,ssh
* --skip-generated
* --report=.sandbox/live-visual-regression/latest.json
* --paste-image=C:\path\to\image.png
* --good-provider=openai-codex --good-model=gpt-5.5 --good-base-url=
* --bad-provider=openai --bad-model=definitely-not-a-real-model-xyz
* --remote-url=http://127.0.0.1:19080 --remote-token=...
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
const { attach } = require("./e2e-attach");
const DEFAULT_REMOTE_URL = "http://127.0.0.1:19080";
const DEFAULT_REMOTE_MEDIA = "MEDIA:/opt/data/images/duck_bathtub.png";
const DEFAULT_LOCAL_MEDIA =
"C:\\Users\\pmos6\\Documents\\AI-Playground\\media\\toy_duck_bathtub.png";
const DEFAULT_GOOD_MODEL = {
provider: "openai-codex",
model: "gpt-5.5",
baseUrl: "",
};
// Used only as a fallback. The suite normally creates temporary dead-route
// custom models so the bad-model checks do not depend on catalog behavior.
const DEFAULT_BAD_MODEL = {
provider: "custom",
model: "visual-dead-route",
baseUrl: "http://127.0.0.1:9/v1",
};
const DEAD_ROUTE_BASE_URL = "http://127.0.0.1:9/v1";
function mediaPathForMode(mode) {
if (mode === "local") return LOCAL_MEDIA;
return REMOTE_MEDIA.startsWith("MEDIA:") ? REMOTE_MEDIA.slice("MEDIA:".length) : REMOTE_MEDIA;
}
function imageKey(image) {
return String(image && image.src ? image.src : "")
.replace(/\\/g, "/")
.toLowerCase();
}
function duplicateImageGroups(images) {
const counts = new Map();
for (const image of images || []) {
const key = imageKey(image);
if (!key) continue;
counts.set(key, (counts.get(key) || 0) + 1);
}
return Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([src, count]) => ({ src, count }));
}
function assertNoDuplicateRenderedImages(state, label) {
const duplicates = duplicateImageGroups(state.bubbleImages);
assert(duplicates.length === 0, `${label} rendered duplicate images`, {
duplicates,
bubbleImages: state.bubbleImages,
agentTail: state.agentText.slice(-1_500),
});
}
function badRouteModel(mode, runId, suffix) {
return {
name: `Visual Bad ${mode} ${runId} ${suffix}`,
provider: BAD_MODEL.provider || "custom",
model: `${BAD_MODEL.model || "visual-dead-route"}-${mode}-${runId}-${suffix}`.toLowerCase(),
baseUrl: BAD_MODEL.baseUrl || DEAD_ROUTE_BASE_URL,
};
}
function parseArgs(argv) {
const args = {};
for (const raw of argv) {
if (!raw.startsWith("--")) continue;
const eq = raw.indexOf("=");
const key = eq === -1 ? raw.slice(2) : raw.slice(2, eq);
const camelKey = key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
const value = eq === -1 ? true : raw.slice(eq + 1);
if (eq === -1) {
args[key] = true;
} else {
args[key] = value;
}
args[camelKey] = value;
}
return args;
}
const args = parseArgs(process.argv.slice(2));
const RUN_ID =
args.runId ||
new Date().toISOString().replace(/\D/g, "").slice(0, 14);
const MODES = String(args.modes || process.env.HERMES_VISUAL_MODES || "local,remote,ssh")
.split(",")
.map((m) => m.trim())
.filter(Boolean);
const CDP_PORT = args.cdpPort || process.env.CDP_PORT || "19333";
const REMOTE_URL =
args.remoteUrl || process.env.HERMES_REMOTE_URL || DEFAULT_REMOTE_URL;
const REMOTE_MEDIA =
args.remoteMedia || process.env.HERMES_REMOTE_MEDIA || DEFAULT_REMOTE_MEDIA;
const LOCAL_MEDIA =
args.localMedia || process.env.HERMES_LOCAL_MEDIA || DEFAULT_LOCAL_MEDIA;
const GOOD_MODEL = {
provider:
args.goodProvider ||
process.env.HERMES_VISUAL_GOOD_PROVIDER ||
DEFAULT_GOOD_MODEL.provider,
model:
args.goodModel ||
process.env.HERMES_VISUAL_GOOD_MODEL ||
DEFAULT_GOOD_MODEL.model,
baseUrl:
args.goodBaseUrl ||
process.env.HERMES_VISUAL_GOOD_BASE_URL ||
DEFAULT_GOOD_MODEL.baseUrl,
};
const BAD_MODEL = {
provider:
args.badProvider ||
process.env.HERMES_VISUAL_BAD_PROVIDER ||
DEFAULT_BAD_MODEL.provider,
model:
args.badModel ||
process.env.HERMES_VISUAL_BAD_MODEL ||
DEFAULT_BAD_MODEL.model,
baseUrl:
args.badBaseUrl ||
process.env.HERMES_VISUAL_BAD_BASE_URL ||
DEFAULT_BAD_MODEL.baseUrl,
};
const OUTPUT_DIR = path.resolve(
args.outputDir ||
process.env.HERMES_VISUAL_OUTPUT_DIR ||
path.join(".sandbox", "live-visual-regression", RUN_ID),
);
const REPORT_PATH = path.resolve(
args.report ||
process.env.HERMES_VISUAL_REPORT ||
path.join(OUTPUT_DIR, "report.json"),
);
const PASTE_IMAGE = path.resolve(
args.pasteImage ||
process.env.HERMES_VISUAL_PASTE_IMAGE ||
firstExisting([
path.join(os.tmpdir(), "codex-clipboard-bd73d905-fefb-4510-8e87-5bee9e0d7a22.png"),
LOCAL_MEDIA,
path.join(process.cwd(), "resources", "icon.png"),
]) ||
"",
);
const TIMEOUTS = {
short: Number(args.shortTimeout || 30_000),
normal: Number(args.normalTimeout || 120_000),
image: Number(args.imageTimeout || 180_000),
generated: Number(args.generatedTimeout || 420_000),
restore: Number(args.restoreTimeout || 45_000),
};
let cachedRemoteToken = args.remoteToken || process.env.HERMES_REMOTE_TOKEN || "";
if (args.help) {
console.log(`
Hermes One live visual regression suite
Usage:
node scripts/drive-live-regression-suite.js [options]
npm run test:live-visual -- [options]
Options:
--modes=local,remote,ssh
--skip-generated
--dry-run
--paste-image=C:\\path\\to\\image.png
--remote-url=http://127.0.0.1:19080
--remote-token=<dashboard token>
--good-provider=openai-codex --good-model=gpt-5.5 --good-base-url=
--bad-provider=openai --bad-model=definitely-not-a-real-model-xyz --bad-base-url=
--output-dir=.sandbox/live-visual-regression/<run>
--report=.sandbox/live-visual-regression/<run>/report.json
The app must already be running with ENABLE_CDP=1 and CDP_PORT matching
--cdp-port or the CDP_PORT environment variable.
`);
process.exit(0);
}
function firstExisting(paths) {
return paths.find((candidate) => candidate && fs.existsSync(candidate));
}
function nowIso() {
return new Date().toISOString();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message, details = {}) {
if (!condition) {
const error = new Error(message);
error.details = details;
throw error;
}
}
function includesAny(text, needles) {
const haystack = String(text || "").toLowerCase();
return needles.some((needle) => haystack.includes(String(needle).toLowerCase()));
}
function countOccurrences(text, needle) {
if (!needle) return 0;
return String(text || "").split(needle).length - 1;
}
function ensureDir(fileOrDir) {
fs.mkdirSync(fileOrDir, { recursive: true });
}
function writeJson(file, value) {
ensureDir(path.dirname(file));
fs.writeFileSync(file, JSON.stringify(value, null, 2));
}
async function renderer(page, fn, arg) {
return page.evaluate(fn, arg);
}
async function readRemoteDashboardToken() {
if (cachedRemoteToken) return cachedRemoteToken;
const response = await fetch(REMOTE_URL, {
signal: AbortSignal.timeout(15_000),
});
const html = await response.text();
const match = /__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/.exec(html);
assert(match, "Remote dashboard token was not found in dashboard shell HTML", {
remoteUrl: REMOTE_URL,
});
cachedRemoteToken = match[1];
return cachedRemoteToken;
}
async function screenshot(page, label) {
const file = path.join(OUTPUT_DIR, `${label}.png`);
ensureDir(path.dirname(file));
try {
await page.screenshot({ path: file, fullPage: false, timeout: 12_000 });
return file;
} catch (playwrightError) {
try {
const client = await page.context().newCDPSession(page);
const shot = await client.send("Page.captureScreenshot", {
format: "png",
fromSurface: true,
captureBeyondViewport: false,
});
await client.detach().catch(() => {});
fs.writeFileSync(file, Buffer.from(shot.data, "base64"));
return file;
} catch (cdpError) {
const diagnosticFile = path.join(OUTPUT_DIR, `${label}.screenshot-failed.txt`);
const state = await getVisualState(page).catch((stateError) => ({
body: `Could not collect visual state: ${stateError.stack || stateError.message}`,
}));
fs.writeFileSync(
diagnosticFile,
[
"Screenshot capture failed.",
"",
"Playwright error:",
playwrightError.stack || playwrightError.message,
"",
"CDP error:",
cdpError.stack || cdpError.message,
"",
"Visible text:",
state.body || "",
].join("\n"),
);
console.warn(`[WARN] screenshot failed for ${label}; wrote ${diagnosticFile}`);
return diagnosticFile;
}
}
}
async function getVisualState(page) {
return renderer(page, () => {
const isVisible = (el) => {
if (!el) return false;
const style = window.getComputedStyle(el);
if (style.visibility === "hidden" || style.display === "none") return false;
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
};
const userRows = Array.from(document.querySelectorAll(".chat-message-user")).filter(isVisible);
const agentRows = Array.from(document.querySelectorAll(".chat-message-agent")).filter(isVisible);
const errorRows = Array.from(
document.querySelectorAll(".chat-bubble-error,.chat-error-message"),
).filter(isVisible);
const bubbleImages = Array.from(document.querySelectorAll(".chat-bubble img"))
.filter(isVisible)
.map((img) => ({
src: img.currentSrc || img.getAttribute("src") || "",
alt: img.getAttribute("alt") || "",
width: img.naturalWidth || img.width || 0,
height: img.naturalHeight || img.height || 0,
}))
.filter((img) => img.width >= 40 && img.height >= 40);
const attachmentPreviewImages = Array.from(
document.querySelectorAll(".chat-attachment-strip img,.attachment-chip-thumb img"),
)
.filter(isVisible)
.map((img) => ({
src: img.currentSrc || img.getAttribute("src") || "",
alt: img.getAttribute("alt") || "",
width: img.naturalWidth || img.width || 0,
height: img.naturalHeight || img.height || 0,
}))
.filter((img) => img.width >= 20 && img.height >= 20);
const toolRows = Array.from(
document.querySelectorAll(".tool-activity-row,.tool-activity-group,[class*='tool']"),
)
.filter(isVisible)
.map((el) => el.textContent || "");
const thoughtRows = Array.from(
document.querySelectorAll(".thought-card,.thinking-card,[class*='thought']"),
)
.filter(isVisible)
.map((el) => el.textContent || "");
const lastSendButton = Array.from(document.querySelectorAll("button.chat-send-btn")).at(-1);
const lastModelButton = Array.from(document.querySelectorAll(".chat-model-name")).at(-1);
return {
title: document.querySelector(".chat-header-title")?.textContent || "",
body: document.body.innerText || "",
users: userRows.map((el) => el.innerText || ""),
agents: agentRows.map((el) => el.innerText || ""),
errors: errorRows.map((el) => el.innerText || ""),
userText: userRows.map((el) => el.innerText || "").join("\n"),
agentText: agentRows.map((el) => el.innerText || "").join("\n"),
errorText: errorRows.map((el) => el.innerText || "").join("\n"),
bubbleImages,
attachmentPreviewImages,
toolText: toolRows.join("\n"),
thoughtText: thoughtRows.join("\n"),
modelLabel: lastModelButton?.textContent || "",
sendTitle: lastSendButton?.getAttribute("title") || "",
sendDisabled: lastSendButton?.hasAttribute("disabled") || false,
sessionCards: Array.from(document.querySelectorAll(".sessions-card")).map((el) => ({
text: el.textContent || "",
active: el.classList.contains("sessions-card--active"),
})),
modelCards: Array.from(document.querySelectorAll(".models-card")).map((el) => ({
text: el.textContent || "",
})),
};
});
}
async function waitForVisualState(page, predicate, timeoutMs, label) {
const deadline = Date.now() + timeoutMs;
let last = await getVisualState(page);
while (Date.now() < deadline) {
last = await getVisualState(page);
if (await predicate(last)) return last;
await sleep(1_000);
}
throw new Error(`${label} timed out after ${timeoutMs}ms`);
}
async function clickNav(page, label) {
await page
.locator(`button[title="${label}"]`)
.first()
.click({ timeout: 10_000 })
.catch(async () => {
await page.getByText(label, { exact: true }).first().click({ timeout: 10_000 });
});
await sleep(700);
}
function chatInput(page) {
return page.locator("textarea.chat-input").last();
}
function sendButton(page) {
return page.locator("button.chat-send-btn").last();
}
function modelButton(page) {
return page.locator("button.chat-model-trigger").last();
}
async function openChat(page) {
await clickNav(page, "Chat");
await chatInput(page).waitFor({ state: "visible", timeout: 15_000 });
}
async function openSessions(page) {
await clickNav(page, "Sessions");
await page.waitForSelector("input.sessions-searchbar-input", { timeout: 15_000 });
}
async function openModels(page) {
await clickNav(page, "Models");
await page.waitForSelector(".models-header", { timeout: 15_000 });
}
async function newChat(page) {
await openSessions(page);
await page.getByRole("button", { name: "New Chat" }).click();
await chatInput(page).waitFor({ state: "visible", timeout: 15_000 });
await waitForVisualState(
page,
(state) => state.users.length === 0 && state.agents.length === 0 && state.errors.length === 0,
TIMEOUTS.short,
"new empty chat",
);
}
async function setConnectionMode(page, mode) {
if (mode === "local") {
await renderer(page, () => window.hermesAPI.setConnectionConfig("local", "", ""));
} else if (mode === "remote") {
const token = await readRemoteDashboardToken();
await renderer(
page,
({ url, token }) => window.hermesAPI.setConnectionConfig("remote", url, token),
{ url: REMOTE_URL, token },
);
} else if (mode === "ssh") {
const token = await readRemoteDashboardToken();
await renderer(
page,
({ url, token }) => window.hermesAPI.setConnectionConfig("remote", url, token),
{ url: REMOTE_URL, token },
);
const cfg = await renderer(page, () => window.hermesAPI.getConnectionConfig());
await renderer(
page,
(ssh) =>
window.hermesAPI.setSshConfig(
ssh.host,
ssh.port,
ssh.username,
ssh.keyPath,
ssh.remotePort,
ssh.localPort,
),
cfg.ssh,
);
} else {
throw new Error(`Unsupported mode: ${mode}`);
}
await sleep(mode === "ssh" ? 5_000 : 2_500);
const cfg = await renderer(page, () => window.hermesAPI.getConnectionConfig());
assert(cfg.mode === mode, `Connection mode did not switch to ${mode}`, { cfg });
return cfg;
}
async function setModel(page, model) {
await renderer(
page,
({ provider, model, baseUrl }) =>
window.hermesAPI.setModelConfig(provider, model, baseUrl || ""),
model,
);
await sleep(1_000);
await openChat(page);
const state = await getVisualState(page);
if (!state.modelLabel.toLowerCase().includes(model.model.toLowerCase())) {
await modelButton(page).click();
await page.waitForSelector(".chat-model-dropdown", { timeout: 15_000 }).catch(() => {});
await sleep(750);
const pickerText = (await getVisualState(page)).body;
assert(
pickerText.toLowerCase().includes(model.model.toLowerCase()) ||
pickerText.toLowerCase().includes(model.provider.toLowerCase()),
"Model picker did not expose the selected/configured model",
{ model, pickerText: pickerText.slice(0, 2_000) },
);
await page.keyboard.press("Escape").catch(() => {});
}
}
async function setModelConfigOnly(page, model) {
await renderer(
page,
({ provider, model, baseUrl }) =>
window.hermesAPI.setModelConfig(provider, model, baseUrl || ""),
model,
);
await sleep(1_000);
}
async function sendPrompt(page, prompt, timeoutMs) {
await openChat(page);
const baseline = await getVisualState(page);
await chatInput(page).fill(prompt);
await sendButton(page).click();
return waitForVisualState(
page,
(state) =>
state.sendTitle === "Send" &&
state.users.length > baseline.users.length &&
(state.agents.length > baseline.agents.length ||
state.errors.length > baseline.errors.length),
timeoutMs,
`send prompt ${prompt.slice(0, 80)}`,
);
}
async function sendPromptWithAttachment(page, prompt, imagePath, timeoutMs) {
assert(fs.existsSync(imagePath), "Pasted-image fixture does not exist", { imagePath });
await openChat(page);
const baseline = await getVisualState(page);
await page.locator("input[type=file]").last().setInputFiles(imagePath);
await waitForVisualState(
page,
(state) =>
state.attachmentPreviewImages.length > 0 ||
state.bubbleImages.length > 0 ||
state.body.includes(path.basename(imagePath)),
TIMEOUTS.short,
"attachment preview",
);
await chatInput(page).fill(prompt);
await sendButton(page).click();
return waitForVisualState(
page,
(state) =>
state.sendTitle === "Send" &&
state.users.length > baseline.users.length &&
(state.agents.length > baseline.agents.length ||
state.errors.length > baseline.errors.length),
timeoutMs,
`send attachment prompt ${prompt.slice(0, 80)}`,
);
}
async function restoreBySearch(page, token) {
await openSessions(page);
const search = page.locator("input.sessions-searchbar-input").first();
await search.fill(token);
let cards = [];
const deadline = Date.now() + TIMEOUTS.restore;
while (Date.now() < deadline) {
const state = await getVisualState(page);
cards = state.sessionCards;
if (cards.length > 0) break;
await sleep(1_000);
}
assert(cards.length > 0, "Sessions search found no matching session", { token });
await page.locator(".sessions-card").first().click();
await sleep(2_500);
await openChat(page);
await sleep(1_000);
return getVisualState(page);
}
async function addTemporaryModel(page, mode, runId) {
const model = {
name: `Visual Added ${mode} ${runId}`,
provider: "custom",
model: `visual-added-${mode}-${runId}`,
baseUrl: DEAD_ROUTE_BASE_URL,
};
return addConfiguredModel(page, model);
}
async function addConfiguredModel(page, model) {
const added = await renderer(
page,
(m) => window.hermesAPI.addModel(m.name, m.provider, m.model, m.baseUrl),
model,
);
await sleep(1_500);
return { ...model, id: added.id };
}
async function removeTemporaryModel(page, id) {
if (!id) return;
await renderer(page, (modelId) => window.hermesAPI.removeModel(modelId), id).catch(
() => false,
);
await sleep(1_500);
}
async function runCase(report, mode, name, fn) {
const caseId = `${mode}-${name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
const startedAt = nowIso();
console.log(`[CASE] ${mode}: ${name}`);
try {
const details = await fn(caseId);
const record = {
mode,
name,
status: "pass",
startedAt,
endedAt: nowIso(),
...details,
};
report.cases.push(record);
console.log(`[PASS] ${mode}: ${name}`);
return record;
} catch (error) {
const record = {
mode,
name,
status: "fail",
startedAt,
endedAt: nowIso(),
error: error && error.stack ? error.stack : String(error),
details: error && error.details ? error.details : undefined,
};
report.cases.push(record);
console.log(`[FAIL] ${mode}: ${name}: ${error.message || error}`);
return record;
} finally {
report.updatedAt = nowIso();
writeJson(REPORT_PATH, report);
}
}
function assertNoDuplicates(restored, checks) {
for (const check of checks) {
const userCount = check.user
? restored.users.filter((row) => row.includes(check.user)).length
: 0;
const agentCount = check.agent
? restored.agents.filter((row) => row.includes(check.agent)).length
: 0;
if (check.user) {
assert(userCount === 1, `Unexpected user message count for ${check.user}`, {
userCount,
restoredUsers: restored.users,
restoredUserText: restored.userText,
});
}
if (check.agent) {
assert(agentCount === 1, `Unexpected assistant message count for ${check.agent}`, {
agentCount,
restoredAgents: restored.agents,
restoredAgentText: restored.agentText,
});
}
}
}
async function runMode(page, report, mode) {
const modeRun = `${RUN_ID}_${mode}`;
await setConnectionMode(page, mode);
await setModel(page, GOOD_MODEL);
await runCase(report, mode, "normal prompt with valid model and session restore", async (caseId) => {
const token = `VIS_${modeRun}_NORMAL_OK`;
await newChat(page);
const live = await sendPrompt(
page,
`Visual regression ${mode} normal. Reply exactly ${token} and nothing else. Do not use tools.`,
TIMEOUTS.normal,
);
assert(live.agentText.includes(token), "Live assistant response missing token", {
agentText: live.agentText,
errorText: live.errorText,
});
const liveShot = await screenshot(page, `${caseId}-live`);
const restored = await restoreBySearch(page, token);
const restoreShot = await screenshot(page, `${caseId}-restore`);
assert(restored.agentText.includes(token), "Restored assistant response missing token");
assertNoDuplicates(restored, [{ user: token, agent: token }]);
return { token, liveShot, restoreShot };
});
await runCase(report, mode, "bad-good-bad-good in one session and session restore", async (caseId) => {
const base = `VIS_${modeRun}_ALT`;
const badModels = [];
await newChat(page);
try {
badModels.push(await addConfiguredModel(page, badRouteModel(mode, modeRun, "bad1")));
badModels.push(await addConfiguredModel(page, badRouteModel(mode, modeRun, "bad2")));
await setModel(page, badModels[0]);
const bad1 = await sendPrompt(
page,
`${base}_BAD1: Reply BAD_SHOULD_FAIL_1.`,
TIMEOUTS.normal,
);
assert(
bad1.errorText ||
includesAny(bad1.agentText, ["error", "failed", "connection", "refused", "unreachable"]),
"First bad-model leg did not visibly fail",
{ agentText: bad1.agentText, errorText: bad1.errorText, badModel: badModels[0] },
);
await setModel(page, GOOD_MODEL);
const good1Token = `${base}_GOOD1_OK`;
const good1 = await sendPrompt(
page,
`${base}_GOOD1: Reply exactly ${good1Token} and nothing else. Do not use tools.`,
TIMEOUTS.normal,
);
assert(good1.agentText.includes(good1Token), "First recovery leg missing token");
await setModel(page, badModels[1]);
const bad2 = await sendPrompt(
page,
`${base}_BAD2: Reply BAD_SHOULD_FAIL_2.`,
TIMEOUTS.normal,
);
assert(
bad2.errorText ||
includesAny(bad2.agentText, ["error", "failed", "connection", "refused", "unreachable"]),
"Second bad-model leg did not visibly fail",
{ agentText: bad2.agentText, errorText: bad2.errorText, badModel: badModels[1] },
);
await setModel(page, GOOD_MODEL);
const good2Token = `${base}_GOOD2_OK`;
const good2 = await sendPrompt(
page,
`${base}_GOOD2: Reply exactly ${good2Token} and nothing else. Do not use tools.`,
TIMEOUTS.normal,
);
assert(good2.agentText.includes(good2Token), "Second recovery leg missing token");
const liveShot = await screenshot(page, `${caseId}-live`);
const restored = await restoreBySearch(page, good2Token);
const restoreShot = await screenshot(page, `${caseId}-restore`);
const restoredText = `${restored.body}\n${restored.errorText}\n${restored.agentText}`;
assert(restored.userText.includes(`${base}_BAD1`), "Restored session missing BAD1 prompt");
assert(restored.userText.includes(`${base}_GOOD1`), "Restored session missing GOOD1 prompt");
assert(restored.userText.includes(`${base}_BAD2`), "Restored session missing BAD2 prompt");
assert(restored.userText.includes(`${base}_GOOD2`), "Restored session missing GOOD2 prompt");
assert(restored.agentText.includes(good1Token), "Restored session missing GOOD1 answer");
assert(restored.agentText.includes(good2Token), "Restored session missing GOOD2 answer");
assert(
includesAny(restoredText, ["error", "failed", "connection", "refused", "unreachable"]),
"Restored session missing bad-model errors",
);
assertNoDuplicates(restored, [
{ user: `${base}_BAD1` },
{ user: `${base}_GOOD1`, agent: good1Token },
{ user: `${base}_BAD2` },
{ user: `${base}_GOOD2`, agent: good2Token },
]);
return { base, liveShot, restoreShot, badModels };
} finally {
for (const bad of badModels) {
await removeTemporaryModel(page, bad.id);
}
await setModel(page, GOOD_MODEL);
}
});
await runCase(report, mode, "add-remove models persistence and chat selector availability", async (caseId) => {
let added;
try {
added = await addTemporaryModel(page, mode, modeRun);
await openModels(page);
await waitForVisualState(
page,
(state) => state.body.includes(added.name) && state.body.includes(added.model),
TIMEOUTS.short,
"models page showing added model",
);
const modelsShot = await screenshot(page, `${caseId}-models-added`);
await openChat(page);
await modelButton(page).click();
const picker = await waitForVisualState(
page,
(state) => state.body.includes(added.model) || state.body.includes(added.name),
TIMEOUTS.short,
"chat selector showing added model",
);
await page.keyboard.press("Escape").catch(() => {});
const pickerShot = await screenshot(page, `${caseId}-picker-added`);
const opposite = mode === "local" ? "remote" : "local";
await setConnectionMode(page, opposite);
await setConnectionMode(page, mode);
const persisted = await renderer(page, (model) =>
window.hermesAPI
.listModels()
.then((rows) => rows.some((row) => row.model === model)),
added.model,
);
assert(persisted, "Added model was not persisted after mode switch", { added });
await removeTemporaryModel(page, added.id);
const removed = await renderer(page, (model) =>
window.hermesAPI
.listModels()
.then((rows) => !rows.some((row) => row.model === model)),
added.model,
);
assert(removed, "Removed model still appears in model library", { added });
await openModels(page);
const removedState = await getVisualState(page);
assert(!removedState.body.includes(added.model), "Removed model still visible in Models UI");
return { added, modelsShot, pickerShot, pickerExcerpt: picker.body.slice(0, 1_000) };
} finally {
if (added) await removeTemporaryModel(page, added.id);
await setModel(page, GOOD_MODEL);
}
});
await runCase(report, mode, "pasted image display in live prompt and restored session", async (caseId) => {
const token = `VIS_${modeRun}_PASTE`;
await setModel(page, GOOD_MODEL);
await newChat(page);
const live = await sendPromptWithAttachment(
page,
`${token}: what is this image? Answer in one sentence.`,
PASTE_IMAGE,
TIMEOUTS.image,
);
assert(live.userText.includes(token), "Live pasted-image prompt missing");
assert(live.bubbleImages.length > 0, "Live pasted-image bubble did not display image");
assert(live.agentText.trim().length > 0, "Live pasted-image response missing");
const liveShot = await screenshot(page, `${caseId}-live`);
const restored = await restoreBySearch(page, token);
const restoreShot = await screenshot(page, `${caseId}-restore`);
assert(restored.userText.includes(token), "Restored pasted-image prompt missing");
assert(restored.bubbleImages.length > 0, "Restored pasted-image bubble did not display image");
assert(
!restored.userText.includes("[The user attached an image"),
"Restored pasted-image prompt showed fallback text instead of attachment",
);
assertNoDuplicates(restored, [{ user: token }]);
return {
token,
liveShot,
restoreShot,
liveImages: live.bubbleImages.length,
restoredImages: restored.bubbleImages.length,
};
});
await runCase(report, mode, "same image markdown and file path renders once", async (caseId) => {
const token = `VIS_${modeRun}_IMAGE_DEDUPE`;
const mediaPath = mediaPathForMode(mode);
await setModel(page, GOOD_MODEL);
await newChat(page);
await sendPrompt(
page,
`${token}: Reply with exactly these three lines and no extra text. Do not use tools.\nHere it is:\n![Toy Duck](${mediaPath})\nFile: \`${mediaPath}\``,
TIMEOUTS.normal,
);
const live = await waitForVisualState(
page,
(state) => state.bubbleImages.length > 0 || state.errorText.trim().length > 0,
TIMEOUTS.image,
"duplicate image/path live media render",
);
assert(!live.errorText, "Duplicate image/path live response has an error", {
errorText: live.errorText,
});
assert(live.agentText.includes(mediaPath), "Live response did not include the requested path", {
mediaPath,
agentTail: live.agentText.slice(-1_500),
});
assert(live.bubbleImages.length > 0, "Live duplicate image/path response rendered no image", {
agentTail: live.agentText.slice(-1_500),
});
assertNoDuplicateRenderedImages(live, "Live duplicate image/path response");
const liveShot = await screenshot(page, `${caseId}-live`);
const restored = await restoreBySearch(page, token);
const restoreShot = await screenshot(page, `${caseId}-restore`);
assert(restored.userText.includes(token), "Restored duplicate image/path prompt missing");
assert(restored.bubbleImages.length > 0, "Restored duplicate image/path response rendered no image");
assertNoDuplicateRenderedImages(restored, "Restored duplicate image/path response");
assertNoDuplicates(restored, [{ user: token }]);
return {
token,
mediaPath,
liveShot,
restoreShot,
liveImages: live.bubbleImages.length,
restoredImages: restored.bubbleImages.length,
};
});
if (!args.skipGenerated) {
await runCase(report, mode, "generated image display in live prompt and restored session", async (caseId) => {
const token = `VIS_${modeRun}_GEN`;
await setModel(page, GOOD_MODEL);
await newChat(page);
const remoteHint =
mode === "local"
? "Use the local AI Playground / ComfyUI endpoint if needed."
: "Use host.docker.internal:49000 for AI Playground / ComfyUI if needed.";
await sendPrompt(
page,
`${token}: Generate an image of a toy duck in a bathtub using AI Playground / ComfyUI. Do not ask clarification. ${remoteHint} Save it and include a markdown image link or file path to the generated PNG.`,
TIMEOUTS.generated,
);
const live = await waitForVisualState(
page,
(state) => state.bubbleImages.length > 0 || state.errorText.trim().length > 0,
TIMEOUTS.image,
"generated-image live media render",
);
const liveShot = await screenshot(page, `${caseId}-live`);
assert(
live.bubbleImages.length > 0,
"Generated-image live response did not render an image in a chat bubble",
{
agentTail: live.agentText.slice(-1_500),
errorText: live.errorText,
bubbleImages: live.bubbleImages,
},
);
assert(!live.errorText, "Generated-image live response has an error", {
errorText: live.errorText,
});
assertNoDuplicateRenderedImages(live, "Generated-image live response");
const restored = await restoreBySearch(page, token);
const restoreShot = await screenshot(page, `${caseId}-restore`);
assert(restored.userText.includes(token), "Restored generated-image prompt missing");
assert(
restored.bubbleImages.length > 0,
"Restored generated-image session did not render an image",
{ agentTail: restored.agentText.slice(-1_500) },
);
assertNoDuplicateRenderedImages(restored, "Restored generated-image response");
assertNoDuplicates(restored, [{ user: token }]);
return {
token,
liveShot,
restoreShot,
liveImages: live.bubbleImages.length,
restoredImages: restored.bubbleImages.length,
};
});
}
}
(async () => {
ensureDir(OUTPUT_DIR);
const { browser, page } = await attach({
cdpUrl: `http://127.0.0.1:${CDP_PORT}`,
titleHint: "Hermes One",
});
page.setDefaultTimeout(15_000);
const original = await renderer(page, async () => ({
connection: await window.hermesAPI.getConnectionConfig(),
model: await window.hermesAPI.getModelConfig().catch(() => null),
}));
if (original.connection.mode !== "local" && !cachedRemoteToken) {
try {
cachedRemoteToken = await readRemoteDashboardToken();
} catch {
/* Cleanup will avoid replacing an existing token unless we have one. */
}
}
const report = {
runId: RUN_ID,
startedAt: nowIso(),
cdpPort: CDP_PORT,
remoteUrl: REMOTE_URL,
modes: MODES,
goodModel: GOOD_MODEL,
badModel: BAD_MODEL,
pasteImage: PASTE_IMAGE,
outputDir: OUTPUT_DIR,
reportPath: REPORT_PATH,
cases: [],
};
if (args.dryRun) {
console.log(JSON.stringify(report, null, 2));
await browser.close();
return;
}
try {
assert(page.url(), "No renderer page attached");
assert(await page.title(), "Renderer page has no title");
assert(
await renderer(page, () => Boolean(window.hermesAPI && window.hermesAPI.sendMessage)),
"Renderer hermesAPI is not available",
);
assert(
fs.existsSync(PASTE_IMAGE),
"No pasted-image fixture found. Pass --paste-image=C:\\path\\image.png",
{ pasteImage: PASTE_IMAGE },
);
for (const mode of MODES) {
await runMode(page, report, mode);
}
} finally {
try {
await renderer(
page,
async ({ connection, model, remoteToken }) => {
try {
if (connection.mode === "local") {
await window.hermesAPI.setConnectionConfig("local", "", "");
} else if (connection.mode === "remote") {
if (remoteToken) {
await window.hermesAPI.setConnectionConfig(
"remote",
connection.remoteUrl || "",
remoteToken,
);
}
} else if (connection.mode === "ssh") {
if (remoteToken) {
await window.hermesAPI.setConnectionConfig(
"remote",
connection.remoteUrl || "",
remoteToken,
);
}
if (connection.ssh) {
await window.hermesAPI.setSshConfig(
connection.ssh.host,
connection.ssh.port,
connection.ssh.username,
connection.ssh.keyPath,
connection.ssh.remotePort,
connection.ssh.localPort,
);
}
}
} catch {
/* best effort */
}
try {
await window.hermesAPI.setConnectionChatTransports(
connection.remoteChatTransport || "auto",
connection.sshChatTransport || "auto",
);
} catch {
/* best effort */
}
if (model) {
try {
await window.hermesAPI.setModelConfig(
model.provider,
model.model,
model.baseUrl || "",
);
} catch {
/* best effort */
}
}
},
{ ...original, remoteToken: cachedRemoteToken },
);
} catch {
/* best effort cleanup */
}
report.endedAt = nowIso();
writeJson(REPORT_PATH, report);
await browser.close();
}
const failed = report.cases.filter((c) => c.status === "fail");
const passed = report.cases.filter((c) => c.status === "pass");
console.log(`[SUMMARY] ${passed.length}/${report.cases.length} passed`);
console.log(`[REPORT] ${REPORT_PATH}`);
if (failed.length > 0) {
for (const failure of failed) {
console.log(`[FAILED] ${failure.mode}: ${failure.name}`);
console.log(failure.error.split("\n")[0]);
}
process.exit(1);
}
})().catch((error) => {
console.error("[FATAL]", error && error.stack ? error.stack : error);
process.exit(1);
});