test: micro-optimize heavy gateway/browser/telegram suites
This commit is contained in:
@@ -11,13 +11,28 @@ import {
|
|||||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||||
|
|
||||||
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
|
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
|
||||||
|
let missingCommandRuntime: AcpxRuntime | null = null;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
sharedFixture = await createMockRuntimeFixture();
|
sharedFixture = await createMockRuntimeFixture();
|
||||||
|
missingCommandRuntime = new AcpxRuntime(
|
||||||
|
{
|
||||||
|
command: "/definitely/missing/acpx",
|
||||||
|
allowPluginLocalInstall: false,
|
||||||
|
installCommand: "n/a",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
permissionMode: "approve-reads",
|
||||||
|
nonInteractivePermissions: "fail",
|
||||||
|
strictWindowsCmdWrapper: true,
|
||||||
|
queueOwnerTtlSeconds: 0.1,
|
||||||
|
},
|
||||||
|
{ logger: NOOP_LOGGER },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
sharedFixture = null;
|
sharedFixture = null;
|
||||||
|
missingCommandRuntime = null;
|
||||||
await cleanupMockRuntimeFixtures();
|
await cleanupMockRuntimeFixtures();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,22 +334,12 @@ describe("AcpxRuntime", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("marks runtime unhealthy when command is missing", async () => {
|
it("marks runtime unhealthy when command is missing", async () => {
|
||||||
const runtime = new AcpxRuntime(
|
expect(missingCommandRuntime).toBeDefined();
|
||||||
{
|
if (!missingCommandRuntime) {
|
||||||
command: "/definitely/missing/acpx",
|
throw new Error("missing-command runtime fixture missing");
|
||||||
allowPluginLocalInstall: false,
|
}
|
||||||
installCommand: "n/a",
|
await missingCommandRuntime.probeAvailability();
|
||||||
cwd: process.cwd(),
|
expect(missingCommandRuntime.isHealthy()).toBe(false);
|
||||||
permissionMode: "approve-reads",
|
|
||||||
nonInteractivePermissions: "fail",
|
|
||||||
strictWindowsCmdWrapper: true,
|
|
||||||
queueOwnerTtlSeconds: 0.1,
|
|
||||||
},
|
|
||||||
{ logger: NOOP_LOGGER },
|
|
||||||
);
|
|
||||||
|
|
||||||
await runtime.probeAvailability();
|
|
||||||
expect(runtime.isHealthy()).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs ACPX spawn resolution once per command policy", async () => {
|
it("logs ACPX spawn resolution once per command policy", async () => {
|
||||||
@@ -363,21 +368,11 @@ describe("AcpxRuntime", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns doctor report for missing command", async () => {
|
it("returns doctor report for missing command", async () => {
|
||||||
const runtime = new AcpxRuntime(
|
expect(missingCommandRuntime).toBeDefined();
|
||||||
{
|
if (!missingCommandRuntime) {
|
||||||
command: "/definitely/missing/acpx",
|
throw new Error("missing-command runtime fixture missing");
|
||||||
allowPluginLocalInstall: false,
|
}
|
||||||
installCommand: "n/a",
|
const report = await missingCommandRuntime.doctor();
|
||||||
cwd: process.cwd(),
|
|
||||||
permissionMode: "approve-reads",
|
|
||||||
nonInteractivePermissions: "fail",
|
|
||||||
strictWindowsCmdWrapper: true,
|
|
||||||
queueOwnerTtlSeconds: 0.1,
|
|
||||||
},
|
|
||||||
{ logger: NOOP_LOGGER },
|
|
||||||
);
|
|
||||||
|
|
||||||
const report = await runtime.doctor();
|
|
||||||
expect(report.ok).toBe(false);
|
expect(report.ok).toBe(false);
|
||||||
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
|
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
|
||||||
expect(report.installCommand).toContain("acpx");
|
expect(report.installCommand).toContain("acpx");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|||||||
|
|
||||||
let fixtureRoot = "";
|
let fixtureRoot = "";
|
||||||
let fixtureCount = 0;
|
let fixtureCount = 0;
|
||||||
|
let syncSourceTemplateDir = "";
|
||||||
|
|
||||||
async function createCaseDir(prefix: string): Promise<string> {
|
async function createCaseDir(prefix: string): Promise<string> {
|
||||||
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
||||||
@@ -26,6 +27,27 @@ async function createCaseDir(prefix: string): Promise<string> {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-sync-suite-"));
|
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-sync-suite-"));
|
||||||
|
syncSourceTemplateDir = await createCaseDir("source-template");
|
||||||
|
await writeSkill({
|
||||||
|
dir: path.join(syncSourceTemplateDir, ".extra", "demo-skill"),
|
||||||
|
name: "demo-skill",
|
||||||
|
description: "Extra version",
|
||||||
|
});
|
||||||
|
await writeSkill({
|
||||||
|
dir: path.join(syncSourceTemplateDir, ".bundled", "demo-skill"),
|
||||||
|
name: "demo-skill",
|
||||||
|
description: "Bundled version",
|
||||||
|
});
|
||||||
|
await writeSkill({
|
||||||
|
dir: path.join(syncSourceTemplateDir, ".managed", "demo-skill"),
|
||||||
|
name: "demo-skill",
|
||||||
|
description: "Managed version",
|
||||||
|
});
|
||||||
|
await writeSkill({
|
||||||
|
dir: path.join(syncSourceTemplateDir, "skills", "demo-skill"),
|
||||||
|
name: "demo-skill",
|
||||||
|
description: "Workspace version",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -39,34 +61,19 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
|||||||
) =>
|
) =>
|
||||||
withEnv({ HOME: workspaceDir, PATH: "" }, () => buildWorkspaceSkillsPrompt(workspaceDir, opts));
|
withEnv({ HOME: workspaceDir, PATH: "" }, () => buildWorkspaceSkillsPrompt(workspaceDir, opts));
|
||||||
|
|
||||||
it("syncs merged skills into a target workspace", async () => {
|
const cloneSourceTemplate = async () => {
|
||||||
const sourceWorkspace = await createCaseDir("source");
|
const sourceWorkspace = await createCaseDir("source");
|
||||||
|
await fs.cp(syncSourceTemplateDir, sourceWorkspace, { recursive: true });
|
||||||
|
return sourceWorkspace;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("syncs merged skills into a target workspace", async () => {
|
||||||
|
const sourceWorkspace = await cloneSourceTemplate();
|
||||||
const targetWorkspace = await createCaseDir("target");
|
const targetWorkspace = await createCaseDir("target");
|
||||||
const extraDir = path.join(sourceWorkspace, ".extra");
|
const extraDir = path.join(sourceWorkspace, ".extra");
|
||||||
const bundledDir = path.join(sourceWorkspace, ".bundled");
|
const bundledDir = path.join(sourceWorkspace, ".bundled");
|
||||||
const managedDir = path.join(sourceWorkspace, ".managed");
|
const managedDir = path.join(sourceWorkspace, ".managed");
|
||||||
|
|
||||||
await writeSkill({
|
|
||||||
dir: path.join(extraDir, "demo-skill"),
|
|
||||||
name: "demo-skill",
|
|
||||||
description: "Extra version",
|
|
||||||
});
|
|
||||||
await writeSkill({
|
|
||||||
dir: path.join(bundledDir, "demo-skill"),
|
|
||||||
name: "demo-skill",
|
|
||||||
description: "Bundled version",
|
|
||||||
});
|
|
||||||
await writeSkill({
|
|
||||||
dir: path.join(managedDir, "demo-skill"),
|
|
||||||
name: "demo-skill",
|
|
||||||
description: "Managed version",
|
|
||||||
});
|
|
||||||
await writeSkill({
|
|
||||||
dir: path.join(sourceWorkspace, "skills", "demo-skill"),
|
|
||||||
name: "demo-skill",
|
|
||||||
description: "Workspace version",
|
|
||||||
});
|
|
||||||
|
|
||||||
await withEnv({ HOME: sourceWorkspace, PATH: "" }, () =>
|
await withEnv({ HOME: sourceWorkspace, PATH: "" }, () =>
|
||||||
syncSkillsToWorkspace({
|
syncSkillsToWorkspace({
|
||||||
sourceWorkspaceDir: sourceWorkspace,
|
sourceWorkspaceDir: sourceWorkspace,
|
||||||
|
|||||||
@@ -7,9 +7,32 @@ import { writeSkill } from "./skills.e2e-test-helpers.js";
|
|||||||
import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt } from "./skills.js";
|
import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt } from "./skills.js";
|
||||||
|
|
||||||
const fixtureSuite = createFixtureSuite("openclaw-skills-snapshot-suite-");
|
const fixtureSuite = createFixtureSuite("openclaw-skills-snapshot-suite-");
|
||||||
|
let truncationWorkspaceTemplateDir = "";
|
||||||
|
let nestedRepoTemplateDir = "";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fixtureSuite.setup();
|
await fixtureSuite.setup();
|
||||||
|
truncationWorkspaceTemplateDir = await fixtureSuite.createCaseDir(
|
||||||
|
"template-truncation-workspace",
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
const name = `skill-${String(i).padStart(2, "0")}`;
|
||||||
|
await writeSkill({
|
||||||
|
dir: path.join(truncationWorkspaceTemplateDir, "skills", name),
|
||||||
|
name,
|
||||||
|
description: "x".repeat(800),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nestedRepoTemplateDir = await fixtureSuite.createCaseDir("template-skills-repo");
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
const name = `repo-skill-${String(i).padStart(2, "0")}`;
|
||||||
|
await writeSkill({
|
||||||
|
dir: path.join(nestedRepoTemplateDir, "skills", name),
|
||||||
|
name,
|
||||||
|
description: `Desc ${i}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -20,6 +43,12 @@ function withWorkspaceHome<T>(workspaceDir: string, cb: () => T): T {
|
|||||||
return withEnv({ HOME: workspaceDir, PATH: "" }, cb);
|
return withEnv({ HOME: workspaceDir, PATH: "" }, cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cloneTemplateDir(templateDir: string, prefix: string): Promise<string> {
|
||||||
|
const cloned = await fixtureSuite.createCaseDir(prefix);
|
||||||
|
await fs.cp(templateDir, cloned, { recursive: true });
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
describe("buildWorkspaceSkillSnapshot", () => {
|
describe("buildWorkspaceSkillSnapshot", () => {
|
||||||
it("returns an empty snapshot when skills dirs are missing", async () => {
|
it("returns an empty snapshot when skills dirs are missing", async () => {
|
||||||
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
@@ -110,17 +139,7 @@ describe("buildWorkspaceSkillSnapshot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("truncates the skills prompt when it exceeds the configured char budget", async () => {
|
it("truncates the skills prompt when it exceeds the configured char budget", async () => {
|
||||||
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
const workspaceDir = await cloneTemplateDir(truncationWorkspaceTemplateDir, "workspace");
|
||||||
|
|
||||||
// Keep fixture size modest while still forcing truncation logic.
|
|
||||||
for (let i = 0; i < 8; i += 1) {
|
|
||||||
const name = `skill-${String(i).padStart(2, "0")}`;
|
|
||||||
await writeSkill({
|
|
||||||
dir: path.join(workspaceDir, "skills", name),
|
|
||||||
name,
|
|
||||||
description: "x".repeat(800),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = withWorkspaceHome(workspaceDir, () =>
|
const snapshot = withWorkspaceHome(workspaceDir, () =>
|
||||||
buildWorkspaceSkillSnapshot(workspaceDir, {
|
buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
@@ -143,16 +162,7 @@ describe("buildWorkspaceSkillSnapshot", () => {
|
|||||||
|
|
||||||
it("limits discovery for nested repo-style skills roots (dir/skills/*)", async () => {
|
it("limits discovery for nested repo-style skills roots (dir/skills/*)", async () => {
|
||||||
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
const repoDir = await fixtureSuite.createCaseDir("skills-repo");
|
const repoDir = await cloneTemplateDir(nestedRepoTemplateDir, "skills-repo");
|
||||||
|
|
||||||
for (let i = 0; i < 8; i += 1) {
|
|
||||||
const name = `repo-skill-${String(i).padStart(2, "0")}`;
|
|
||||||
await writeSkill({
|
|
||||||
dir: path.join(repoDir, "skills", name),
|
|
||||||
name,
|
|
||||||
description: `Desc ${i}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = withWorkspaceHome(workspaceDir, () =>
|
const snapshot = withWorkspaceHome(workspaceDir, () =>
|
||||||
buildWorkspaceSkillSnapshot(workspaceDir, {
|
buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { captureEnv } from "../test-utils/env.js";
|
import { captureEnv } from "../test-utils/env.js";
|
||||||
import {
|
import {
|
||||||
@@ -141,6 +141,7 @@ async function waitForListMatch<T>(
|
|||||||
describe("chrome extension relay server", () => {
|
describe("chrome extension relay server", () => {
|
||||||
const TEST_GATEWAY_TOKEN = "test-gateway-token";
|
const TEST_GATEWAY_TOKEN = "test-gateway-token";
|
||||||
let cdpUrl = "";
|
let cdpUrl = "";
|
||||||
|
let sharedCdpUrl = "";
|
||||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -162,6 +163,24 @@ describe("chrome extension relay server", () => {
|
|||||||
envSnapshot.restore();
|
envSnapshot.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!sharedCdpUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await stopChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl }).catch(() => {});
|
||||||
|
sharedCdpUrl = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
async function ensureSharedRelayServer() {
|
||||||
|
if (sharedCdpUrl) {
|
||||||
|
return sharedCdpUrl;
|
||||||
|
}
|
||||||
|
const port = await getFreePort();
|
||||||
|
sharedCdpUrl = `http://127.0.0.1:${port}`;
|
||||||
|
await ensureChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl });
|
||||||
|
return sharedCdpUrl;
|
||||||
|
}
|
||||||
|
|
||||||
async function startRelayWithExtension() {
|
async function startRelayWithExtension() {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
cdpUrl = `http://127.0.0.1:${port}`;
|
||||||
@@ -205,57 +224,51 @@ describe("chrome extension relay server", () => {
|
|||||||
const unknown = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
|
const unknown = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
|
||||||
expect(unknown).toEqual({});
|
expect(unknown).toEqual({});
|
||||||
|
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const headers = getChromeExtensionRelayAuthHeaders(cdpUrl);
|
const headers = getChromeExtensionRelayAuthHeaders(sharedUrl);
|
||||||
expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
|
expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
|
||||||
expect(headers["x-openclaw-relay-token"]).not.toBe(TEST_GATEWAY_TOKEN);
|
expect(headers["x-openclaw-relay-token"]).not.toBe(TEST_GATEWAY_TOKEN);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects CDP access without relay auth token", async () => {
|
it("rejects CDP access without relay auth token", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
const sharedPort = new URL(sharedUrl).port;
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const res = await fetch(`${cdpUrl}/json/version`);
|
const res = await fetch(`${sharedUrl}/json/version`);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|
||||||
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
|
const cdp = new WebSocket(`ws://127.0.0.1:${sharedPort}/cdp`);
|
||||||
const err = await waitForError(cdp);
|
const err = await waitForError(cdp);
|
||||||
expect(err.message).toContain("401");
|
expect(err.message).toContain("401");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 for malformed percent-encoding in target action routes", async () => {
|
it("returns 400 for malformed percent-encoding in target action routes", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const res = await fetch(`${cdpUrl}/json/activate/%E0%A4%A`, {
|
const res = await fetch(`${sharedUrl}/json/activate/%E0%A4%A`, {
|
||||||
headers: relayAuthHeaders(cdpUrl),
|
headers: relayAuthHeaders(sharedUrl),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(await res.text()).toContain("invalid targetId encoding");
|
expect(await res.text()).toContain("invalid targetId encoding");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates concurrent relay starts for the same requested port", async () => {
|
it("deduplicates concurrent relay starts for the same requested port", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
const port = Number(new URL(sharedUrl).port);
|
||||||
const [first, second] = await Promise.all([
|
const [first, second] = await Promise.all([
|
||||||
ensureChromeExtensionRelayServer({ cdpUrl }),
|
ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }),
|
||||||
ensureChromeExtensionRelayServer({ cdpUrl }),
|
ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }),
|
||||||
]);
|
]);
|
||||||
expect(first).toBe(second);
|
expect(first).toBe(second);
|
||||||
expect(first.port).toBe(port);
|
expect(first.port).toBe(port);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows CORS preflight from chrome-extension origins", async () => {
|
it("allows CORS preflight from chrome-extension origins", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const origin = "chrome-extension://abcdefghijklmnop";
|
const origin = "chrome-extension://abcdefghijklmnop";
|
||||||
const res = await fetch(`${cdpUrl}/json/version`, {
|
const res = await fetch(`${sharedUrl}/json/version`, {
|
||||||
method: "OPTIONS",
|
method: "OPTIONS",
|
||||||
headers: {
|
headers: {
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
@@ -272,11 +285,9 @@ describe("chrome extension relay server", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects CORS preflight from non-extension origins", async () => {
|
it("rejects CORS preflight from non-extension origins", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const res = await fetch(`${cdpUrl}/json/version`, {
|
const res = await fetch(`${sharedUrl}/json/version`, {
|
||||||
method: "OPTIONS",
|
method: "OPTIONS",
|
||||||
headers: {
|
headers: {
|
||||||
Origin: "https://example.com",
|
Origin: "https://example.com",
|
||||||
@@ -288,15 +299,13 @@ describe("chrome extension relay server", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns CORS headers on JSON responses for extension origins", async () => {
|
it("returns CORS headers on JSON responses for extension origins", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const origin = "chrome-extension://abcdefghijklmnop";
|
const origin = "chrome-extension://abcdefghijklmnop";
|
||||||
const res = await fetch(`${cdpUrl}/json/version`, {
|
const res = await fetch(`${sharedUrl}/json/version`, {
|
||||||
headers: {
|
headers: {
|
||||||
Origin: origin,
|
Origin: origin,
|
||||||
...relayAuthHeaders(cdpUrl),
|
...relayAuthHeaders(sharedUrl),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,11 +314,10 @@ describe("chrome extension relay server", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects extension websocket access without relay auth token", async () => {
|
it("rejects extension websocket access without relay auth token", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
const sharedPort = new URL(sharedUrl).port;
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
|
const ext = new WebSocket(`ws://127.0.0.1:${sharedPort}/extension`);
|
||||||
const err = await waitForError(ext);
|
const err = await waitForError(ext);
|
||||||
expect(err.message).toContain("401");
|
expect(err.message).toContain("401");
|
||||||
});
|
});
|
||||||
@@ -566,44 +574,42 @@ describe("chrome extension relay server", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("accepts extension websocket access with relay token query param", async () => {
|
it("accepts extension websocket access with relay token query param", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
const sharedPort = new URL(sharedUrl).port;
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const token = relayAuthHeaders(`ws://127.0.0.1:${port}/extension`)["x-openclaw-relay-token"];
|
const token = relayAuthHeaders(`ws://127.0.0.1:${sharedPort}/extension`)[
|
||||||
|
"x-openclaw-relay-token"
|
||||||
|
];
|
||||||
expect(token).toBeTruthy();
|
expect(token).toBeTruthy();
|
||||||
const ext = new WebSocket(
|
const ext = new WebSocket(
|
||||||
`ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(String(token))}`,
|
`ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(String(token))}`,
|
||||||
);
|
);
|
||||||
await waitForOpen(ext);
|
await waitForOpen(ext);
|
||||||
ext.close();
|
ext.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts /json endpoints with relay token query param", async () => {
|
it("accepts /json endpoints with relay token query param", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const token = relayAuthHeaders(cdpUrl)["x-openclaw-relay-token"];
|
const token = relayAuthHeaders(sharedUrl)["x-openclaw-relay-token"];
|
||||||
expect(token).toBeTruthy();
|
expect(token).toBeTruthy();
|
||||||
const versionRes = await fetch(
|
const versionRes = await fetch(
|
||||||
`${cdpUrl}/json/version?token=${encodeURIComponent(String(token))}`,
|
`${sharedUrl}/json/version?token=${encodeURIComponent(String(token))}`,
|
||||||
);
|
);
|
||||||
expect(versionRes.status).toBe(200);
|
expect(versionRes.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts raw gateway token for relay auth compatibility", async () => {
|
it("accepts raw gateway token for relay auth compatibility", async () => {
|
||||||
const port = await getFreePort();
|
const sharedUrl = await ensureSharedRelayServer();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
const sharedPort = new URL(sharedUrl).port;
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
|
||||||
|
|
||||||
const versionRes = await fetch(`${cdpUrl}/json/version`, {
|
const versionRes = await fetch(`${sharedUrl}/json/version`, {
|
||||||
headers: { "x-openclaw-relay-token": TEST_GATEWAY_TOKEN },
|
headers: { "x-openclaw-relay-token": TEST_GATEWAY_TOKEN },
|
||||||
});
|
});
|
||||||
expect(versionRes.status).toBe(200);
|
expect(versionRes.status).toBe(200);
|
||||||
|
|
||||||
const ext = new WebSocket(
|
const ext = new WebSocket(
|
||||||
`ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`,
|
`ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`,
|
||||||
);
|
);
|
||||||
await waitForOpen(ext);
|
await waitForOpen(ext);
|
||||||
ext.close();
|
ext.close();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-cha
|
|||||||
import { buildDeviceAuthPayload } from "./device-auth.js";
|
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import {
|
import {
|
||||||
|
createGatewaySuiteHarness,
|
||||||
connectReq,
|
connectReq,
|
||||||
getTrackedConnectChallengeNonce,
|
getTrackedConnectChallengeNonce,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
@@ -360,6 +361,7 @@ export {
|
|||||||
connectReq,
|
connectReq,
|
||||||
CONTROL_UI_CLIENT,
|
CONTROL_UI_CLIENT,
|
||||||
createSignedDevice,
|
createSignedDevice,
|
||||||
|
createGatewaySuiteHarness,
|
||||||
ensurePairedDeviceTokenForCurrentIdentity,
|
ensurePairedDeviceTokenForCurrentIdentity,
|
||||||
expectHelloOkServerVersion,
|
expectHelloOkServerVersion,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
|
|||||||
@@ -115,12 +115,11 @@ installGatewayTestHooks({ scope: "suite" });
|
|||||||
|
|
||||||
let harness: GatewayServerHarness;
|
let harness: GatewayServerHarness;
|
||||||
let sharedSessionStoreDir: string;
|
let sharedSessionStoreDir: string;
|
||||||
let sharedSessionStorePath: string;
|
let sessionStoreCaseSeq = 0;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
harness = await startGatewayServerHarness();
|
harness = await startGatewayServerHarness();
|
||||||
sharedSessionStoreDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-"));
|
sharedSessionStoreDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-"));
|
||||||
sharedSessionStorePath = path.join(sharedSessionStoreDir, "sessions.json");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -131,10 +130,11 @@ afterAll(async () => {
|
|||||||
const openClient = async (opts?: Parameters<typeof connectOk>[1]) => await harness.openClient(opts);
|
const openClient = async (opts?: Parameters<typeof connectOk>[1]) => await harness.openClient(opts);
|
||||||
|
|
||||||
async function createSessionStoreDir() {
|
async function createSessionStoreDir() {
|
||||||
await fs.rm(sharedSessionStoreDir, { recursive: true, force: true });
|
const dir = path.join(sharedSessionStoreDir, `case-${sessionStoreCaseSeq++}`);
|
||||||
await fs.mkdir(sharedSessionStoreDir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
testState.sessionStorePath = sharedSessionStorePath;
|
const storePath = path.join(dir, "sessions.json");
|
||||||
return { dir: sharedSessionStoreDir, storePath: sharedSessionStorePath };
|
testState.sessionStorePath = storePath;
|
||||||
|
return { dir, storePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeSingleLineSession(dir: string, sessionId: string, content: string) {
|
async function writeSingleLineSession(dir: string, sessionId: string, content: string) {
|
||||||
|
|||||||
@@ -354,6 +354,57 @@ export async function withGatewayServer<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createGatewaySuiteHarness(opts?: {
|
||||||
|
port?: number;
|
||||||
|
serverOptions?: GatewayServerOptions;
|
||||||
|
}): Promise<{
|
||||||
|
port: number;
|
||||||
|
server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||||
|
openWs: (headers?: Record<string, string>) => Promise<WebSocket>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const started = await startGatewayServerWithRetries({
|
||||||
|
port: opts?.port ?? (await getFreePort()),
|
||||||
|
opts: opts?.serverOptions,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
port: started.port,
|
||||||
|
server: started.server,
|
||||||
|
openWs: async (headers?: Record<string, string>) => {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${started.port}`, headers ? { headers } : undefined);
|
||||||
|
trackConnectChallengeNonce(ws);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.off("open", onOpen);
|
||||||
|
ws.off("error", onError);
|
||||||
|
ws.off("close", onClose);
|
||||||
|
};
|
||||||
|
const onOpen = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onError = (err: unknown) => {
|
||||||
|
cleanup();
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
};
|
||||||
|
const onClose = (code: number, reason: Buffer) => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`closed ${code}: ${reason.toString()}`));
|
||||||
|
};
|
||||||
|
ws.once("open", onOpen);
|
||||||
|
ws.once("error", onError);
|
||||||
|
ws.once("close", onClose);
|
||||||
|
});
|
||||||
|
return ws;
|
||||||
|
},
|
||||||
|
close: async () => {
|
||||||
|
await started.server.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function startServerWithClient(
|
export async function startServerWithClient(
|
||||||
token?: string,
|
token?: string,
|
||||||
opts?: GatewayServerOptions & { wsHeaders?: Record<string, string> },
|
opts?: GatewayServerOptions & { wsHeaders?: Record<string, string> },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Chat, Message } from "@grammyjs/types";
|
import type { Chat, Message } from "@grammyjs/types";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
||||||
import { withEnvAsync } from "../test-utils/env.js";
|
import { withEnvAsync } from "../test-utils/env.js";
|
||||||
import {
|
import {
|
||||||
@@ -52,10 +52,10 @@ const TELEGRAM_TEST_TIMINGS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
describe("createTelegramBot", () => {
|
||||||
beforeEach(() => {
|
beforeAll(() => {
|
||||||
process.env.TZ = "UTC";
|
process.env.TZ = "UTC";
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterAll(() => {
|
||||||
process.env.TZ = ORIGINAL_TZ;
|
process.env.TZ = ORIGINAL_TZ;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
||||||
import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js";
|
import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js";
|
||||||
import {
|
import {
|
||||||
@@ -36,8 +36,14 @@ function resolveSkillCommands(config: Parameters<typeof listNativeCommandSpecsFo
|
|||||||
|
|
||||||
const ORIGINAL_TZ = process.env.TZ;
|
const ORIGINAL_TZ = process.env.TZ;
|
||||||
describe("createTelegramBot", () => {
|
describe("createTelegramBot", () => {
|
||||||
beforeEach(() => {
|
beforeAll(() => {
|
||||||
process.env.TZ = "UTC";
|
process.env.TZ = "UTC";
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
process.env.TZ = ORIGINAL_TZ;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -49,11 +55,8 @@ describe("createTelegramBot", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
|
||||||
process.env.TZ = ORIGINAL_TZ;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("merges custom commands with native commands", () => {
|
it("merges custom commands with native commands", async () => {
|
||||||
const config = {
|
const config = {
|
||||||
channels: {
|
channels: {
|
||||||
telegram: {
|
telegram: {
|
||||||
@@ -68,6 +71,10 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(setMyCommandsSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
||||||
command: string;
|
command: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -84,7 +91,7 @@ describe("createTelegramBot", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores custom commands that collide with native commands", () => {
|
it("ignores custom commands that collide with native commands", async () => {
|
||||||
const errorSpy = vi.fn();
|
const errorSpy = vi.fn();
|
||||||
const config = {
|
const config = {
|
||||||
channels: {
|
channels: {
|
||||||
@@ -109,6 +116,10 @@ describe("createTelegramBot", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(setMyCommandsSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
||||||
command: string;
|
command: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -126,7 +137,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(errorSpy).toHaveBeenCalled();
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers custom commands when native commands are disabled", () => {
|
it("registers custom commands when native commands are disabled", async () => {
|
||||||
const config = {
|
const config = {
|
||||||
commands: { native: false },
|
commands: { native: false },
|
||||||
channels: {
|
channels: {
|
||||||
@@ -142,6 +153,10 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(setMyCommandsSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
||||||
command: string;
|
command: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ let sharedBinDir = "";
|
|||||||
let sharedHomeDir = "";
|
let sharedHomeDir = "";
|
||||||
let sharedHomeBinDir = "";
|
let sharedHomeBinDir = "";
|
||||||
let sharedFakePythonPath = "";
|
let sharedFakePythonPath = "";
|
||||||
|
const runScriptCache = new Map<string, { ok: boolean; stdout: string; stderr: string }>();
|
||||||
|
|
||||||
async function writeExecutable(filePath: string, body: string): Promise<void> {
|
async function writeExecutable(filePath: string, body: string): Promise<void> {
|
||||||
await writeFile(filePath, body, "utf8");
|
await writeFile(filePath, body, "utf8");
|
||||||
@@ -29,6 +30,14 @@ function runScript(
|
|||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
} {
|
} {
|
||||||
|
const cacheKey = JSON.stringify({
|
||||||
|
homeDir,
|
||||||
|
extraEnv: Object.entries(extraEnv).toSorted(([a], [b]) => a.localeCompare(b)),
|
||||||
|
});
|
||||||
|
const cached = runScriptCache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
const binDir = path.join(homeDir, "bin");
|
const binDir = path.join(homeDir, "bin");
|
||||||
const env = {
|
const env = {
|
||||||
HOME: homeDir,
|
HOME: homeDir,
|
||||||
@@ -42,7 +51,9 @@ function runScript(
|
|||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
return { ok: true, stdout: stdout.trim(), stderr: "" };
|
const result = { ok: true, stdout: stdout.trim(), stderr: "" };
|
||||||
|
runScriptCache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const e = error as {
|
const e = error as {
|
||||||
stdout?: string | Buffer;
|
stdout?: string | Buffer;
|
||||||
@@ -50,7 +61,9 @@ function runScript(
|
|||||||
};
|
};
|
||||||
const stdout = typeof e.stdout === "string" ? e.stdout : (e.stdout?.toString("utf8") ?? "");
|
const stdout = typeof e.stdout === "string" ? e.stdout : (e.stdout?.toString("utf8") ?? "");
|
||||||
const stderr = typeof e.stderr === "string" ? e.stderr : (e.stderr?.toString("utf8") ?? "");
|
const stderr = typeof e.stderr === "string" ? e.stderr : (e.stderr?.toString("utf8") ?? "");
|
||||||
return { ok: false, stdout: stdout.trim(), stderr: stderr.trim() };
|
const result = { ok: false, stdout: stdout.trim(), stderr: stderr.trim() };
|
||||||
|
runScriptCache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user