Merge gateway profile foundation into provider profiles UI stack

This commit is contained in:
Jesse Merhi
2026-08-12 12:53:02 +10:00
3 changed files with 373 additions and 49 deletions
+1 -35
View File
@@ -1,13 +1,11 @@
// Verifies config IO compatibility loading and migration behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { normalizeCompatibilityConfigValues } from "../commands/doctor/shared/legacy-config-core-migrate.js";
import { describe, expect, it, vi } from "vitest";
import { VERSION } from "../version.js";
import { createConfigIO } from "./io.js";
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
import { withTempHome } from "./test-helpers.js";
import type { OpenClawConfig } from "./types.openclaw.js";
async function writeConfig(
home: string,
@@ -30,29 +28,6 @@ function createIoForHome(home: string, env: NodeJS.ProcessEnv = {} as NodeJS.Pro
}
describe("config io paths", () => {
let whatsappSharedAccessDefaults: unknown;
beforeAll(() => {
const migrated = normalizeCompatibilityConfigValues({
channels: {
whatsapp: {
enabled: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
groupAllowFrom: [],
accounts: {
work: {
enabled: true,
authDir: "/tmp/wa-work",
},
},
},
},
} as OpenClawConfig);
whatsappSharedAccessDefaults = migrated.config.channels?.whatsapp?.accounts?.default;
});
it("uses ~/.openclaw/openclaw.json when config exists", async () => {
await withTempHome(async (home) => {
const configPath = await writeConfig(home, ".openclaw", 19001);
@@ -335,13 +310,4 @@ describe("config io paths", () => {
});
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]);
});
it("moves WhatsApp shared access defaults into accounts.default during runtime compat", () => {
expect(whatsappSharedAccessDefaults).toEqual({
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
groupAllowFrom: [],
});
});
});
@@ -0,0 +1,284 @@
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "chat Markdown alignment",
unavailableMessage: (executablePath) => `Playwright Chromium is unavailable at ${executablePath}`,
});
suite.define(() => {
it("aligns Markdown markers and text while containing expanded disclosures", async () => {
const longJson = JSON.stringify(
Object.fromEntries(
Array.from({ length: 40 }, (_, index) => [`field-${index + 1}`, index + 1]),
),
null,
2,
);
await suite.withPage(
{
colorScheme: "light",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 800, width: 1180 },
},
async ({ page }) => {
await installMockGateway(page, {
historyMessages: [
{
content: [
{
type: "text",
text: [
"## Alignment check",
"",
"- Bullet item",
"",
"1. Numbered item",
"",
"- [ ] Unchecked task",
"",
"<details open>",
"<summary>More details</summary>",
"",
"Disclosure body",
"</details>",
"",
"<details>",
"<summary>Collapsed details</summary>",
"Hidden body",
"</details>",
"",
"```json",
longJson,
"```",
].join("\n"),
},
],
role: "assistant",
timestamp: Date.now(),
},
],
});
await page.goto(`${suite.server.baseUrl}chat`);
const markdown = page.locator(".chat-group.assistant .chat-text", {
hasText: "Alignment check",
});
await markdown.waitFor();
const geometry = await markdown.evaluate((root) => {
const textRect = (selector: string) => {
const element = root.querySelector(selector);
if (!element) {
throw new Error(`Missing element for ${selector}`);
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let text = walker.nextNode();
while (text && !text.textContent?.trim()) {
text = walker.nextNode();
}
if (!text) {
throw new Error(`Missing text for ${selector}`);
}
const range = document.createRange();
range.selectNodeContents(text);
return range.getBoundingClientRect();
};
const checkbox = root.querySelector(".task-list-item-checkbox");
const details = root.querySelector("details:not(.json-collapse)[open]");
const summary = root.querySelector("details:not(.json-collapse)[open] > summary");
if (!checkbox || !details || !summary) {
throw new Error("Missing task-list or disclosure markup");
}
const detailsStyle = getComputedStyle(details);
const summaryStyle = getComputedStyle(summary);
const checkboxRect = checkbox.getBoundingClientRect();
const taskTextRect = textRect(".task-list-item");
const collapsedSummary = root.querySelector(
"details:not(.json-collapse):not([open]) > summary",
);
const jsonCollapse = root.querySelector("details.json-collapse");
const jsonSummary = root.querySelector("details.json-collapse > summary");
const jsonCopy = root.querySelector("details.json-collapse .code-block-copy");
if (!collapsedSummary || !jsonCollapse || !jsonSummary || !jsonCopy) {
throw new Error("Missing authored or JSON disclosure markup");
}
const closedChevronStyle = getComputedStyle(collapsedSummary, "::after");
const collapsedSummaryRect = collapsedSummary.getBoundingClientRect();
const collapsedSummaryTextRect = textRect(
"details:not(.json-collapse):not([open]) > summary",
);
const jsonCollapseStyle = getComputedStyle(jsonCollapse);
const jsonSummaryStyle = getComputedStyle(jsonSummary);
return {
bodyTextX: textRect("details:not(.json-collapse) > p").x,
borderInlineStartWidth: detailsStyle.borderInlineStartWidth,
bulletTextX: textRect("ul:not(.contains-task-list) > li").x,
checkboxGap: taskTextRect.x - checkboxRect.right,
checkboxLineCenterDelta:
checkboxRect.y + checkboxRect.height / 2 - (taskTextRect.y + taskTextRect.height / 2),
checkboxSize: checkboxRect.width,
chevronClosedTransform: closedChevronStyle.transform,
chevronInlineEnd: closedChevronStyle.insetInlineEnd,
chevronTransitionDuration: closedChevronStyle.transitionDuration,
chevronWidth: closedChevronStyle.width,
collapsedSummaryPaddingInlineEnd: getComputedStyle(collapsedSummary).paddingInlineEnd,
collapsedSummaryTextRight: collapsedSummaryTextRect.right,
collapsedSummaryTextX: collapsedSummaryTextRect.x,
collapsedSummaryRight: collapsedSummaryRect.right,
detailsX: details.getBoundingClientRect().x,
jsonBorderInlineStartWidth: jsonCollapseStyle.borderInlineStartWidth,
jsonCopyFloat: getComputedStyle(jsonCopy).float,
jsonDetailsX: jsonCollapse.getBoundingClientRect().x,
jsonSummaryDisplay: jsonSummaryStyle.display,
jsonSummaryPaddingInlineStart: jsonSummaryStyle.paddingInlineStart,
numberedTextX: textRect("ol > li").x,
rootX: root.getBoundingClientRect().x,
summaryMarginBottom: summaryStyle.marginBottom,
summaryTextX: textRect("details[open] > summary").x,
taskTextX: taskTextRect.x,
};
});
const textStarts = [
geometry.bulletTextX,
geometry.numberedTextX,
geometry.taskTextX,
geometry.summaryTextX,
geometry.collapsedSummaryTextX,
];
expect(Math.max(...textStarts) - Math.min(...textStarts)).toBeLessThanOrEqual(1);
expect(geometry.checkboxGap).toBeGreaterThanOrEqual(7);
expect(geometry.checkboxGap).toBeLessThanOrEqual(9);
expect(Math.abs(geometry.checkboxLineCenterDelta)).toBeLessThanOrEqual(1);
expect(geometry.checkboxSize).toBe(16);
expect(geometry.bodyTextX - geometry.rootX).toBeGreaterThanOrEqual(28);
expect(geometry.detailsX).toBeGreaterThan(geometry.rootX);
expect(geometry.detailsX).toBeLessThan(geometry.bodyTextX);
expect(Number.parseFloat(geometry.borderInlineStartWidth)).toBeGreaterThan(0);
expect(Number.parseFloat(geometry.summaryMarginBottom)).toBeGreaterThan(0);
expect(Number.parseFloat(geometry.chevronWidth)).toBe(16);
expect(Number.parseFloat(geometry.chevronInlineEnd)).toBe(0);
expect(Number.parseFloat(geometry.collapsedSummaryPaddingInlineEnd)).toBeGreaterThanOrEqual(
24,
);
expect(geometry.collapsedSummaryRight - geometry.collapsedSummaryTextRight).toBeGreaterThan(
24,
);
expect(geometry.chevronTransitionDuration).not.toBe("0s");
expect(Math.abs(geometry.jsonDetailsX - geometry.rootX)).toBeLessThanOrEqual(1);
expect(Number.parseFloat(geometry.jsonBorderInlineStartWidth)).toBe(0);
expect(geometry.jsonSummaryDisplay).toBe("list-item");
expect(Number.parseFloat(geometry.jsonSummaryPaddingInlineStart)).toBe(8);
expect(geometry.jsonCopyFloat).toBe("right");
const collapsedSummary = markdown.locator("summary", { hasText: "Collapsed details" });
await collapsedSummary.click();
await expect
.poll(() =>
collapsedSummary.evaluate((summary) => getComputedStyle(summary, "::after").transform),
)
.not.toBe(geometry.chevronClosedTransform);
},
);
});
it("preserves the shared Markdown gutter in RTL transcripts", async () => {
await suite.withPage(
{
colorScheme: "light",
locale: "ar",
serviceWorkers: "block",
viewport: { height: 800, width: 1180 },
},
async ({ page }) => {
await installMockGateway(page, {
historyMessages: [
{
content: [
{
type: "text",
text: [
"## فحص المحاذاة",
"",
"- عنصر نقطي",
"",
"1. عنصر مرقم",
"",
"- [ ] مهمة غير مكتملة",
"",
"<details open>",
"<summary>تفاصيل إضافية</summary>",
"",
"محتوى التفاصيل",
"</details>",
].join("\n"),
},
],
role: "assistant",
timestamp: Date.now(),
},
],
});
await page.goto(`${suite.server.baseUrl}chat`);
const markdown = page.locator(".chat-group.assistant .chat-text[dir='rtl']", {
hasText: "فحص المحاذاة",
});
await markdown.waitFor();
const geometry = await markdown.evaluate((root) => {
const textRight = (selector: string) => {
const element = root.querySelector(selector);
if (!element) {
throw new Error(`Missing element for ${selector}`);
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let text = walker.nextNode();
while (text && !text.textContent?.trim()) {
text = walker.nextNode();
}
if (!text) {
throw new Error(`Missing text for ${selector}`);
}
const range = document.createRange();
range.selectNodeContents(text);
return range.getBoundingClientRect().right;
};
const checkbox = root.querySelector(".task-list-item-checkbox");
const task = root.querySelector(".task-list-item");
const unorderedList = root.querySelector("ul:not(.contains-task-list)");
const orderedList = root.querySelector("ol");
const summary = root.querySelector("details:not(.json-collapse) > summary");
if (!checkbox || !task || !unorderedList || !orderedList || !summary) {
throw new Error("Missing RTL Markdown geometry");
}
const checkboxRect = checkbox.getBoundingClientRect();
return {
checkboxGap: checkboxRect.left - textRight(".task-list-item"),
chevronInlineEnd: getComputedStyle(summary, "::after").insetInlineEnd,
orderedPaddingInlineStart: getComputedStyle(orderedList).paddingInlineStart,
textStarts: [
textRight("ul:not(.contains-task-list) > li"),
textRight("ol > li"),
textRight(".task-list-item"),
textRight("details:not(.json-collapse) > summary"),
],
unorderedPaddingInlineStart: getComputedStyle(unorderedList).paddingInlineStart,
};
});
expect(
Math.max(...geometry.textStarts) - Math.min(...geometry.textStarts),
).toBeLessThanOrEqual(1);
expect(Number.parseFloat(geometry.unorderedPaddingInlineStart)).toBe(32);
expect(Number.parseFloat(geometry.orderedPaddingInlineStart)).toBe(32);
expect(geometry.checkboxGap).toBeGreaterThanOrEqual(7);
expect(geometry.checkboxGap).toBeLessThanOrEqual(9);
expect(Number.parseFloat(geometry.chevronInlineEnd)).toBe(0);
},
);
});
});
+88 -14
View File
@@ -137,24 +137,103 @@
font-weight: 500;
}
.chat-text :where(ul, ol) {
padding-left: 1.2em;
:is(.chat-text, .chat-thinking) {
--chat-markdown-indent: var(--space-7);
--chat-markdown-marker-gap: var(--space-2);
}
:is(.chat-text, .chat-thinking) :where(ul, ol) {
padding-inline-start: var(--chat-markdown-indent);
}
:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)) {
margin-inline-start: var(--space-3);
padding-inline-start: calc(var(--chat-markdown-indent) - var(--space-3));
}
.chat-text :where(li + li) {
margin-top: 0.4em;
}
/* Hide default marker only for unordered task lists; ordered lists keep numbers */
.chat-text :where(ul > .task-list-item),
.chat-thinking :where(ul > .task-list-item) {
/* Keep bullets, checkboxes, and numbers in one marker column while their text
shares the same start edge. */
:is(.chat-text, .chat-thinking) :where(ul > li:not(.task-list-item))::marker {
content: "";
}
:is(.chat-text, .chat-thinking) :where(ul > .task-list-item) {
list-style: none;
}
.chat-text :where(.task-list-item-checkbox),
.chat-thinking :where(.task-list-item-checkbox) {
margin-right: 0.4em;
vertical-align: middle;
:is(.chat-text, .chat-thinking) :where(ol > li)::marker {
font-variant-numeric: tabular-nums;
}
:is(.chat-text, .chat-thinking) :where(ul > li) {
position: relative;
}
:is(.chat-text, .chat-thinking) :where(ul > li:not(.task-list-item))::before {
position: absolute;
inset-inline-end: calc(100% + var(--chat-markdown-marker-gap));
content: "•";
inset-block-start: 0;
inline-size: calc(var(--chat-markdown-indent) - var(--chat-markdown-marker-gap));
text-align: end;
}
/* Authored disclosures align with list text but place their affordance at the
row edge; JSON code blocks retain their separate compact presentation. */
:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary) {
position: relative;
padding-inline-end: var(--space-7);
list-style: none;
cursor: var(--cursor-action);
}
:is(.chat-text, .chat-thinking)
:where(details:not(.json-collapse) > summary)::-webkit-details-marker {
display: none;
}
:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary)::after {
position: absolute;
content: "";
inset-block-start: calc((1lh - var(--space-4)) / 2);
inset-inline-end: 0;
inline-size: var(--space-4);
block-size: var(--space-4);
background: var(--select-chevron) center / var(--space-4) no-repeat;
transform: rotate(-90deg);
transition: transform var(--duration-normal) var(--ease-out);
}
:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open] > summary)::after {
transform: rotate(0deg);
}
:is(.chat-text, .chat-thinking) :where(.task-list-item-checkbox) {
position: absolute;
inset-block-start: calc((1lh - var(--space-4)) / 2);
inset-inline-end: calc(100% + var(--chat-markdown-marker-gap));
inline-size: var(--space-4);
block-size: var(--space-4);
margin: 0;
}
:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open]) {
padding-inline-start: calc(var(--chat-markdown-indent) - var(--space-3) - 1px);
border-inline-start: 1px solid var(--border);
}
:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse)[open] > summary) {
margin-bottom: var(--space-2);
}
@media (prefers-reduced-motion: reduce) {
:is(.chat-text, .chat-thinking) :where(details:not(.json-collapse) > summary)::after {
transition: none;
}
}
.chat-text :where(a) {
@@ -428,11 +507,6 @@
text-align: right;
}
.chat-text[dir="rtl"] :where(ul, ol) {
padding-left: 0;
padding-right: 1.5em;
}
.chat-text[dir="rtl"] :where(blockquote) {
border-left: none;
border-right: 3px solid var(--border);