mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): make the Control UI e2e suite green and gate merges on it (#114554)
* fix(ui): restore chat flow e2e coverage (cherry picked from commit 16a094eaec4ae121db09f67c094a41262aa9cefc) * test(ui): align browser Talk mocks with voice sessions (cherry picked from commit ef0649334539954b253ab8c51ff436bd8ce757c6) * test(ui): wait for chat startup in update E2E (cherry picked from commit 586e1046320dd56398fd8a3b995a1a465f6c6fe2) * test(ui): update sidebar session route expectation (cherry picked from commit 9692985027556fad7bb7819c8e140a101e181c47) * test(ui): update session management e2e contracts (cherry picked from commit 4e99348dabf08fb8886d55e25a9f9eda1dfe1056) * test(ui): align Codex session E2E contracts Update the catalog layout assertion for reorderable sibling sections, expose advanced plugin settings explicitly, and make adopted sessions resolvable through the current path router. (cherry picked from commit 1a03f924a32facb23302c5618a2b15004feeccff) * test(ui): align composer e2e with metadata hydration (cherry picked from commit eca1902e773be32c993ddb22594925f7da82f98f) * fix(ui): restore usage hint click behavior (cherry picked from commit ea63b5dab7c3c54048a04d60703e529e5b900df6) * fix(ui): give reveal-only tooltips an explicit click-to-open opt-in #112602 made the shared tooltip dismiss on pointer activation so an action button never strands one open. The usage summary hint exists only to be read, so clicking it did nothing on touch or in browsers that skip focus on click. Opt that trigger in rather than bouncing focus through blur/focus, which briefly drops focus for assistive tech. (cherry picked from commit 16d165857a4006ae0a0df42168f34a40f4cc9117) * test(ui): follow sidebar dock resize ownership (cherry picked from commit fd4792a2dd9fc81fc63f8c9e25291ff2246d235a) * test(ui): prove the dock resize persists off its clamp Asserting the post-End width equals SIDEBAR_MIN_WIDTH_PX compared the clamp to itself across reload, so the check would pass even if the persisted width were dropped and the column fell back to its minimum. Step off the clamp first and assert that value survives. (cherry picked from commit 3636124c326a93b80006ccc4064415ec1a6c2c9f) * test(ui): align native link routing e2e contracts Use a non-link bubble hit target after flat assistant styling, assert modified clicks at the routing boundary instead of relying on Chromium tab creation, and preserve native context menus for internal anchors. (cherry picked from commit 55fbd3052a40c5c03f00f0bf595ef356ac0d1f10) * test(ui): align native catalog host assertions (cherry picked from commit 9db9a2fcd5633675a88296b2ccd874563a00cb80) * test(ui): target tool outcome rollup at dashboard session Completed-work groups are dashboard-only, so the legacy main session never rendered the disclosure exercised by this test. (cherry picked from commit ba84b617ef038866451ad03491298349d966a54f) * test(ui): track discussion panel close action The flexible multi-sidebar layout replaced the generic Close sidebar action with a panel-specific Close Discussion action. Keep the E2E toggle proof aligned with that intentional accessible-name contract. (cherry picked from commit 422b1f2bd8fae2a7571881d0455c3040c2ef2de9) * test(ui): mock pairing list in WhatsApp logout e2e (cherry picked from commit 3730de075bbb50071fc0cb57c007ab894688a7a1) * test(ui): open build info from identity menu (cherry picked from commit b1626a6a723ec13c2cf50444b4c7a65fec289213) * fix(ui): let the board fixture host page own its theme The fixture module unconditionally re-added `wa-dark` at import time, which runs after the host page's inline theme script. #111998 gave that page the real prefers-color-scheme contract, leaving the line from #112076 stale: in light mode the root ended up with both `wa-light` and `wa-dark`, so Web Awesome rendered dropdowns dark. (cherry picked from commit 43e50de9728f3d5feac9609142d28821896b4f77) * test(ui): align approval E2E with scoped errors (cherry picked from commit 6435fe995491c6bae2c28fa374d06cd77f3c46ac) * test(ui): use radio role in Set Default E2E (cherry picked from commit acc8d5a487aac4d56515ae35d976fd347efa0be5) * ci: gate merges on the Control UI e2e suite test/vitest/vitest.ui-e2e.config.ts was referenced only by package.json and mantis-web-ui-chat-proof.yml, so nothing in the merge gate ever ran it and 39 failures accumulated across 21 files (#114438). Give it its own job: the suite runs one file at a time because each owns a Chromium context and a mocked Gateway, ~21 min measured, which does not fit checks-ui's 20-minute budget. Also route the config file itself through the Control UI changed-scope so a change to it triggers the job. * test(ui): isolate MCP app conformance from sidecars (cherry picked from commit c2bfd0a00edad804474ebb23449d3150557c7f78) * test(ui): list newly created E2E sessions (cherry picked from commit e1a2188699011e818e040358d4eaf6ce26eb0732) * test(ui): synchronize full-suite e2e setup * test(ui): drop implied-eval module import and align the e2e workflow guard * test(ui): wait for pin editor roving focus before keyboard nav * test(ui): read the startle class in one in-page step * test(ui): synchronize transient e2e assertions
This commit is contained in:
committed by
GitHub
parent
1755087bca
commit
0fdc825b29
@@ -1402,9 +1402,14 @@ jobs:
|
||||
contents: read
|
||||
name: checks-ui-e2e
|
||||
needs: [preflight]
|
||||
# Compatibility targets pin a frozen Control UI whose e2e expectations track
|
||||
# that release, not current main.
|
||||
if: needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 30
|
||||
# The suite runs one file at a time (fileParallelism: false) because each
|
||||
# file owns a Chromium context and a mocked Gateway; ~21 min measured, so
|
||||
# this cannot share checks-ui's 20-minute budget.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- *linux_node_checkout_step
|
||||
- name: Setup Node environment
|
||||
@@ -1419,8 +1424,10 @@ jobs:
|
||||
- name: Install Playwright Chromium
|
||||
run: node scripts/ensure-playwright-chromium.mjs
|
||||
|
||||
- name: Test Control UI new-session E2E
|
||||
run: pnpm test:ui:e2e ui/src/e2e/new-session-page.e2e.test.ts
|
||||
# main gated only new-session-page while the rest of the suite was red.
|
||||
# The whole suite is green now, so gate on all of it.
|
||||
- name: Test Control UI end-to-end
|
||||
run: pnpm test:ui:e2e
|
||||
|
||||
control-ui-i18n:
|
||||
permissions:
|
||||
@@ -3606,6 +3613,7 @@ jobs:
|
||||
- pnpm-store-warmup
|
||||
- build-artifacts
|
||||
- checks-ui
|
||||
- checks-ui-e2e
|
||||
- control-ui-i18n
|
||||
- checks-fast-core
|
||||
- checks-fast-plugin-contracts-shard
|
||||
|
||||
@@ -64,7 +64,7 @@ const RELEASE_BRANCH_RE = /^release\/\d{4}\.\d+\.\d+$/;
|
||||
export class ControlUiGeneratedArtifactsMixedError extends Error {}
|
||||
export class NativeGeneratedArtifactsMixedError extends Error {}
|
||||
const CONTROL_UI_TEST_SCOPE_RE =
|
||||
/^(ui\/|test\/vitest\/vitest\.shared\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/;
|
||||
/^(ui\/|test\/vitest\/vitest\.(?:shared|ui-e2e)\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/;
|
||||
const NATIVE_I18N_SCOPE_RE =
|
||||
/^(?:apps\/\.i18n\/|apps\/android\/(?:app\/src\/(?:main|play|thirdParty)\/|wear\/src\/main\/)|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/;
|
||||
// Android base resources are co-owned: source PRs edit their English content,
|
||||
|
||||
@@ -4294,7 +4294,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
|
||||
expect(uiTest.run).toContain("pnpm --dir ui test");
|
||||
});
|
||||
|
||||
it("gates current Control UI changes on the mocked new-session Chromium E2E", () => {
|
||||
it("gates current Control UI changes on the full mocked Chromium E2E suite", () => {
|
||||
const workflow = readCiWorkflow();
|
||||
const ui = workflow.jobs["checks-ui"];
|
||||
const uiE2e = workflow.jobs["checks-ui-e2e"];
|
||||
@@ -4305,7 +4305,9 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
|
||||
"needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true'",
|
||||
);
|
||||
expect(uiE2e["runs-on"]).toBe(ui["runs-on"]);
|
||||
expect(uiE2e["timeout-minutes"]).toBe(30);
|
||||
// The full suite runs one file at a time (fileParallelism: false), so it
|
||||
// needs a wider budget than the single-file gate this job replaced.
|
||||
expect(uiE2e["timeout-minutes"]).toBe(45);
|
||||
|
||||
const uiSetup = expectDefined(
|
||||
ui.steps.find((step: WorkflowStep) => step.name === "Setup Node environment"),
|
||||
@@ -4325,10 +4327,10 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
|
||||
expect(chromiumInstall.run).toBe("node scripts/ensure-playwright-chromium.mjs");
|
||||
|
||||
const scenario = expectDefined(
|
||||
uiE2e.steps.find((step: WorkflowStep) => step.name === "Test Control UI new-session E2E"),
|
||||
"Control UI new-session E2E regression",
|
||||
uiE2e.steps.find((step: WorkflowStep) => step.name === "Test Control UI end-to-end"),
|
||||
"Control UI E2E suite",
|
||||
);
|
||||
expect(scenario.run).toBe("pnpm test:ui:e2e ui/src/e2e/new-session-page.e2e.test.ts");
|
||||
expect(scenario.run).toBe("pnpm test:ui:e2e");
|
||||
expect(JSON.stringify(uiE2e)).not.toContain("OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM");
|
||||
});
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ class TooltipProvider extends OpenClawLitElement {
|
||||
class Tooltip extends OpenClawLitElement {
|
||||
@property() content = "";
|
||||
|
||||
/** Let a reveal-only trigger open on click instead of dismissing. */
|
||||
@property({ type: Boolean, attribute: "open-on-click" }) openOnClick = false;
|
||||
|
||||
@query("wa-tooltip") private webAwesomeTooltip?: WaTooltip;
|
||||
|
||||
private triggerElement: HTMLElement | null = null;
|
||||
@@ -337,7 +340,17 @@ class Tooltip extends OpenClawLitElement {
|
||||
}
|
||||
this.close();
|
||||
};
|
||||
private readonly handleClick = () => this.close();
|
||||
// Pointer activation normally dismisses, so an action button never strands an
|
||||
// open tooltip. A trigger whose only job is to reveal the tip opts out: on
|
||||
// touch and in browsers that do not focus buttons on click there is no other
|
||||
// way to read it.
|
||||
private readonly handleClick = () => {
|
||||
if (this.openOnClick) {
|
||||
this.show();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
};
|
||||
private readonly handleDocumentPointerUp = () => {
|
||||
document.removeEventListener("pointerup", this.handleDocumentPointerUp);
|
||||
this.suppressPointerFocus = false;
|
||||
|
||||
@@ -113,17 +113,62 @@ describeControlUiE2e("Control UI About mocked Gateway E2E", () => {
|
||||
await expect.poll(() => xLink.getAttribute("href")).toBe("https://x.com/openclaw");
|
||||
|
||||
const clawd = page.getByRole("button", { name: "Wave hello to Clawd" });
|
||||
await clawd.click();
|
||||
await expect
|
||||
.poll(() => clawd.evaluate((el) => el.classList.contains("about-hero__clawd--wave")))
|
||||
.toBe(true);
|
||||
// CLAWD_WAVE_MS clears the class after 1400ms, so click and read it in one browser step.
|
||||
const clawdWaving = await clawd.evaluate(async (element) => {
|
||||
const button = element as HTMLButtonElement;
|
||||
const owner = element.closest("openclaw-about-page") as
|
||||
| (HTMLElement & {
|
||||
updateComplete: Promise<unknown>;
|
||||
})
|
||||
| null;
|
||||
if (!owner) {
|
||||
throw new Error("About page owner is unavailable");
|
||||
}
|
||||
button.click();
|
||||
await owner.updateComplete;
|
||||
return button.classList.contains("about-hero__clawd--wave");
|
||||
});
|
||||
expect(clawdWaving).toBe(true);
|
||||
|
||||
await expect.poll(() => page.locator(".about-footer").textContent()).toContain("MIT License");
|
||||
|
||||
const copyButton = strip.locator(".about-commit button");
|
||||
await expect.poll(() => copyButton.getAttribute("aria-label")).toBe("Copy full commit hash");
|
||||
await copyButton.click();
|
||||
await expect.poll(() => copyButton.getAttribute("aria-label")).toBe("Commit hash copied");
|
||||
// COPY_RESULT_VISIBLE_MS clears the copied label after 1800ms. Await both the
|
||||
// initial copying render and the async clipboard continuation before reading it.
|
||||
const copiedLabel = await copyButton.evaluate(async (element) => {
|
||||
const button = element as HTMLButtonElement;
|
||||
const owner = element.closest("openclaw-about-page") as
|
||||
| (HTMLElement & {
|
||||
updateComplete: Promise<unknown>;
|
||||
})
|
||||
| null;
|
||||
if (!owner) {
|
||||
throw new Error("About page owner is unavailable");
|
||||
}
|
||||
let copyObserver: MutationObserver | undefined;
|
||||
const copySettled = new Promise<void>((resolve) => {
|
||||
copyObserver = new MutationObserver(() => {
|
||||
if (button.getAttribute("aria-busy") !== "true") {
|
||||
copyObserver?.disconnect();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
copyObserver.observe(button, {
|
||||
attributeFilter: ["aria-busy", "aria-label"],
|
||||
attributes: true,
|
||||
});
|
||||
});
|
||||
button.click();
|
||||
await owner.updateComplete;
|
||||
if (button.getAttribute("aria-busy") === "true") {
|
||||
await copySettled;
|
||||
}
|
||||
copyObserver?.disconnect();
|
||||
await owner.updateComplete;
|
||||
return button.getAttribute("aria-label");
|
||||
});
|
||||
expect(copiedLabel).toBe("Commit hash copied");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
|
||||
@@ -91,7 +91,7 @@ describeControlUiE2e("Control UI agents Set Default mocked Gateway E2E", () => {
|
||||
// non-default agent.
|
||||
const agentSelect = page.locator("wa-dropdown.agent-select");
|
||||
await agentSelect.locator(".agent-select__trigger").click();
|
||||
await agentSelect.getByRole("menuitemcheckbox", { name: "Kimi agent", exact: true }).click();
|
||||
await agentSelect.getByRole("menuitemradio", { name: "Kimi agent", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Set Default", exact: true }).click();
|
||||
|
||||
// The fix routes Set Default through the canonical save path; without it the click
|
||||
|
||||
@@ -59,7 +59,7 @@ describeControlUiE2e("Control UI approval flow", () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("keeps an older resolve failure off the newly active approval", async () => {
|
||||
it("keeps a resolve failure scoped to its approval when a newer one arrives", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
|
||||
const currentPage = await context.newPage();
|
||||
page = currentPage;
|
||||
@@ -85,6 +85,18 @@ describeControlUiE2e("Control UI approval flow", () => {
|
||||
message: "gateway unavailable",
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
currentPage
|
||||
.locator('[data-approval-id="approval-active"] .exec-approval-error')
|
||||
.textContent(),
|
||||
)
|
||||
.toBe("Approval failed: gateway unavailable");
|
||||
|
||||
await currentPage.getByText("echo newer", { exact: true }).click();
|
||||
await expect
|
||||
.poll(() => currentPage.locator(".exec-approval-card").getAttribute("data-approval-id"))
|
||||
.toBe("approval-newer");
|
||||
await expect.poll(() => currentPage.locator(".exec-approval-error").count()).toBe(0);
|
||||
await expect
|
||||
.poll(() => currentPage.getByRole("button", { name: "Deny" }).isEnabled())
|
||||
|
||||
@@ -192,6 +192,7 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
methodResponses: {
|
||||
"talk.client.create": {
|
||||
provider: "google",
|
||||
voiceSessionId: "voice-browser-talk-e2e",
|
||||
transport: "provider-websocket",
|
||||
protocol: "google-live-bidi",
|
||||
clientSecret: "auth_tokens/browser-talk-e2e",
|
||||
@@ -373,6 +374,7 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
methodResponses: {
|
||||
"talk.client.create": {
|
||||
provider: "google",
|
||||
voiceSessionId: "voice-controls-e2e",
|
||||
transport: "provider-websocket",
|
||||
protocol: "google-live-bidi",
|
||||
// Fake harness token, assembled so secret scanners do not flag it.
|
||||
@@ -491,6 +493,7 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
"talk.catalog": videoTalkCatalog("openai"),
|
||||
"talk.client.create": {
|
||||
provider: "openai",
|
||||
voiceSessionId: "voice-openai-video-e2e",
|
||||
transport: "webrtc",
|
||||
clientSecret: "test-client-secret",
|
||||
offerUrl: "https://api.openai.com/v1/realtime/calls",
|
||||
@@ -717,6 +720,7 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
"talk.catalog": videoTalkCatalog("google"),
|
||||
"talk.client.create": {
|
||||
provider: "google",
|
||||
voiceSessionId: "voice-google-video-e2e",
|
||||
transport: "provider-websocket",
|
||||
protocol: "google-live-bidi",
|
||||
// Fake harness token, assembled so secret scanners do not flag it.
|
||||
@@ -862,6 +866,7 @@ describeControlUiE2e("Control UI browser Talk", () => {
|
||||
"talk.catalog": videoTalkCatalog("google"),
|
||||
"talk.client.create": {
|
||||
provider: "google",
|
||||
voiceSessionId: "voice-blocked-camera-e2e",
|
||||
transport: "provider-websocket",
|
||||
protocol: "google-live-bidi",
|
||||
// Fake harness token, assembled so secret scanners do not flag it.
|
||||
|
||||
@@ -47,9 +47,9 @@ function containsBrokenSurrogate(value: string): boolean {
|
||||
}
|
||||
|
||||
async function openBuildDetails(page: Page) {
|
||||
const buildLink = page
|
||||
.locator("openclaw-app-sidebar")
|
||||
.getByRole("link", { name: "Control UI build details", exact: true });
|
||||
const sidebar = page.locator("openclaw-app-sidebar");
|
||||
await sidebar.getByRole("button", { name: /^Identity and app menu for / }).click();
|
||||
const buildLink = sidebar.getByRole("link", { name: "Control UI build details", exact: true });
|
||||
await buildLink.waitFor();
|
||||
const compactText = (await buildLink.textContent()) ?? "";
|
||||
expect(compactText).toContain(`${COMPACT_BRANCH}@0123456`);
|
||||
|
||||
@@ -59,6 +59,12 @@ describeControlUiE2e("Control UI WhatsApp logout mocked Gateway E2E", () => {
|
||||
channelAccounts: {},
|
||||
channelDefaultAccountId: {},
|
||||
},
|
||||
"channels.pairing.list": {
|
||||
accounts: [],
|
||||
requests: [],
|
||||
commandOwnerConfigured: true,
|
||||
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
|
||||
},
|
||||
"web.login.start": {
|
||||
connected: false,
|
||||
message: "Scan this QR.",
|
||||
|
||||
@@ -375,8 +375,8 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
|
||||
.poll(() => page.getByRole("button", { name: "Send message" }).isVisible())
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Start voice input" }).count())
|
||||
.toBe(0);
|
||||
.poll(() => page.getByRole("button", { name: "Start voice input" }).isVisible())
|
||||
.toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
const sendRequest = await gateway.waitForRequest("chat.send");
|
||||
@@ -774,7 +774,19 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
deferredMethods: ["chat.startup", "chat.metadata"],
|
||||
deferredMethods: ["chat.startup"],
|
||||
methodResponses: {
|
||||
"chat.metadata": {
|
||||
cases: [
|
||||
{
|
||||
match: { agentId: "work" },
|
||||
response: {
|
||||
__mockError: { code: "UNAVAILABLE", message: "metadata unavailable" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
models: [{ id: "gpt-default", name: "GPT Default", provider: "openai", available: true }],
|
||||
});
|
||||
|
||||
@@ -792,9 +804,23 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
await gateway.rejectDeferred("chat.metadata", {
|
||||
code: "UNAVAILABLE",
|
||||
message: "metadata unavailable",
|
||||
await page.waitForFunction(() => {
|
||||
const pane = document.querySelector("openclaw-chat-pane") as
|
||||
| (HTMLElement & {
|
||||
state?: {
|
||||
sessionKey?: string;
|
||||
chatMetadataRequestVersion?: number;
|
||||
chatModelCatalog?: unknown[];
|
||||
chatModelsLoading?: boolean;
|
||||
};
|
||||
})
|
||||
| null;
|
||||
return (
|
||||
pane?.state?.sessionKey === "agent:work:main" &&
|
||||
(pane.state.chatMetadataRequestVersion ?? 0) >= 2 &&
|
||||
pane.state.chatModelsLoading === false &&
|
||||
pane.state.chatModelCatalog?.length === 0
|
||||
);
|
||||
});
|
||||
const agentsRequestsBeforeStartup = (await gateway.getRequests("agents.list")).length;
|
||||
await gateway.resolveDeferred("chat.startup", {
|
||||
@@ -815,9 +841,6 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
|
||||
sessionId: "control-ui-e2e-session",
|
||||
thinkingLevel: null,
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("agents.list")).length)
|
||||
.toBeGreaterThan(agentsRequestsBeforeStartup);
|
||||
await page.waitForFunction(() => {
|
||||
const pane = document.querySelector("openclaw-chat-pane") as
|
||||
| (HTMLElement & {
|
||||
@@ -829,6 +852,7 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
|
||||
pane.state.agentsList.agents?.some((agent) => agent.id === "main") === true
|
||||
);
|
||||
});
|
||||
expect(await gateway.getRequests("agents.list")).toHaveLength(agentsRequestsBeforeStartup);
|
||||
const composer = page.locator(".agent-chat__input");
|
||||
await expect
|
||||
.poll(async () =>
|
||||
@@ -836,10 +860,17 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
|
||||
)
|
||||
.not.toContain("GPT Default");
|
||||
const metadataRequests = await gateway.getRequests("chat.metadata");
|
||||
expect(metadataRequests).toHaveLength(1);
|
||||
expect((metadataRequests[0]?.params as { agentId?: string } | undefined)?.agentId).toBe(
|
||||
"work",
|
||||
);
|
||||
expect(
|
||||
metadataRequests.filter(
|
||||
(request) => (request.params as { agentId?: string } | undefined)?.agentId === "work",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
metadataRequests.every(
|
||||
(request) =>
|
||||
typeof (request.params as { agentId?: string } | undefined)?.agentId === "string",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(await gateway.getRequests("models.list")).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
|
||||
@@ -711,7 +711,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
await page.reload();
|
||||
|
||||
await page.getByText(historyText).waitFor({ timeout: 10_000 });
|
||||
await expect.poll(async () => (await gateway.getRequests("chat.startup")).length).toBe(1);
|
||||
await expect.poll(async () => (await gateway.getRequests("chat.startup")).length).toBe(2);
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
}
|
||||
@@ -1635,13 +1635,36 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
const scrollTopBefore = await thread.evaluate((element) =>
|
||||
Math.round((element as HTMLElement).scrollTop),
|
||||
);
|
||||
await copyButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => copyButton.evaluate((el) => el.classList.contains("copied")), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(true);
|
||||
// The copied class clears after 1500ms, so click and read it in one browser step.
|
||||
const copied = await copyButton.evaluate(async (element) => {
|
||||
const button = element as HTMLButtonElement;
|
||||
const owner = element.closest("openclaw-chat-pane") as
|
||||
| (HTMLElement & {
|
||||
updateComplete: Promise<unknown>;
|
||||
})
|
||||
| null;
|
||||
if (!owner) {
|
||||
throw new Error("Chat pane owner is unavailable");
|
||||
}
|
||||
let copyObserver: MutationObserver | undefined;
|
||||
const copySettled = new Promise<void>((resolve) => {
|
||||
copyObserver = new MutationObserver(() => {
|
||||
if (button.classList.contains("copied")) {
|
||||
copyObserver?.disconnect();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
copyObserver.observe(button, { attributeFilter: ["class"], attributes: true });
|
||||
});
|
||||
button.click();
|
||||
await owner.updateComplete;
|
||||
if (!button.classList.contains("copied")) {
|
||||
await copySettled;
|
||||
}
|
||||
copyObserver?.disconnect();
|
||||
return button.classList.contains("copied");
|
||||
});
|
||||
expect(copied).toBe(true);
|
||||
expect(await copiedViaExec(page)).toContain(code);
|
||||
await expect
|
||||
.poll(() => thread.evaluate((element) => Math.round((element as HTMLElement).scrollTop)))
|
||||
@@ -2652,8 +2675,32 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
const configPatchCount = (await gateway.getRequests("config.patch")).length;
|
||||
const configGetCount = (await gateway.getRequests("config.get")).length;
|
||||
const overrideConfig = {
|
||||
...runtimeConfig,
|
||||
ui: { prefs: { chatFollowUpMode: "steer" } },
|
||||
};
|
||||
await gateway.setMethodResponse("config.get", {
|
||||
config: overrideConfig,
|
||||
hash: "queue-followup-override-config",
|
||||
issues: [],
|
||||
raw: JSON.stringify(overrideConfig),
|
||||
runtimeConfig: overrideConfig,
|
||||
valid: true,
|
||||
});
|
||||
await followUpSelect.selectOption("steer");
|
||||
await waitForRequests(gateway, "config.patch", configPatchCount + 1);
|
||||
await waitForRequests(gateway, "config.get", configGetCount + 1);
|
||||
await page.getByText("Overriding server default (followup)").waitFor({ timeout: 10_000 });
|
||||
await gateway.setMethodResponse("config.get", {
|
||||
config: runtimeConfig,
|
||||
hash: "queue-followup-reset-config",
|
||||
issues: [],
|
||||
raw: JSON.stringify(runtimeConfig),
|
||||
runtimeConfig,
|
||||
valid: true,
|
||||
});
|
||||
if (artifactDir) {
|
||||
await page.screenshot({
|
||||
path: `${artifactDir}/server-followup-override.png`,
|
||||
@@ -2661,6 +2708,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
});
|
||||
}
|
||||
await page.getByRole("button", { name: "Reset to server default" }).click();
|
||||
await waitForRequests(gateway, "config.patch", configPatchCount + 2);
|
||||
await waitForRequests(gateway, "config.get", configGetCount + 2);
|
||||
await page.getByText("Using server default (followup)").waitFor({ timeout: 10_000 });
|
||||
expect(await followUpSelect.inputValue()).toBe("server");
|
||||
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
@@ -2796,11 +2846,17 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
await queue.getByRole("button", { name: "Steer" }).click();
|
||||
|
||||
const steerRequest = await gateway.waitForRequest("chat.send");
|
||||
expect(requireRecord(steerRequest.params)).toMatchObject({
|
||||
const steerParams = requireRecord(steerRequest.params);
|
||||
expect(steerParams).toMatchObject({
|
||||
deliver: false,
|
||||
message: queuedPrompt,
|
||||
sessionKey: "main",
|
||||
});
|
||||
await queue.getByText("Steered").waitFor({ timeout: 10_000 });
|
||||
await gateway.emitChatFinal({
|
||||
runId: requireString(steerParams.idempotencyKey, "restored steer idempotency key"),
|
||||
text: "Restored steer completed.",
|
||||
});
|
||||
await queue.getByText(queuedPrompt).waitFor({ state: "detached", timeout: 10_000 });
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
@@ -2955,7 +3011,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
historyMessages: currentSessionMessages,
|
||||
methodResponses: {
|
||||
"chat.history": {
|
||||
"chat.startup": {
|
||||
cases: [
|
||||
{
|
||||
match: { sessionKey: "agent:main:session-b" },
|
||||
@@ -2984,12 +3040,18 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.getByText("Current session placeholder").waitFor({ timeout: 10_000 });
|
||||
|
||||
const startupCountBeforeSwitch = (await gateway.getRequests("chat.startup")).length;
|
||||
await page
|
||||
.locator(
|
||||
'.sidebar-recent-session[data-session-key="agent:main:session-b"] a.sidebar-recent-session__link',
|
||||
)
|
||||
.click();
|
||||
const historyRequest = await gateway.waitForRequest("chat.history");
|
||||
const startupRequests = await waitForRequests(
|
||||
gateway,
|
||||
"chat.startup",
|
||||
startupCountBeforeSwitch + 1,
|
||||
);
|
||||
const historyRequest = expectDefined(startupRequests.at(-1), "session B startup request");
|
||||
expect(requireRecord(historyRequest.params)).toMatchObject({
|
||||
sessionKey: "agent:main:session-b",
|
||||
});
|
||||
@@ -3106,6 +3168,31 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
"chat.startup": {
|
||||
cases: [
|
||||
{
|
||||
match: { sessionKey: "agent:main:session-b" },
|
||||
response: {
|
||||
hasMore: true,
|
||||
messages: recentMessages,
|
||||
nextOffset: 100,
|
||||
sessionId: "retained-history-session",
|
||||
thinkingLevel: null,
|
||||
totalMessages: 140,
|
||||
},
|
||||
},
|
||||
{
|
||||
match: {},
|
||||
response: {
|
||||
hasMore: false,
|
||||
messages: shortMessages,
|
||||
sessionId: "short-history-session",
|
||||
thinkingLevel: null,
|
||||
totalMessages: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"sessions.list": chatSessionListResponse(),
|
||||
},
|
||||
sessionKey: "agent:main:session-a",
|
||||
@@ -3329,7 +3416,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.goto(controlUiSessionUrl(server.baseUrl, "global"));
|
||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
||||
await composer.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
@@ -3456,10 +3543,12 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
type: "file",
|
||||
},
|
||||
]);
|
||||
await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 });
|
||||
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
|
||||
await queue.getByText("Needs review").waitFor({ timeout: 10_000 });
|
||||
await queue
|
||||
.getByText("Delivery could not be confirmed after reconnect.", { exact: false })
|
||||
.waitFor({ timeout: 10_000 });
|
||||
if (artifactDir) {
|
||||
await page.screenshot({ path: `${artifactDir}/02-reconnected-active.png`, fullPage: true });
|
||||
await page.screenshot({ path: `${artifactDir}/02-reconnected-review.png`, fullPage: true });
|
||||
}
|
||||
await expectRequestCountStable(gateway, "chat.send", 1);
|
||||
const requestsAfterReconnect = await gateway.getRequests("chat.send");
|
||||
@@ -3471,6 +3560,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
]);
|
||||
await gateway.emitChatFinal({ runId, text: "Delivered after reconnect." });
|
||||
await queue.waitFor({ state: "detached", timeout: 10_000 });
|
||||
await page.locator(".chat-thread").getByText(prompt).waitFor({ timeout: 10_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const proof = await readStoredProof();
|
||||
@@ -3771,7 +3861,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder.slice(0, 11));
|
||||
await page.getByRole("button", { name: "Load more" }).click();
|
||||
await page.getByRole("button", { name: "Show more" }).click();
|
||||
await expect.poll(() => sidebarSessionOrder(page)).toEqual(createdOrder);
|
||||
|
||||
await page
|
||||
|
||||
@@ -60,7 +60,7 @@ describeControlUiE2e("session pull request chips", () => {
|
||||
it("pins detected PR chips above the composer with rate-limit staleness", async () => {
|
||||
const context = await newBrowserContext();
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "controlUi.sessionPullRequests"],
|
||||
methodResponses: {
|
||||
"controlUi.sessionPullRequests": {
|
||||
@@ -102,6 +102,7 @@ describeControlUiE2e("session pull request chips", () => {
|
||||
},
|
||||
});
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("controlUi.sessionPullRequests");
|
||||
|
||||
// Three detected PRs collapse to two chips; merged history hides first.
|
||||
const chips = page.locator(".chat-pr");
|
||||
@@ -170,7 +171,7 @@ describeControlUiE2e("session pull request chips", () => {
|
||||
it("offers a Create PR row with the stale warning while rate limited pre-PR", async () => {
|
||||
const context = await newBrowserContext();
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["chat.metadata", "chat.startup", "controlUi.sessionPullRequests"],
|
||||
methodResponses: {
|
||||
"controlUi.sessionPullRequests": {
|
||||
@@ -189,6 +190,7 @@ describeControlUiE2e("session pull request chips", () => {
|
||||
},
|
||||
});
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("controlUi.sessionPullRequests");
|
||||
|
||||
const row = page.locator('.chat-pr[data-state="branch"]');
|
||||
await expect.poll(() => row.count()).toBe(1);
|
||||
|
||||
@@ -92,9 +92,9 @@ describeControlUiE2e("session discussion toggle", () => {
|
||||
await showDiscussion.click();
|
||||
|
||||
const hideDiscussion = page.getByRole("button", { name: "Hide discussion" });
|
||||
const closeSidebar = page.getByRole("button", { name: "Close sidebar" });
|
||||
const closeDiscussion = page.getByRole("button", { name: "Close Discussion" });
|
||||
await expect.poll(() => hideDiscussion.getAttribute("aria-pressed")).toBe("true");
|
||||
await expect.poll(() => closeSidebar.isVisible()).toBe(true);
|
||||
await expect.poll(() => closeDiscussion.isVisible()).toBe(true);
|
||||
expect(await gateway.getRequests("session.discussion.open")).toHaveLength(1);
|
||||
if (captureUiProof) {
|
||||
await page.screenshot({ path: path.join(proofDir, "discussion-open.png") });
|
||||
@@ -102,7 +102,7 @@ describeControlUiE2e("session discussion toggle", () => {
|
||||
|
||||
await hideDiscussion.click();
|
||||
|
||||
await expect.poll(() => closeSidebar.isVisible()).toBe(false);
|
||||
await expect.poll(() => closeDiscussion.isVisible()).toBe(false);
|
||||
await expect.poll(() => showDiscussion.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(await gateway.getRequests("session.discussion.open")).toHaveLength(1);
|
||||
if (captureUiProof) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { chromium, type Browser } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
controlUiSessionUrl,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
@@ -63,7 +64,9 @@ describeControlUiE2e("Control UI autonomous tool-turn outcomes", () => {
|
||||
it("keeps an earlier autonomous failure visible after a later turn recovers", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
|
||||
const page = await context.newPage();
|
||||
const sessionKey = "agent:main:dashboard:tool-turn-outcome";
|
||||
await installMockGateway(page, {
|
||||
sessionKey,
|
||||
historyMessages: [
|
||||
failedTool(1),
|
||||
{
|
||||
@@ -82,7 +85,7 @@ describeControlUiE2e("Control UI autonomous tool-turn outcomes", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.goto(controlUiSessionUrl(server.baseUrl, sessionKey));
|
||||
await page.getByText("Recovered on the next autonomous turn.", { exact: true }).waitFor();
|
||||
await expandCompletedWorkGroups(page);
|
||||
|
||||
|
||||
@@ -248,11 +248,15 @@ suite("Claude native session catalog", () => {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expandCodingSection(page);
|
||||
for (const catalogId of ["claude", "codex"]) {
|
||||
const catalogLabel = catalogId === "claude" ? "Claude Code" : "Codex";
|
||||
const section = page.locator(`[data-session-section="catalog:${catalogId}"]`);
|
||||
const gatewayHost = section.locator('[data-session-catalog-host="gateway:local"]');
|
||||
const buildHost = section.locator('[data-session-catalog-host="node:build"]');
|
||||
await gatewayHost.getByText("Gateway Mac", { exact: true }).waitFor();
|
||||
await gatewayHost.getByText(`${catalogLabel} local plan`, { exact: true }).waitFor();
|
||||
await buildHost.getByText("Build Node", { exact: true }).waitFor();
|
||||
await buildHost.getByText(`${catalogLabel} remote review`, { exact: true }).waitFor();
|
||||
expect(await gatewayHost.locator(".sidebar-session-catalog-host__head").count()).toBe(0);
|
||||
expect(await gatewayHost.getByText("Gateway Mac", { exact: true }).count()).toBe(0);
|
||||
expect(await gatewayHost.locator(".sidebar-recent-session").count()).toBe(1);
|
||||
expect(await buildHost.locator(".sidebar-recent-session").count()).toBe(1);
|
||||
}
|
||||
|
||||
@@ -177,9 +177,10 @@ suite("Codex native session catalog", () => {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "dark"));
|
||||
await expandCodingSection(page);
|
||||
const workSection = page.locator('[data-session-section="work"]');
|
||||
const sessionGroups = page.locator(".sidebar-recent-sessions");
|
||||
const workSection = sessionGroups.locator(':scope > [data-session-section="work"]');
|
||||
const liveRows = workSection.locator(":scope > .sidebar-recent-sessions__list");
|
||||
const catalog = workSection.locator(':scope > [data-session-section="catalog:codex"]');
|
||||
const catalog = sessionGroups.locator(':scope > [data-session-section="catalog:codex"]');
|
||||
await catalog.waitFor({ state: "visible" });
|
||||
const [liveRowsBox, catalogBox] = await Promise.all([
|
||||
liveRows.boundingBox(),
|
||||
@@ -190,7 +191,7 @@ suite("Codex native session catalog", () => {
|
||||
expect(Math.round(catalogBox!.y - (liveRowsBox!.y + liveRowsBox!.height))).toBe(10);
|
||||
if (captureUiProofEnabled) {
|
||||
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||
await workSection.screenshot({
|
||||
await sessionGroups.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, "06-coding-catalog-spacing.png"),
|
||||
});
|
||||
@@ -612,7 +613,7 @@ suite("Codex native session catalog", () => {
|
||||
});
|
||||
}
|
||||
|
||||
await page.goto(`${server.baseUrl}settings/automation?section=plugins`);
|
||||
await page.goto(`${server.baseUrl}settings/automation?section=plugins&advanced=1`);
|
||||
const expandPluginSetting = async (pluginLabel: string) => {
|
||||
const pluginGroup = page
|
||||
.getByText(pluginLabel, { exact: true })
|
||||
@@ -782,6 +783,28 @@ suite("Codex native session catalog", () => {
|
||||
await expect.poll(() => page.getByText("prepare release", { exact: true }).count()).toBe(1);
|
||||
const composer = page.locator(".agent-chat__composer-combobox > textarea");
|
||||
await composer.fill("continue with the final checks");
|
||||
await gateway.setMethodResponse("sessions.list", {
|
||||
count: 1,
|
||||
defaults: {
|
||||
contextTokens: null,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
},
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
contextTokens: null,
|
||||
displayName: "Adopted Codex session",
|
||||
key: "agent:main:adopted-codex",
|
||||
kind: "direct",
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
totalTokens: 0,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
ts: Date.now(),
|
||||
});
|
||||
await composer.press("Enter");
|
||||
const continued = await gateway.waitForRequest("sessions.catalog.continue");
|
||||
expect(continued.params).toEqual({
|
||||
|
||||
@@ -413,6 +413,7 @@ describeConformance("MCP App Control UI and standalone host conformance", () =>
|
||||
"OPENCLAW_SKIP_CHANNELS",
|
||||
"OPENCLAW_SKIP_CRON",
|
||||
"OPENCLAW_SKIP_PROVIDERS",
|
||||
"OPENCLAW_TEST_MINIMAL_GATEWAY",
|
||||
"OPENCLAW_BUNDLED_PLUGINS_DIR",
|
||||
]);
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mcp-app-conformance-"));
|
||||
@@ -468,6 +469,7 @@ describeConformance("MCP App Control UI and standalone host conformance", () =>
|
||||
setTestEnvValue("OPENCLAW_SKIP_CHANNELS", "1");
|
||||
setTestEnvValue("OPENCLAW_SKIP_CRON", "1");
|
||||
setTestEnvValue("OPENCLAW_SKIP_PROVIDERS", "1");
|
||||
setTestEnvValue("OPENCLAW_TEST_MINIMAL_GATEWAY", "1");
|
||||
setTestEnvValue("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(tempRoot, "empty-plugins"));
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
@@ -162,7 +162,7 @@ describeControlUiE2e("native link routing", () => {
|
||||
expect(bubbleBox).not.toBeNull();
|
||||
await bubble.click({
|
||||
button: "right",
|
||||
position: { x: bubbleBox!.width - 8, y: bubbleBox!.height - 8 },
|
||||
position: { x: bubbleBox!.width - 8, y: bubbleBox!.height - 2 },
|
||||
});
|
||||
const replyMenu = page.getByRole("menu", { name: "Message actions" });
|
||||
await expect.poll(() => replyMenu.isVisible()).toBe(true);
|
||||
@@ -202,12 +202,39 @@ describeControlUiE2e("native link routing", () => {
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe("https://example.com/report");
|
||||
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.evaluate(() => {
|
||||
const host = window as Window & {
|
||||
openclawModifiedLinkClick?: { defaultPrevented: boolean; metaKey: boolean };
|
||||
};
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
host.openclawModifiedLinkClick = {
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
metaKey: event.metaKey,
|
||||
};
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
await link.click({ modifiers: ["Meta"] });
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState("domcontentloaded");
|
||||
expect(popup.url()).toBe("https://example.com/report");
|
||||
await popup.close();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & {
|
||||
openclawModifiedLinkClick?: { defaultPrevented: boolean; metaKey: boolean };
|
||||
}
|
||||
).openclawModifiedLinkClick,
|
||||
),
|
||||
).toEqual({ defaultPrevented: false, metaKey: true });
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
(window as Window & { openclawNativeLinkMessages?: unknown[] })
|
||||
.openclawNativeLinkMessages,
|
||||
),
|
||||
).toHaveLength(3);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await customElements.whenDefined("openclaw-modal-dialog");
|
||||
@@ -262,7 +289,7 @@ describeControlUiE2e("native link routing", () => {
|
||||
.click({ button: "right" });
|
||||
expect(await page.locator("openclaw-native-link-menu").count()).toBe(0);
|
||||
const messageMenu = page.getByRole("menu", { name: "Message actions" });
|
||||
await expect.poll(() => messageMenu.isVisible()).toBe(true);
|
||||
await expect.poll(() => messageMenu.isVisible()).toBe(false);
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await page.keyboard.press("Escape");
|
||||
await expect.poll(() => messageMenu.count()).toBe(0);
|
||||
|
||||
@@ -52,6 +52,27 @@ const SESSION_LIST_DEFAULTS = {
|
||||
modelProvider: "openai",
|
||||
};
|
||||
|
||||
function createdSessionListResult(sessionKey: string) {
|
||||
return {
|
||||
count: 1,
|
||||
defaults: SESSION_LIST_DEFAULTS,
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
contextTokens: null,
|
||||
displayName: "Created session",
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
totalTokens: 0,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
ts: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function captureUiProof(page: Page, fileName: string) {
|
||||
if (!captureUiProofEnabled) {
|
||||
return;
|
||||
@@ -287,6 +308,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.create": { key: sessionKey, runStarted: true },
|
||||
"sessions.list": createdSessionListResult(sessionKey),
|
||||
"chat.startup": {
|
||||
messages: [
|
||||
{
|
||||
@@ -352,6 +374,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.create": { key: sessionKey, runStarted: true },
|
||||
"sessions.list": createdSessionListResult(sessionKey),
|
||||
"chat.startup": {
|
||||
messages: [],
|
||||
sessionId: "reconnected-initial-prompt",
|
||||
@@ -428,6 +451,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
runStarted: true,
|
||||
messageSeq: 1,
|
||||
},
|
||||
"sessions.list": createdSessionListResult(sessionKey),
|
||||
"chat.startup": {
|
||||
messages: [
|
||||
{
|
||||
@@ -2042,6 +2066,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
repositoryStatus: "git",
|
||||
},
|
||||
"sessions.create": { key: sessionKey },
|
||||
"sessions.list": createdSessionListResult(sessionKey),
|
||||
"sessions.dispatch": {
|
||||
ok: true,
|
||||
key: sessionKey,
|
||||
@@ -4213,6 +4238,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
runStarted: false,
|
||||
runError: { code: "INVALID_REQUEST", message: runError },
|
||||
},
|
||||
"sessions.list": createdSessionListResult(sessionKey),
|
||||
"chat.history": {
|
||||
messages: [],
|
||||
sessionId: "storage-failed-initial-turn",
|
||||
@@ -4335,6 +4361,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
],
|
||||
},
|
||||
"sessions.create": { key: "agent:main:node-draft-e2e" },
|
||||
"sessions.list": createdSessionListResult("agent:main:node-draft-e2e"),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -354,17 +354,23 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
|
||||
|
||||
const divider = page.locator(".board-session-surface__divider");
|
||||
const dock = page.locator(".board-session-surface__chat");
|
||||
const dockHeight = () => dock.evaluate((element) => getComputedStyle(element).height);
|
||||
await divider.focus();
|
||||
await page.keyboard.press("End");
|
||||
await expect.poll(() => dock.getAttribute("style")).not.toBe("height: 320px");
|
||||
const persistedStyle = await dock.getAttribute("style");
|
||||
expect(persistedStyle).toMatch(/^height: \d+(?:\.\d+)?px$/u);
|
||||
await expect.poll(dockHeight).not.toBe("320px");
|
||||
const clampedHeight = await dockHeight();
|
||||
// End pins the bottom dock against its clamp, so step back off it: comparing
|
||||
// a clamped height to itself after reload would pass if persistence broke and
|
||||
// the dock merely fell back to its minimum.
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect.poll(dockHeight).not.toBe(clampedHeight);
|
||||
const persistedHeight = await dockHeight();
|
||||
expect(persistedHeight).toMatch(/^\d+(?:\.\d+)?px$/u);
|
||||
|
||||
await page.reload();
|
||||
await page.locator(".board-session-surface__chat").waitFor();
|
||||
expect(await page.locator(".board-session-surface__chat").getAttribute("style")).toBe(
|
||||
persistedStyle,
|
||||
);
|
||||
await dock.waitFor();
|
||||
expect(await dockHeight()).toBe(persistedHeight);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator('.chat-tool-card__preview[data-kind="canvas"] [data-pin-widget]').isDisabled(),
|
||||
|
||||
@@ -835,7 +835,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
});
|
||||
|
||||
const assertSelectedRoute = async () => {
|
||||
await expect.poll(() => page.url()).toContain(`session=${encodeURIComponent(selected.key)}`);
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname)
|
||||
.toBe(controlUiSessionPath(selected.key));
|
||||
const row = page.locator(`.sidebar-recent-session[data-session-key="${selected.key}"]`);
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await expect
|
||||
@@ -1465,8 +1467,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
|
||||
await alphaToggle.click();
|
||||
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0);
|
||||
|
||||
// Reorder by dragging the whole group header (not just the dot handle).
|
||||
await gamma.locator(".sidebar-recent-sessions__head").dragTo(alpha, {
|
||||
// Header buttons intentionally keep their click behavior; reorder from
|
||||
// the dedicated grip beside them.
|
||||
await gamma.locator(".sidebar-session-group-drag-handle").dragTo(alpha, {
|
||||
targetPosition: { x: 4, y: 2 },
|
||||
});
|
||||
const customGroupOrder = () =>
|
||||
|
||||
@@ -448,7 +448,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
|
||||
await captureSettingsSidebarProof(settingsSidebar, "01f-settings-search-navigated.png");
|
||||
await holdUiProof(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/chat");
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(controlUiSessionPath("main"));
|
||||
await expect.poll(() => sidebar.isVisible()).toBe(true);
|
||||
await openSettingsFromIdentity();
|
||||
await expect.poll(() => settingsSidebar.isVisible()).toBe(true);
|
||||
@@ -777,10 +777,19 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
|
||||
expect(Number.parseFloat(movement.after)).toBeGreaterThanOrEqual(18);
|
||||
expect(Number.parseFloat(movement.after)).toBeLessThanOrEqual(50);
|
||||
await expectLobsterOnFooterLedge(sidebar);
|
||||
const sprite = pet.locator(".lobster-pet:not(.lobster-pet--passer)").first();
|
||||
await sprite.dispatchEvent("pointerdown");
|
||||
await sprite.dispatchEvent("pointerup");
|
||||
await expect.poll(() => sprite.getAttribute("class")).toContain("lobster-pet--act-startle");
|
||||
// startle clears itself after LOBSTER_PET_ACT_DURATION_MS.startle (750ms), so
|
||||
// poking over one round trip and then polling for the class over another can
|
||||
// straddle the entire window on a loaded runner and never observe it. Poke and
|
||||
// read the resulting class in a single in-page step, as the unit test does.
|
||||
const startleClasses = await pet.evaluate(async (element) => {
|
||||
const lobster = element as HTMLElement & { updateComplete: Promise<unknown> };
|
||||
const target = lobster.querySelector<HTMLElement>(".lobster-pet:not(.lobster-pet--passer)");
|
||||
target?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
target?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
||||
await lobster.updateComplete;
|
||||
return target?.getAttribute("class") ?? "";
|
||||
});
|
||||
expect(startleClasses).toContain("lobster-pet--act-startle");
|
||||
await captureUiProof(page, "08-lobster-footer-ledge-desktop.png");
|
||||
|
||||
await page.setViewportSize({ height: 900, width: 900 });
|
||||
@@ -809,6 +818,17 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () =
|
||||
"wa-dropdown.sidebar-customize-menu:not(.sidebar-more-menu):not(.sidebar-agent-menu)",
|
||||
)
|
||||
.locator('[role="menuitem"], [role="menuitemcheckbox"]');
|
||||
// The pin editor installs roving focus asynchronously. Pressing End before it
|
||||
// settles sends the key to the outgoing More menu, so the list never moves and
|
||||
// the focus assertions below can never become true.
|
||||
await expect
|
||||
.poll(() =>
|
||||
pinItems.evaluateAll((items) => items.filter((item) => item.tabIndex === 0).length),
|
||||
)
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(() => pinItems.first().evaluate((element) => element === document.activeElement))
|
||||
.toBe(true);
|
||||
await page.keyboard.press("End");
|
||||
await expect
|
||||
.poll(() => pinItems.last().evaluate((element) => element === document.activeElement))
|
||||
|
||||
@@ -55,10 +55,18 @@ describeControlUiE2e("Control UI terminal runtime isolation", () => {
|
||||
|
||||
try {
|
||||
await page.goto(server.baseUrl);
|
||||
// addScriptTag resolves before the module body runs, so the global is not
|
||||
// observable yet; wait for the assignment instead of racing page.evaluate.
|
||||
await page.addScriptTag({
|
||||
content: `globalThis.openclawTerminalRuntimeModule = import(${JSON.stringify(moduleUrl)});`,
|
||||
type: "module",
|
||||
});
|
||||
await page.waitForFunction(() =>
|
||||
Boolean(
|
||||
(globalThis as unknown as { openclawTerminalRuntimeModule?: unknown })
|
||||
.openclawTerminalRuntimeModule,
|
||||
),
|
||||
);
|
||||
const sentinel = "CLOSE_RESET_SENTINEL";
|
||||
const result = await page.evaluate(
|
||||
async ({ staleText }) => {
|
||||
|
||||
@@ -56,6 +56,7 @@ describeControlUiE2e("Control UI coalesced update E2E", () => {
|
||||
|
||||
try {
|
||||
expect((await page.goto(`${server.baseUrl}chat`))?.status()).toBe(200);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
await gateway.emitGatewayEvent("update.available", {
|
||||
updateAvailable: {
|
||||
channel: "stable",
|
||||
@@ -119,6 +120,7 @@ describeControlUiE2e("Control UI coalesced update E2E", () => {
|
||||
|
||||
try {
|
||||
expect((await page.goto(`${server.baseUrl}chat`))?.status()).toBe(200);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
await gateway.emitGatewayEvent("update.available", {
|
||||
updateAvailable: {
|
||||
channel: "stable",
|
||||
|
||||
@@ -334,7 +334,8 @@ describeControlUiE2e("Control UI usage cost analysis mocked Gateway E2E", () =>
|
||||
.poll(() => page.locator(".usage-insight-card", { hasText: "Top Providers" }).textContent())
|
||||
.toContain("openai");
|
||||
const messagesHint = page.locator("#usage-summary-hint-messages");
|
||||
const messagesTooltip = page.locator("#usage-summary-hint-messages-tooltip");
|
||||
const messagesTooltipHost = messagesHint.locator("xpath=..");
|
||||
const messagesTooltip = messagesTooltipHost.locator("wa-tooltip");
|
||||
await messagesHint.hover();
|
||||
await expect.poll(() => messagesTooltip.getAttribute("open")).toBe("");
|
||||
await page.mouse.move(1, 1);
|
||||
@@ -348,7 +349,7 @@ describeControlUiE2e("Control UI usage cost analysis mocked Gateway E2E", () =>
|
||||
await messagesHint.click();
|
||||
await expect.poll(() => messagesTooltip.getAttribute("open")).toBe("");
|
||||
await expect
|
||||
.poll(() => messagesTooltip.textContent())
|
||||
.poll(() => messagesTooltipHost.locator('[slot="content"]').textContent())
|
||||
.toContain("Total user and assistant messages in range.");
|
||||
await page.getByRole("button", { name: "Cost", exact: true }).click();
|
||||
await expect.poll(() => messagesTooltip.getAttribute("open")).toBeNull();
|
||||
|
||||
@@ -74,6 +74,89 @@ async function waitForToastUpdate(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function emitObserverAndReadToast(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof observerDigest>,
|
||||
action?: "open" | "dismiss",
|
||||
): Promise<{ actionable: boolean | null; message: string; visible: boolean }> {
|
||||
// Toasts auto-dismiss after 6000ms. Keep emit, read, and any action in one browser
|
||||
// step, including the shell's lazy runtime load and the toast host's Lit update.
|
||||
return await page.locator("openclaw-toast-host").evaluate(
|
||||
async (element, params) => {
|
||||
const host = element as HTMLElement & { updateComplete: Promise<unknown> };
|
||||
const app = document.querySelector("openclaw-app-shell") as
|
||||
| (HTMLElement & {
|
||||
criticalNoticeRuntime?: Promise<unknown> | null;
|
||||
})
|
||||
| null;
|
||||
const gateway = (
|
||||
window as Window & {
|
||||
openclawControlUiE2eGateway?: {
|
||||
emit: (event: string, payload?: unknown) => void;
|
||||
};
|
||||
}
|
||||
).openclawControlUiE2eGateway;
|
||||
if (!app || !gateway) {
|
||||
throw new Error("Critical observer notice owner is unavailable");
|
||||
}
|
||||
|
||||
gateway.emit("session.observer", params.payload);
|
||||
const runtime = app.criticalNoticeRuntime;
|
||||
if (!runtime) {
|
||||
throw new Error("Critical observer notice runtime did not start");
|
||||
}
|
||||
await runtime;
|
||||
await host.updateComplete;
|
||||
|
||||
const toast = host.querySelector<HTMLElement>(".app-toast");
|
||||
const isVisible = (target: HTMLElement | null): target is HTMLElement => {
|
||||
if (!target?.isConnected) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(target);
|
||||
const bounds = target.getBoundingClientRect();
|
||||
return (
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
bounds.width > 0 &&
|
||||
bounds.height > 0
|
||||
);
|
||||
};
|
||||
const visible = isVisible(toast);
|
||||
let actionable: boolean | null = null;
|
||||
const result = () => ({
|
||||
actionable,
|
||||
message: toast?.querySelector(".app-toast__message")?.textContent ?? "",
|
||||
visible,
|
||||
});
|
||||
if (params.action) {
|
||||
const selector = params.action === "open" ? ".app-toast__action" : ".app-toast__dismiss";
|
||||
const button = toast?.querySelector<HTMLButtonElement>(selector);
|
||||
if (!button) {
|
||||
throw new Error(`Toast ${params.action} action is unavailable`);
|
||||
}
|
||||
const bounds = button.getBoundingClientRect();
|
||||
const hitTarget = document.elementFromPoint(
|
||||
bounds.left + bounds.width / 2,
|
||||
bounds.top + bounds.height / 2,
|
||||
);
|
||||
actionable =
|
||||
visible &&
|
||||
isVisible(button) &&
|
||||
!button.disabled &&
|
||||
Boolean(hitTarget && (hitTarget === button || button.contains(hitTarget)));
|
||||
if (!actionable) {
|
||||
return result();
|
||||
}
|
||||
button.click();
|
||||
await host.updateComplete;
|
||||
}
|
||||
return result();
|
||||
},
|
||||
{ action, payload },
|
||||
);
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI critical observer notice mocked Gateway E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
@@ -151,23 +234,24 @@ describeControlUiE2e("Control UI critical observer notice mocked Gateway E2E", (
|
||||
expect(await toast.count()).toBe(0);
|
||||
|
||||
const firstHeadline = "Background verification is stuck";
|
||||
await gateway.emitGatewayEvent(
|
||||
"session.observer",
|
||||
const firstToast = await emitObserverAndReadToast(
|
||||
page,
|
||||
observerDigest({
|
||||
sessionKey: backgroundSessionKey,
|
||||
health: "stuck",
|
||||
headline: firstHeadline,
|
||||
revision: 2,
|
||||
}),
|
||||
"open",
|
||||
);
|
||||
await toast.waitFor({ state: "visible" });
|
||||
expect(await toast.locator(".app-toast__message").textContent()).toContain(firstHeadline);
|
||||
expect(firstToast.visible).toBe(true);
|
||||
expect(firstToast.actionable).toBe(true);
|
||||
expect(firstToast.message).toContain(firstHeadline);
|
||||
await page.screenshot({
|
||||
fullPage: true,
|
||||
path: path.join(artifactDir, "01-critical-background-session.png"),
|
||||
});
|
||||
|
||||
await toast.locator(".app-toast__action").click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname)
|
||||
.toBe(controlUiSessionPath(backgroundSessionKey));
|
||||
@@ -194,17 +278,18 @@ describeControlUiE2e("Control UI critical observer notice mocked Gateway E2E", (
|
||||
await waitForToastUpdate(page);
|
||||
expect(await toast.count()).toBe(0);
|
||||
|
||||
await gateway.emitGatewayEvent(
|
||||
"session.observer",
|
||||
const dismissToast = await emitObserverAndReadToast(
|
||||
page,
|
||||
observerDigest({
|
||||
sessionKey: backgroundSessionKey,
|
||||
health: "stuck",
|
||||
headline: "Background verification is stuck again",
|
||||
revision: 4,
|
||||
}),
|
||||
"dismiss",
|
||||
);
|
||||
await toast.waitFor({ state: "visible" });
|
||||
await toast.locator(".app-toast__dismiss").click();
|
||||
expect(dismissToast.visible).toBe(true);
|
||||
expect(dismissToast.actionable).toBe(true);
|
||||
expect(await toast.count()).toBe(0);
|
||||
|
||||
await gateway.emitGatewayEvent(
|
||||
@@ -330,22 +415,21 @@ describeControlUiE2e("Control UI critical observer notice mocked Gateway E2E", (
|
||||
expect(await gateway.getRequests("connect")).toHaveLength(1);
|
||||
|
||||
const headline = `Configured-global observer notice for ${testCase.sessionKey}`;
|
||||
await gateway.emitGatewayEvent(
|
||||
"session.observer",
|
||||
observerDigest({
|
||||
agentId: testCase.agentId,
|
||||
sessionKey: testCase.sessionKey,
|
||||
health: "stuck",
|
||||
headline,
|
||||
revision: 1,
|
||||
}),
|
||||
);
|
||||
const digest = observerDigest({
|
||||
agentId: testCase.agentId,
|
||||
sessionKey: testCase.sessionKey,
|
||||
health: "stuck",
|
||||
headline,
|
||||
revision: 1,
|
||||
});
|
||||
|
||||
const toast = page.locator(".app-toast");
|
||||
if (testCase.visible) {
|
||||
await toast.waitFor({ state: "visible" });
|
||||
expect(await toast.locator(".app-toast__message").textContent()).toContain(headline);
|
||||
const toastState = await emitObserverAndReadToast(page, digest);
|
||||
expect(toastState.visible).toBe(true);
|
||||
expect(toastState.message).toContain(headline);
|
||||
} else {
|
||||
await gateway.emitGatewayEvent("session.observer", digest);
|
||||
await waitForToastUpdate(page);
|
||||
expect(await toast.count()).toBe(0);
|
||||
}
|
||||
|
||||
@@ -592,8 +592,9 @@ function renderPeakErrorList(
|
||||
}
|
||||
|
||||
function focusSummaryHint(event: MouseEvent) {
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
event.currentTarget.focus();
|
||||
const target = event.currentTarget;
|
||||
if (target instanceof HTMLElement) {
|
||||
target.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +629,7 @@ function renderSummaryStat(params: {
|
||||
<div class=${classes}>
|
||||
<div class="usage-summary-title">
|
||||
${params.title}
|
||||
<openclaw-tooltip>
|
||||
<openclaw-tooltip open-on-click>
|
||||
<button
|
||||
id=${hintId}
|
||||
type="button"
|
||||
@@ -638,8 +639,10 @@ function renderSummaryStat(params: {
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<!-- Some browsers do not focus buttons on pointer activation; the
|
||||
click handler normalizes that path without adding a second toggle. -->
|
||||
<!-- Shared tooltips dismiss pointer activation so action buttons never
|
||||
strand one open. This hint exists only to be read, so it opts in to
|
||||
click-to-open; the click handler still normalizes browsers that do
|
||||
not focus buttons on pointer activation. -->
|
||||
<span slot="content">${params.hint}</span>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,6 @@ import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
|
||||
import "../styles.css";
|
||||
import "../components/board/board-view.ts";
|
||||
|
||||
document.documentElement.classList.add("wa-dark");
|
||||
|
||||
const initialSnapshot: BoardSnapshot = {
|
||||
sessionKey: "agent:main:board-fixture",
|
||||
revision: 7,
|
||||
|
||||
Reference in New Issue
Block a user