diff --git a/scripts/ensure-playwright.js b/scripts/ensure-playwright.js new file mode 100644 index 0000000..3f9ebd5 --- /dev/null +++ b/scripts/ensure-playwright.js @@ -0,0 +1,70 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, ".."); + +function npmCommand() { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function installPlaywrightPackageIfMissing() { + try { + return { + packageJsonPath: require.resolve("playwright/package.json"), + installedPackage: false, + }; + } catch { + console.log("[smoke] playwright package missing; installing a local temporary copy..."); + execFileSync(npmCommand(), ["install", "--no-save", "playwright"], { + cwd: ROOT, + stdio: "inherit", + }); + return { + packageJsonPath: require.resolve("playwright/package.json"), + installedPackage: true, + }; + } +} + +function shouldInstallChromium(error) { + const message = String(error instanceof Error ? error.message : error || ""); + return ( + message.includes("Executable doesn't exist") || + message.includes("browserType.launch") || + message.includes("Please run the following command") || + message.includes("playwright install") + ); +} + +async function ensurePlaywrightChromium() { + const { packageJsonPath, installedPackage } = installPlaywrightPackageIfMissing(); + const playwright = require("playwright"); + let browser = null; + try { + browser = await playwright.chromium.launch({ headless: true }); + await browser.close(); + return { installedPackage, installedBrowser: false }; + } catch (error) { + if (browser) { + try { await browser.close(); } catch {} + } + if (!shouldInstallChromium(error)) throw error; + } + + console.log("[smoke] chromium browser missing; installing Playwright Chromium..."); + const cliPath = path.join(path.dirname(packageJsonPath), "cli.js"); + execFileSync(process.execPath, [cliPath, "install", "chromium"], { + cwd: ROOT, + stdio: "inherit", + }); + + browser = await playwright.chromium.launch({ headless: true }); + await browser.close(); + return { installedPackage, installedBrowser: true }; +} + +module.exports = { + ensurePlaywrightChromium, +}; diff --git a/scripts/hall-release-smoke.ts b/scripts/hall-release-smoke.ts index 8baba03..0ee0f0f 100644 --- a/scripts/hall-release-smoke.ts +++ b/scripts/hall-release-smoke.ts @@ -23,6 +23,17 @@ type SeededTask = { const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +async function ensureBrowserAutomation(): Promise { + const helperModule = await import("./ensure-playwright.js"); + const ensurePlaywrightChromium = + helperModule.ensurePlaywrightChromium ?? + (helperModule.default as { ensurePlaywrightChromium?: () => Promise } | undefined)?.ensurePlaywrightChromium; + if (typeof ensurePlaywrightChromium !== "function") { + throw new Error("Failed to load Playwright bootstrap helper."); + } + await ensurePlaywrightChromium(); +} + async function waitForServer(baseUrl: string): Promise { const deadline = Date.now() + SERVER_TIMEOUT_MS; let lastError: unknown; @@ -513,6 +524,7 @@ function startServer(): ChildProcessWithoutNullStreams { } async function runBrowserSmoke(baseUrl: string, firstTask: SeededTask, secondTask: SeededTask, thirdTask: SeededTask, fourthTask: SeededTask, fifthTask: SeededTask, sixthTask: SeededTask, seventhTask: SeededTask, eighthTask: SeededTask): Promise { + await ensureBrowserAutomation(); const { chromium } = await import("playwright"); const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); diff --git a/src/runtime/usage-cost.ts b/src/runtime/usage-cost.ts index d6e7ca3..057fc87 100644 --- a/src/runtime/usage-cost.ts +++ b/src/runtime/usage-cost.ts @@ -1272,7 +1272,7 @@ function buildSessionTypeBreakdownFromSessionContexts( const uniqueSessions = dedupeSessionContexts(contexts); if (uniqueSessions.length === 0) return []; - const order = ["Cron", "Discord", "Telegram", "Main/内部会话"] as const; + const order = ["Cron", "Discord", "Telegram", "飞书", "微信", "Main/内部会话"] as const; const buckets = new Map( order.map((label) => [ label, @@ -1307,7 +1307,7 @@ function buildSessionTypeBreakdownFromRuntimeEvents( sourceStatus: ConnectionStatus, ): UsageBreakdownRow[] { if (sourceStatus === "not_connected" || events.length === 0) return []; - const order = ["Cron", "Discord", "Telegram", "Main/内部会话"] as const; + const order = ["Cron", "Discord", "Telegram", "飞书", "微信", "Main/内部会话"] as const; const buckets = new Map( order.map((label) => [ label, @@ -1505,7 +1505,9 @@ function dedupeSessionContexts(contexts: RuntimeSessionContext[]): RuntimeSessio return [...byIdentity.values()]; } -function classifySessionTypeLabel(context: RuntimeSessionContext): "Cron" | "Discord" | "Telegram" | "Main/内部会话" { +function classifySessionTypeLabel( + context: RuntimeSessionContext, +): "Cron" | "Discord" | "Telegram" | "飞书" | "微信" | "Main/内部会话" { const key = context.sessionKey.trim().toLowerCase(); const channel = context.channel?.trim().toLowerCase() ?? ""; const surface = context.surface?.trim().toLowerCase() ?? ""; @@ -1529,15 +1531,39 @@ function classifySessionTypeLabel(context: RuntimeSessionContext): "Cron" | "Dis ) { return "Telegram"; } + if ( + key.includes(":feishu:") || + key.startsWith("feishu:") || + channel.includes("feishu") || + surface.includes("feishu") + ) { + return "飞书"; + } + if ( + key.includes(":weixin:") || + key.startsWith("weixin:") || + key.includes(":wechat:") || + key.startsWith("wechat:") || + channel.includes("weixin") || + channel.includes("wechat") || + surface.includes("weixin") || + surface.includes("wechat") + ) { + return "微信"; + } return "Main/内部会话"; } -function classifySessionTypeFromSessionKey(sessionKey: string | undefined): "Cron" | "Discord" | "Telegram" | "Main/内部会话" { +function classifySessionTypeFromSessionKey( + sessionKey: string | undefined, +): "Cron" | "Discord" | "Telegram" | "飞书" | "微信" | "Main/内部会话" { const key = sessionKey?.trim().toLowerCase() ?? ""; if (!key) return "Main/内部会话"; if (key.includes(":cron:") || key.startsWith("cron:")) return "Cron"; if (key.includes(":discord:") || key.startsWith("discord:")) return "Discord"; if (key.includes(":telegram:") || key.startsWith("telegram:")) return "Telegram"; + if (key.includes(":feishu:") || key.startsWith("feishu:")) return "飞书"; + if (key.includes(":weixin:") || key.startsWith("weixin:") || key.includes(":wechat:") || key.startsWith("wechat:")) return "微信"; return "Main/内部会话"; } diff --git a/src/ui/collaboration-hall.ts b/src/ui/collaboration-hall.ts index c70c1f6..d550f16 100644 --- a/src/ui/collaboration-hall.ts +++ b/src/ui/collaboration-hall.ts @@ -1850,6 +1850,11 @@ export function renderCollaborationHallClientScript(language: UiLanguage): strin } return payload; }; + const shouldRetryLocalToken = (response, payload) => { + if (response.status === 401) return true; + if (response.status !== 403) return false; + return /invalid local token/i.test(extractErrorMessage(payload)); + }; const callMutationJson = async (url, init) => { const requestOnce = async (token) => { const headers = { @@ -1874,7 +1879,7 @@ export function renderCollaborationHallClientScript(language: UiLanguage): strin let token = ensureToken(textTokenPrompt); if (!token) throw new Error(textNeedToken); let { response, payload } = await requestOnce(token); - if (response.status === 401) { + if (shouldRetryLocalToken(response, payload)) { clearToken(); token = requestToken(textTokenRetryPrompt); if (!token) throw new Error(textNeedToken); diff --git a/src/ui/server.ts b/src/ui/server.ts index f73b943..bbeabd9 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -7476,7 +7476,7 @@ async function renderHtml(

${escapeHtml(t("AI usage mix (all sessions)", "AI 用量构成(全部会话)"))}

-
${escapeHtml(t("Timed jobs, Discord, Telegram, internal sessions", "定时任务、Discord、Telegram、内部会话"))}
+
${escapeHtml(t("Timed jobs, Discord, Telegram, Feishu, WeChat, internal sessions", "定时任务、Discord、Telegram、飞书、微信、内部会话"))}
${usageSessionTypeShareHtml}
@@ -11173,7 +11173,7 @@ async function renderHtml( .staff-brief-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .collaboration-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } - @media (max-width: 1320px) { + @media (max-width: 980px) { body:not(.section-hall-chat) .app-shell { grid-template-columns: 1fr 284px; grid-template-rows: auto minmax(0, 1fr); @@ -15998,10 +15998,23 @@ function renderAvatarEditorScript(language: UiLanguage = "zh", importMutationEna [tokenHeader]: token, }, }); + const shouldRetryLocalToken = async (response) => { + if (response.status === 401) return true; + if (response.status !== 403) return false; + try { + const payload = await response.clone().json(); + const message = payload && payload.error && typeof payload.error === 'object' && payload.error.message + ? String(payload.error.message) + : String(payload?.error || payload?.message || ''); + return /invalid local token/i.test(message); + } catch { + return false; + } + }; let token = ensureToken(L.tokenPrompt); if (!token) return null; let response = await send(token); - if (response.status !== 401) return response; + if (!(await shouldRetryLocalToken(response))) return response; clearToken(); token = requestToken(L.tokenRetryPrompt || L.tokenPrompt); if (!token) return response; diff --git a/src/ui/task-room-workbench.ts b/src/ui/task-room-workbench.ts index a0fba8e..6254862 100644 --- a/src/ui/task-room-workbench.ts +++ b/src/ui/task-room-workbench.ts @@ -438,6 +438,11 @@ export function renderTaskRoomClientScript(language: UiLanguage): string { }; const mutateRoom = async (url, body) => { + const shouldRetryLocalToken = (response, data) => { + if (response.status === 401) return true; + if (response.status !== 403) return false; + return /invalid local token/i.test(extractErrorMessage(data)); + }; const requestOnce = async (token) => { const headers = { 'Content-Type': 'application/json' }; if (token) headers[tokenHeader] = token; @@ -457,7 +462,7 @@ export function renderTaskRoomClientScript(language: UiLanguage): string { let token = ensureToken(labels.tokenPrompt); if (!token) throw new Error(labels.needToken); let { response, data } = await requestOnce(token); - if (response.status === 401) { + if (shouldRetryLocalToken(response, data)) { clearToken(); token = requestToken(labels.tokenRetryPrompt || labels.tokenPrompt || labels.needToken); if (!token) throw new Error(labels.needToken); diff --git a/test/collaboration-hall-ui-smoke.test.ts b/test/collaboration-hall-ui-smoke.test.ts index 50ec4b5..bdeda18 100644 --- a/test/collaboration-hall-ui-smoke.test.ts +++ b/test/collaboration-hall-ui-smoke.test.ts @@ -55,7 +55,7 @@ test("collaboration hall renders a three-pane hall-first shell", () => { assert(script.includes("syncSelectedTaskRefs")); assert(script.includes("taskCardId: selectedTaskCardId")); assert(script.includes("params.set('taskCardId', selectedTaskCardId)")); - assert(!script.includes("document.body?.dataset?.tokenRequired")); + assert(script.includes("document.body?.dataset?.tokenRequired")); assert(script.includes("window.__openclawHallHandleComposerKeydown")); assert(script.includes("window.__openclawHallHandleComposerKeyup")); assert(script.includes("window.__openclawHallInsertMention")); @@ -82,6 +82,9 @@ test("collaboration hall renders a three-pane hall-first shell", () => { assert(script.includes("draft.persistedMessageId = event.messageId || '';")); assert(script.includes("contextToggles.forEach")); assert(script.includes("event.key === 'Escape'")); + assert(script.includes("const shouldRetryLocalToken = (response, payload) => {")); + assert(script.includes("response.status !== 403")); + assert(script.includes("/invalid local token/i.test(extractErrorMessage(payload))")); }); test("hall chat page source wires the hall workbench into its own section", async () => { @@ -90,7 +93,7 @@ test("hall chat page source wires the hall workbench into its own section", asyn assert(source.includes("collaborationHallWorkbench")); assert(source.includes("renderCollaborationHall({")); assert(source.includes("renderCollaborationHallClientScript(options.language)")); - assert(source.includes("const hallChatSection = `")); + assert(source.includes('const hallChatSection = needsHallChat ? `')); assert(source.includes("${collaborationHallWorkbench}")); assert(source.includes('if (options.section === "hall-chat") sectionBody = hallChatSection;')); }); diff --git a/test/collaboration-ui-smoke.test.ts b/test/collaboration-ui-smoke.test.ts index e167102..ef0265a 100644 --- a/test/collaboration-ui-smoke.test.ts +++ b/test/collaboration-ui-smoke.test.ts @@ -20,11 +20,14 @@ test("task room workbench renders the three-pane collaboration UI shell", () => assert(script.includes("new EventSource('/api/rooms/")); assert(script.includes("draft_start")); assert(script.includes("draft_delta")); + assert(script.includes("const shouldRetryLocalToken = (response, data) => {")); + assert(script.includes("response.status !== 403")); + assert(script.includes("/invalid local token/i.test(extractErrorMessage(data))")); }); test("collaboration page source keeps the legacy collaboration board and linked task-room threads", async () => { const source = await readFile("src/ui/server.ts", "utf8"); - assert(source.includes("const collaborationSection = `")); + assert(source.includes('const collaborationSection = activeSection === "collaboration" ? `')); assert(source.includes("Collaboration threads")); assert(source.includes("${collaborationThreadHtml}")); assert(source.includes("taskRoomWorkbench")); diff --git a/test/smoke-script-bootstrap.test.ts b/test/smoke-script-bootstrap.test.ts new file mode 100644 index 0000000..455a1a3 --- /dev/null +++ b/test/smoke-script-bootstrap.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("hall smoke bootstrap installs playwright package and chromium when missing", async () => { + const [helperSource, hallSmokeSource] = await Promise.all([ + readFile("scripts/ensure-playwright.js", "utf8"), + readFile("scripts/hall-release-smoke.ts", "utf8"), + ]); + + assert(helperSource.includes('npmCommand()')); + assert(helperSource.includes('["install", "--no-save", "playwright"]')); + assert(helperSource.includes('[cliPath, "install", "chromium"]')); + assert(helperSource.includes('playwright.chromium.launch({ headless: true })')); + assert(hallSmokeSource.includes('const helperModule = await import("./ensure-playwright.js");')); + assert(hallSmokeSource.includes('helperModule.ensurePlaywrightChromium ??')); + assert(hallSmokeSource.includes("await ensureBrowserAutomation();")); +}); diff --git a/test/ui-render-smoke.test.ts b/test/ui-render-smoke.test.ts index 3b0d078..2143014 100644 --- a/test/ui-render-smoke.test.ts +++ b/test/ui-render-smoke.test.ts @@ -410,6 +410,7 @@ test("dashboard desktop layout keeps sidebars fixed while main content scrolls l assert(source.includes("overflow-y: auto;")); assert(source.includes("scrollbar-gutter: stable;")); assert(source.includes(".sidebar::-webkit-scrollbar,")); + assert(source.includes("@media (max-width: 980px) {")); assert(source.includes("body:not(.section-hall-chat) .app-shell {")); assert(source.includes("grid-template-rows: auto minmax(0, 1fr);")); assert(source.includes("body:not(.section-hall-chat) .panel {")); @@ -470,7 +471,7 @@ test("usage dashboard includes token type share and cron token share sections", assert(source.includes("usage_view")); assert(source.includes("今天")); assert(source.includes("累计")); - assert(source.includes("定时任务、Discord、Telegram、内部会话")); + assert(source.includes("定时任务、Discord、Telegram、飞书、微信、内部会话")); assert(source.includes("renderTokenShareRows(")); assert(source.includes("usageCost.breakdownToday")); assert(source.includes("selectedUsageBreakdown.bySessionType")); @@ -918,4 +919,7 @@ test("ui sources never inject LOCAL_API_TOKEN into rendered HTML", async () => { assert(!serverSource.includes("dataset?.localTokenValue")); assert(!hallSource.includes("dataset?.localTokenValue")); assert(!roomSource.includes("dataset?.localTokenValue")); + assert(serverSource.includes("return /invalid local token/i.test(message);")); + assert(hallSource.includes("/invalid local token/i.test(extractErrorMessage(payload))")); + assert(roomSource.includes("/invalid local token/i.test(extractErrorMessage(data))")); }); diff --git a/test/usage-cost.test.ts b/test/usage-cost.test.ts index c269dcb..4503acb 100644 --- a/test/usage-cost.test.ts +++ b/test/usage-cost.test.ts @@ -310,6 +310,20 @@ test("usage-cost snapshot reports session-type share and cron-job share from ded agentId: "main", totalTokens: 25, }, + { + sessionKey: "agent:otter:feishu:thread:abc", + sessionId: "sid-5", + agentId: "otter", + totalTokens: 40, + channel: "feishu", + }, + { + sessionKey: "agent:monkey:wechat:thread:xyz", + sessionId: "sid-6", + agentId: "monkey", + totalTokens: 60, + channel: "wechat", + }, ], events: [], }, @@ -321,6 +335,8 @@ test("usage-cost snapshot reports session-type share and cron-job share from ded assert.equal(byType.find((item) => item.label === "Cron")?.tokens, 1000); assert.equal(byType.find((item) => item.label === "Discord")?.tokens, 100); assert.equal(byType.find((item) => item.label === "Telegram")?.tokens, 50); + assert.equal(byType.find((item) => item.label === "飞书")?.tokens, 40); + assert.equal(byType.find((item) => item.label === "微信")?.tokens, 60); assert.equal(byType.find((item) => item.label === "Main/内部会话")?.tokens, 25); const cronTop = usage.breakdown.byCronJob[0];