mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
test: speed up heavy suites with shared fixtures
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
||||||
import {
|
import {
|
||||||
cleanupMockRuntimeFixtures,
|
cleanupMockRuntimeFixtures,
|
||||||
@@ -10,7 +10,14 @@ import {
|
|||||||
} from "./runtime-internals/test-fixtures.js";
|
} from "./runtime-internals/test-fixtures.js";
|
||||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||||
|
|
||||||
|
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
sharedFixture = await createMockRuntimeFixture();
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
sharedFixture = null;
|
||||||
await cleanupMockRuntimeFixtures();
|
await cleanupMockRuntimeFixtures();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,14 +28,10 @@ describe("AcpxRuntime", () => {
|
|||||||
createRuntime: async () => fixture.runtime,
|
createRuntime: async () => fixture.runtime,
|
||||||
agentId: "codex",
|
agentId: "codex",
|
||||||
successPrompt: "contract-pass",
|
successPrompt: "contract-pass",
|
||||||
errorPrompt: "trigger-error",
|
|
||||||
includeControlChecks: false,
|
includeControlChecks: false,
|
||||||
assertSuccessEvents: (events) => {
|
assertSuccessEvents: (events) => {
|
||||||
expect(events.some((event) => event.type === "done")).toBe(true);
|
expect(events.some((event) => event.type === "done")).toBe(true);
|
||||||
},
|
},
|
||||||
assertErrorOutcome: ({ events, thrown }) => {
|
|
||||||
expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs = await readMockRuntimeLogEntries(fixture.logPath);
|
const logs = await readMockRuntimeLogEntries(fixture.logPath);
|
||||||
@@ -108,34 +111,12 @@ describe("AcpxRuntime", () => {
|
|||||||
expect(promptArgs).toContain("--approve-all");
|
expect(promptArgs).toContain("--approve-all");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
|
|
||||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
|
||||||
const handle = await runtime.ensureSession({
|
|
||||||
sessionKey: "agent:codex:acp:ttl-default",
|
|
||||||
agent: "codex",
|
|
||||||
mode: "persistent",
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const _event of runtime.runTurn({
|
|
||||||
handle,
|
|
||||||
text: "ttl-default",
|
|
||||||
mode: "prompt",
|
|
||||||
requestId: "req-ttl-default",
|
|
||||||
})) {
|
|
||||||
// drain
|
|
||||||
}
|
|
||||||
|
|
||||||
const logs = await readMockRuntimeLogEntries(logPath);
|
|
||||||
const prompt = logs.find((entry) => entry.kind === "prompt");
|
|
||||||
expect(prompt).toBeDefined();
|
|
||||||
const promptArgs = (prompt?.args as string[]) ?? [];
|
|
||||||
const ttlFlagIndex = promptArgs.indexOf("--ttl");
|
|
||||||
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves leading spaces across streamed text deltas", async () => {
|
it("preserves leading spaces across streamed text deltas", async () => {
|
||||||
const { runtime } = await createMockRuntimeFixture();
|
const runtime = sharedFixture?.runtime;
|
||||||
|
expect(runtime).toBeDefined();
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("shared runtime fixture missing");
|
||||||
|
}
|
||||||
const handle = await runtime.ensureSession({
|
const handle = await runtime.ensureSession({
|
||||||
sessionKey: "agent:codex:acp:space",
|
sessionKey: "agent:codex:acp:space",
|
||||||
agent: "codex",
|
agent: "codex",
|
||||||
@@ -156,10 +137,28 @@ describe("AcpxRuntime", () => {
|
|||||||
|
|
||||||
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
|
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
|
||||||
expect(textDeltas.join("")).toBe("alpha beta gamma");
|
expect(textDeltas.join("")).toBe("alpha beta gamma");
|
||||||
|
|
||||||
|
// Keep the default queue-owner TTL assertion on a runTurn that already exists.
|
||||||
|
const activeLogPath = process.env.MOCK_ACPX_LOG;
|
||||||
|
expect(activeLogPath).toBeDefined();
|
||||||
|
const logs = await readMockRuntimeLogEntries(String(activeLogPath));
|
||||||
|
const prompt = logs.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:space",
|
||||||
|
);
|
||||||
|
expect(prompt).toBeDefined();
|
||||||
|
const promptArgs = (prompt?.args as string[]) ?? [];
|
||||||
|
const ttlFlagIndex = promptArgs.indexOf("--ttl");
|
||||||
|
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits done once when ACP stream repeats stop reason responses", async () => {
|
it("emits done once when ACP stream repeats stop reason responses", async () => {
|
||||||
const { runtime } = await createMockRuntimeFixture();
|
const runtime = sharedFixture?.runtime;
|
||||||
|
expect(runtime).toBeDefined();
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("shared runtime fixture missing");
|
||||||
|
}
|
||||||
const handle = await runtime.ensureSession({
|
const handle = await runtime.ensureSession({
|
||||||
sessionKey: "agent:codex:acp:double-done",
|
sessionKey: "agent:codex:acp:double-done",
|
||||||
agent: "codex",
|
agent: "codex",
|
||||||
@@ -181,7 +180,11 @@ describe("AcpxRuntime", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("maps acpx error events into ACP runtime error events", async () => {
|
it("maps acpx error events into ACP runtime error events", async () => {
|
||||||
const { runtime } = await createMockRuntimeFixture();
|
const runtime = sharedFixture?.runtime;
|
||||||
|
expect(runtime).toBeDefined();
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("shared runtime fixture missing");
|
||||||
|
}
|
||||||
const handle = await runtime.ensureSession({
|
const handle = await runtime.ensureSession({
|
||||||
sessionKey: "agent:codex:acp:456",
|
sessionKey: "agent:codex:acp:456",
|
||||||
agent: "codex",
|
agent: "codex",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempWorkspace } from "./skills-install.download-test-utils.js";
|
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
|
||||||
|
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
|
||||||
|
import { setTempStateDir } from "./skills-install.download-test-utils.js";
|
||||||
import { installSkill } from "./skills-install.js";
|
import { installSkill } from "./skills-install.js";
|
||||||
import {
|
import {
|
||||||
runCommandWithTimeoutMock,
|
runCommandWithTimeoutMock,
|
||||||
@@ -36,6 +38,27 @@ metadata: {"openclaw":{"install":[{"id":"deps","kind":"node","package":"example-
|
|||||||
return skillDir;
|
return skillDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workspaceSuite = createFixtureSuite("openclaw-skills-install-");
|
||||||
|
let tempHome: TempHomeEnv;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempHome = await createTempHomeEnv("openclaw-skills-install-home-");
|
||||||
|
await workspaceSuite.setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await workspaceSuite.cleanup();
|
||||||
|
await tempHome.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function withWorkspaceCase(
|
||||||
|
run: (params: { workspaceDir: string; stateDir: string }) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const workspaceDir = await workspaceSuite.createCaseDir("case");
|
||||||
|
const stateDir = setTempStateDir(workspaceDir);
|
||||||
|
await run({ workspaceDir, stateDir });
|
||||||
|
}
|
||||||
|
|
||||||
describe("installSkill code safety scanning", () => {
|
describe("installSkill code safety scanning", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
runCommandWithTimeoutMock.mockClear();
|
runCommandWithTimeoutMock.mockClear();
|
||||||
@@ -50,7 +73,7 @@ describe("installSkill code safety scanning", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("adds detailed warnings for critical findings and continues install", async () => {
|
it("adds detailed warnings for critical findings and continues install", async () => {
|
||||||
await withTempWorkspace(async ({ workspaceDir }) => {
|
await withWorkspaceCase(async ({ workspaceDir }) => {
|
||||||
const skillDir = await writeInstallableSkill(workspaceDir, "danger-skill");
|
const skillDir = await writeInstallableSkill(workspaceDir, "danger-skill");
|
||||||
scanDirectoryWithSummaryMock.mockResolvedValue({
|
scanDirectoryWithSummaryMock.mockResolvedValue({
|
||||||
scannedFiles: 1,
|
scannedFiles: 1,
|
||||||
@@ -84,7 +107,7 @@ describe("installSkill code safety scanning", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("warns and continues when skill scan fails", async () => {
|
it("warns and continues when skill scan fails", async () => {
|
||||||
await withTempWorkspace(async ({ workspaceDir }) => {
|
await withWorkspaceCase(async ({ workspaceDir }) => {
|
||||||
await writeInstallableSkill(workspaceDir, "scanfail-skill");
|
await writeInstallableSkill(workspaceDir, "scanfail-skill");
|
||||||
scanDirectoryWithSummaryMock.mockRejectedValue(new Error("scanner exploded"));
|
scanDirectoryWithSummaryMock.mockRejectedValue(new Error("scanner exploded"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { withEnv } from "../test-utils/env.js";
|
import { withEnv } from "../test-utils/env.js";
|
||||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
|
||||||
import { writeSkill } from "./skills.e2e-test-helpers.js";
|
import { writeSkill } from "./skills.e2e-test-helpers.js";
|
||||||
import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt } from "./skills.js";
|
import { buildWorkspaceSkillSnapshot, buildWorkspaceSkillsPrompt } from "./skills.js";
|
||||||
|
|
||||||
const tempDirs = createTrackedTempDirs();
|
const fixtureSuite = createFixtureSuite("openclaw-skills-snapshot-suite-");
|
||||||
|
|
||||||
afterEach(async () => {
|
beforeAll(async () => {
|
||||||
await tempDirs.cleanup();
|
await fixtureSuite.setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fixtureSuite.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
function withWorkspaceHome<T>(workspaceDir: string, cb: () => T): T {
|
function withWorkspaceHome<T>(workspaceDir: string, cb: () => T): T {
|
||||||
@@ -18,7 +22,7 @@ function withWorkspaceHome<T>(workspaceDir: string, cb: () => T): T {
|
|||||||
|
|
||||||
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 tempDirs.make("openclaw-");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
|
|
||||||
const snapshot = withWorkspaceHome(workspaceDir, () =>
|
const snapshot = withWorkspaceHome(workspaceDir, () =>
|
||||||
buildWorkspaceSkillSnapshot(workspaceDir, {
|
buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
@@ -32,7 +36,7 @@ describe("buildWorkspaceSkillSnapshot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("omits disable-model-invocation skills from the prompt", async () => {
|
it("omits disable-model-invocation skills from the prompt", async () => {
|
||||||
const workspaceDir = await tempDirs.make("openclaw-");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
await writeSkill({
|
await writeSkill({
|
||||||
dir: path.join(workspaceDir, "skills", "visible-skill"),
|
dir: path.join(workspaceDir, "skills", "visible-skill"),
|
||||||
name: "visible-skill",
|
name: "visible-skill",
|
||||||
@@ -61,7 +65,7 @@ describe("buildWorkspaceSkillSnapshot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps prompt output aligned with buildWorkspaceSkillsPrompt", async () => {
|
it("keeps prompt output aligned with buildWorkspaceSkillsPrompt", async () => {
|
||||||
const workspaceDir = await tempDirs.make("openclaw-");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
await writeSkill({
|
await writeSkill({
|
||||||
dir: path.join(workspaceDir, "skills", "visible"),
|
dir: path.join(workspaceDir, "skills", "visible"),
|
||||||
name: "visible",
|
name: "visible",
|
||||||
@@ -106,7 +110,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 tempDirs.make("openclaw-");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
|
|
||||||
// Keep fixture size modest while still forcing truncation logic.
|
// Keep fixture size modest while still forcing truncation logic.
|
||||||
for (let i = 0; i < 8; i += 1) {
|
for (let i = 0; i < 8; i += 1) {
|
||||||
@@ -138,8 +142,8 @@ 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 tempDirs.make("openclaw-");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
const repoDir = await tempDirs.make("openclaw-skills-repo-");
|
const repoDir = await fixtureSuite.createCaseDir("skills-repo");
|
||||||
|
|
||||||
for (let i = 0; i < 8; i += 1) {
|
for (let i = 0; i < 8; i += 1) {
|
||||||
const name = `repo-skill-${String(i).padStart(2, "0")}`;
|
const name = `repo-skill-${String(i).padStart(2, "0")}`;
|
||||||
@@ -175,7 +179,7 @@ describe("buildWorkspaceSkillSnapshot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips skills whose SKILL.md exceeds maxSkillFileBytes", async () => {
|
it("skips skills whose SKILL.md exceeds maxSkillFileBytes", async () => {
|
||||||
const workspaceDir = await tempDirs.make("openclaw-");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
|
|
||||||
await writeSkill({
|
await writeSkill({
|
||||||
dir: path.join(workspaceDir, "skills", "small-skill"),
|
dir: path.join(workspaceDir, "skills", "small-skill"),
|
||||||
@@ -211,8 +215,8 @@ describe("buildWorkspaceSkillSnapshot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("detects nested skills roots beyond the first 25 entries", async () => {
|
it("detects nested skills roots beyond the first 25 entries", async () => {
|
||||||
const workspaceDir = await tempDirs.make("openclaw-");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
const repoDir = await tempDirs.make("openclaw-skills-repo-");
|
const repoDir = await fixtureSuite.createCaseDir("skills-repo");
|
||||||
|
|
||||||
// Create 30 nested dirs, but only the last one is an actual skill.
|
// Create 30 nested dirs, but only the last one is an actual skill.
|
||||||
for (let i = 0; i < 30; i += 1) {
|
for (let i = 0; i < 30; i += 1) {
|
||||||
@@ -250,8 +254,8 @@ describe("buildWorkspaceSkillSnapshot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("enforces maxSkillFileBytes for root-level SKILL.md", async () => {
|
it("enforces maxSkillFileBytes for root-level SKILL.md", async () => {
|
||||||
const workspaceDir = await tempDirs.make("openclaw-");
|
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
|
||||||
const rootSkillDir = await tempDirs.make("openclaw-root-skill-");
|
const rootSkillDir = await fixtureSuite.createCaseDir("root-skill");
|
||||||
|
|
||||||
await writeSkill({
|
await writeSkill({
|
||||||
dir: rootSkillDir,
|
dir: rootSkillDir,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
|
||||||
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
|
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
|
||||||
import { writeSkill } from "./skills.e2e-test-helpers.js";
|
import { writeSkill } from "./skills.e2e-test-helpers.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
loadWorkspaceSkillEntries,
|
loadWorkspaceSkillEntries,
|
||||||
} from "./skills.js";
|
} from "./skills.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const fixtureSuite = createFixtureSuite("openclaw-skills-suite-");
|
||||||
let tempHome: TempHomeEnv | null = null;
|
let tempHome: TempHomeEnv | null = null;
|
||||||
|
|
||||||
const resolveTestSkillDirs = (workspaceDir: string) => ({
|
const resolveTestSkillDirs = (workspaceDir: string) => ({
|
||||||
@@ -21,11 +21,7 @@ const resolveTestSkillDirs = (workspaceDir: string) => ({
|
|||||||
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
|
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeWorkspace = async () => {
|
const makeWorkspace = async () => await fixtureSuite.createCaseDir("workspace");
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-"));
|
|
||||||
tempDirs.push(workspaceDir);
|
|
||||||
return workspaceDir;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withClearedEnv = <T>(
|
const withClearedEnv = <T>(
|
||||||
keys: string[],
|
keys: string[],
|
||||||
@@ -52,6 +48,7 @@ const withClearedEnv = <T>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
await fixtureSuite.setup();
|
||||||
tempHome = await createTempHomeEnv("openclaw-skills-home-");
|
tempHome = await createTempHomeEnv("openclaw-skills-home-");
|
||||||
await fs.mkdir(path.join(tempHome.home, ".openclaw", "agents", "main", "sessions"), {
|
await fs.mkdir(path.join(tempHome.home, ".openclaw", "agents", "main", "sessions"), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
@@ -63,10 +60,7 @@ afterAll(async () => {
|
|||||||
await tempHome.restore();
|
await tempHome.restore();
|
||||||
tempHome = null;
|
tempHome = null;
|
||||||
}
|
}
|
||||||
|
await fixtureSuite.cleanup();
|
||||||
await Promise.all(
|
|
||||||
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildWorkspaceSkillCommandSpecs", () => {
|
describe("buildWorkspaceSkillCommandSpecs", () => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
|
|||||||
import { buildModelAliasIndex } from "../../agents/model-selection.js";
|
import { buildModelAliasIndex } from "../../agents/model-selection.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
import { saveSessionStore } from "../../config/sessions.js";
|
|
||||||
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
|
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
|
||||||
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||||
import { applyResetModelOverride } from "./session-reset-model.js";
|
import { applyResetModelOverride } from "./session-reset-model.js";
|
||||||
@@ -51,6 +50,14 @@ async function makeStorePath(prefix: string): Promise<string> {
|
|||||||
|
|
||||||
const createStorePath = makeStorePath;
|
const createStorePath = makeStorePath;
|
||||||
|
|
||||||
|
async function writeSessionStoreFast(
|
||||||
|
storePath: string,
|
||||||
|
store: Record<string, SessionEntry | Record<string, unknown>>,
|
||||||
|
): Promise<void> {
|
||||||
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
|
await fs.writeFile(storePath, JSON.stringify(store), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
describe("initSessionState thread forking", () => {
|
describe("initSessionState thread forking", () => {
|
||||||
it("forks a new session from the parent session file", async () => {
|
it("forks a new session from the parent session file", async () => {
|
||||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
@@ -89,7 +96,7 @@ describe("initSessionState thread forking", () => {
|
|||||||
|
|
||||||
const storePath = path.join(root, "sessions.json");
|
const storePath = path.join(root, "sessions.json");
|
||||||
const parentSessionKey = "agent:main:slack:channel:c1";
|
const parentSessionKey = "agent:main:slack:channel:c1";
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[parentSessionKey]: {
|
[parentSessionKey]: {
|
||||||
sessionId: parentSessionId,
|
sessionId: parentSessionId,
|
||||||
sessionFile: parentSessionFile,
|
sessionFile: parentSessionFile,
|
||||||
@@ -175,7 +182,7 @@ describe("initSessionState thread forking", () => {
|
|||||||
const storePath = path.join(root, "sessions.json");
|
const storePath = path.join(root, "sessions.json");
|
||||||
const parentSessionKey = "agent:main:slack:channel:c1";
|
const parentSessionKey = "agent:main:slack:channel:c1";
|
||||||
const threadSessionKey = "agent:main:slack:channel:c1:thread:123";
|
const threadSessionKey = "agent:main:slack:channel:c1:thread:123";
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[parentSessionKey]: {
|
[parentSessionKey]: {
|
||||||
sessionId: parentSessionId,
|
sessionId: parentSessionId,
|
||||||
sessionFile: parentSessionFile,
|
sessionFile: parentSessionFile,
|
||||||
@@ -256,7 +263,7 @@ describe("initSessionState thread forking", () => {
|
|||||||
const storePath = path.join(root, "sessions.json");
|
const storePath = path.join(root, "sessions.json");
|
||||||
const parentSessionKey = "agent:main:slack:channel:c1";
|
const parentSessionKey = "agent:main:slack:channel:c1";
|
||||||
// Set totalTokens well above PARENT_FORK_MAX_TOKENS (100_000)
|
// Set totalTokens well above PARENT_FORK_MAX_TOKENS (100_000)
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[parentSessionKey]: {
|
[parentSessionKey]: {
|
||||||
sessionId: parentSessionId,
|
sessionId: parentSessionId,
|
||||||
sessionFile: parentSessionFile,
|
sessionFile: parentSessionFile,
|
||||||
@@ -324,7 +331,7 @@ describe("initSessionState thread forking", () => {
|
|||||||
|
|
||||||
const storePath = path.join(root, "sessions.json");
|
const storePath = path.join(root, "sessions.json");
|
||||||
const parentSessionKey = "agent:main:slack:channel:c1";
|
const parentSessionKey = "agent:main:slack:channel:c1";
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[parentSessionKey]: {
|
[parentSessionKey]: {
|
||||||
sessionId: parentSessionId,
|
sessionId: parentSessionId,
|
||||||
sessionFile: parentSessionFile,
|
sessionFile: parentSessionFile,
|
||||||
@@ -461,7 +468,7 @@ describe("initSessionState RawBody", () => {
|
|||||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
@@ -507,7 +514,7 @@ describe("initSessionState reset policy", () => {
|
|||||||
const sessionKey = "agent:main:whatsapp:dm:s1";
|
const sessionKey = "agent:main:whatsapp:dm:s1";
|
||||||
const existingSessionId = "daily-session-id";
|
const existingSessionId = "daily-session-id";
|
||||||
|
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: existingSessionId,
|
sessionId: existingSessionId,
|
||||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||||
@@ -532,7 +539,7 @@ describe("initSessionState reset policy", () => {
|
|||||||
const sessionKey = "agent:main:whatsapp:dm:s-edge";
|
const sessionKey = "agent:main:whatsapp:dm:s-edge";
|
||||||
const existingSessionId = "daily-edge-session";
|
const existingSessionId = "daily-edge-session";
|
||||||
|
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: existingSessionId,
|
sessionId: existingSessionId,
|
||||||
updatedAt: new Date(2026, 0, 17, 3, 30, 0).getTime(),
|
updatedAt: new Date(2026, 0, 17, 3, 30, 0).getTime(),
|
||||||
@@ -557,7 +564,7 @@ describe("initSessionState reset policy", () => {
|
|||||||
const sessionKey = "agent:main:whatsapp:dm:s2";
|
const sessionKey = "agent:main:whatsapp:dm:s2";
|
||||||
const existingSessionId = "idle-session-id";
|
const existingSessionId = "idle-session-id";
|
||||||
|
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: existingSessionId,
|
sessionId: existingSessionId,
|
||||||
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
|
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
|
||||||
@@ -587,7 +594,7 @@ describe("initSessionState reset policy", () => {
|
|||||||
const sessionKey = "agent:main:slack:channel:c1:thread:123";
|
const sessionKey = "agent:main:slack:channel:c1:thread:123";
|
||||||
const existingSessionId = "thread-session-id";
|
const existingSessionId = "thread-session-id";
|
||||||
|
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: existingSessionId,
|
sessionId: existingSessionId,
|
||||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||||
@@ -618,7 +625,7 @@ describe("initSessionState reset policy", () => {
|
|||||||
const sessionKey = "agent:main:discord:channel:c1";
|
const sessionKey = "agent:main:discord:channel:c1";
|
||||||
const existingSessionId = "thread-nosuffix";
|
const existingSessionId = "thread-nosuffix";
|
||||||
|
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: existingSessionId,
|
sessionId: existingSessionId,
|
||||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||||
@@ -648,7 +655,7 @@ describe("initSessionState reset policy", () => {
|
|||||||
const sessionKey = "agent:main:whatsapp:dm:s4";
|
const sessionKey = "agent:main:whatsapp:dm:s4";
|
||||||
const existingSessionId = "type-default-session";
|
const existingSessionId = "type-default-session";
|
||||||
|
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: existingSessionId,
|
sessionId: existingSessionId,
|
||||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||||
@@ -678,7 +685,7 @@ describe("initSessionState reset policy", () => {
|
|||||||
const sessionKey = "agent:main:whatsapp:dm:s3";
|
const sessionKey = "agent:main:whatsapp:dm:s3";
|
||||||
const existingSessionId = "legacy-session-id";
|
const existingSessionId = "legacy-session-id";
|
||||||
|
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: existingSessionId,
|
sessionId: existingSessionId,
|
||||||
updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(),
|
updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(),
|
||||||
@@ -710,7 +717,7 @@ describe("initSessionState channel reset overrides", () => {
|
|||||||
const sessionId = "session-override";
|
const sessionId = "session-override";
|
||||||
const updatedAt = Date.now() - (10080 - 1) * 60_000;
|
const updatedAt = Date.now() - (10080 - 1) * 60_000;
|
||||||
|
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId,
|
sessionId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
@@ -747,7 +754,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await saveSessionStore(params.storePath, {
|
await writeSessionStoreFast(params.storePath, {
|
||||||
[params.sessionKey]: {
|
[params.sessionKey]: {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -840,7 +847,7 @@ describe("initSessionState reset triggers in Slack channels", () => {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await saveSessionStore(params.storePath, {
|
await writeSessionStoreFast(params.storePath, {
|
||||||
[params.sessionKey]: {
|
[params.sessionKey]: {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -989,7 +996,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
overrides: Record<string, unknown>;
|
overrides: Record<string, unknown>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await saveSessionStore(params.storePath, {
|
await writeSessionStoreFast(params.storePath, {
|
||||||
[params.sessionKey]: {
|
[params.sessionKey]: {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -1390,7 +1397,7 @@ describe("initSessionState stale threadId fallback", () => {
|
|||||||
describe("initSessionState dmScope delivery migration", () => {
|
describe("initSessionState dmScope delivery migration", () => {
|
||||||
it("retires stale main-session delivery route when dmScope uses per-channel DM keys", async () => {
|
it("retires stale main-session delivery route when dmScope uses per-channel DM keys", async () => {
|
||||||
const storePath = await createStorePath("dm-scope-retire-main-route-");
|
const storePath = await createStorePath("dm-scope-retire-main-route-");
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
"agent:main:main": {
|
"agent:main:main": {
|
||||||
sessionId: "legacy-main",
|
sessionId: "legacy-main",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -1436,7 +1443,7 @@ describe("initSessionState dmScope delivery migration", () => {
|
|||||||
|
|
||||||
it("keeps legacy main-session delivery route when current DM target does not match", async () => {
|
it("keeps legacy main-session delivery route when current DM target does not match", async () => {
|
||||||
const storePath = await createStorePath("dm-scope-keep-main-route-");
|
const storePath = await createStorePath("dm-scope-keep-main-route-");
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
"agent:main:main": {
|
"agent:main:main": {
|
||||||
sessionId: "legacy-main",
|
sessionId: "legacy-main",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -1483,7 +1490,7 @@ describe("initSessionState internal channel routing preservation", () => {
|
|||||||
it("keeps persisted external lastChannel when OriginatingChannel is internal webchat", async () => {
|
it("keeps persisted external lastChannel when OriginatingChannel is internal webchat", async () => {
|
||||||
const storePath = await createStorePath("preserve-external-channel-");
|
const storePath = await createStorePath("preserve-external-channel-");
|
||||||
const sessionKey = "agent:main:telegram:group:12345";
|
const sessionKey = "agent:main:telegram:group:12345";
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sess-1",
|
sessionId: "sess-1",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -1517,7 +1524,7 @@ describe("initSessionState internal channel routing preservation", () => {
|
|||||||
it("keeps persisted external route when OriginatingChannel is non-deliverable", async () => {
|
it("keeps persisted external route when OriginatingChannel is non-deliverable", async () => {
|
||||||
const storePath = await createStorePath("preserve-nondeliverable-route-");
|
const storePath = await createStorePath("preserve-nondeliverable-route-");
|
||||||
const sessionKey = "agent:main:discord:channel:24680";
|
const sessionKey = "agent:main:discord:channel:24680";
|
||||||
await saveSessionStore(storePath, {
|
await writeSessionStoreFast(storePath, {
|
||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sess-2",
|
sessionId: "sess-2",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
|||||||
@@ -126,20 +126,16 @@ async function waitForListMatch<T>(
|
|||||||
timeoutMs = RELAY_LIST_MATCH_TIMEOUT_MS,
|
timeoutMs = RELAY_LIST_MATCH_TIMEOUT_MS,
|
||||||
intervalMs = 20,
|
intervalMs = 20,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let latest: T | undefined;
|
const deadline = Date.now() + timeoutMs;
|
||||||
await expect
|
let latest: T | null = null;
|
||||||
.poll(
|
while (Date.now() <= deadline) {
|
||||||
async () => {
|
latest = await fetchList();
|
||||||
latest = await fetchList();
|
if (predicate(latest)) {
|
||||||
return predicate(latest);
|
return latest;
|
||||||
},
|
}
|
||||||
{ timeout: timeoutMs, interval: intervalMs },
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
)
|
|
||||||
.toBe(true);
|
|
||||||
if (latest === undefined) {
|
|
||||||
throw new Error("expected list value");
|
|
||||||
}
|
}
|
||||||
return latest;
|
throw new Error("timeout waiting for list match");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("chrome extension relay server", () => {
|
describe("chrome extension relay server", () => {
|
||||||
@@ -453,14 +449,13 @@ describe("chrome extension relay server", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const list = await waitForListMatch(
|
await waitForListMatch(
|
||||||
async () =>
|
async () =>
|
||||||
(await fetch(`${cdpUrl}/json/list`, {
|
(await fetch(`${cdpUrl}/json/list`, {
|
||||||
headers: relayAuthHeaders(cdpUrl),
|
headers: relayAuthHeaders(cdpUrl),
|
||||||
}).then((r) => r.json())) as Array<{ id?: string }>,
|
}).then((r) => r.json())) as Array<{ id?: string }>,
|
||||||
(entries) => entries.some((entry) => entry.id === "t-minimal"),
|
(entries) => entries.some((entry) => entry.id === "t-minimal"),
|
||||||
);
|
);
|
||||||
expect(list.some((entry) => entry.id === "t-minimal")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits briefly for extension reconnect before failing CDP commands", async () => {
|
it("waits briefly for extension reconnect before failing CDP commands", async () => {
|
||||||
@@ -666,7 +661,7 @@ describe("chrome extension relay server", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const list2 = await waitForListMatch(
|
await waitForListMatch(
|
||||||
async () =>
|
async () =>
|
||||||
(await fetch(`${cdpUrl}/json/list`, {
|
(await fetch(`${cdpUrl}/json/list`, {
|
||||||
headers: relayAuthHeaders(cdpUrl),
|
headers: relayAuthHeaders(cdpUrl),
|
||||||
@@ -683,12 +678,6 @@ describe("chrome extension relay server", () => {
|
|||||||
t.title === "DER STANDARD",
|
t.title === "DER STANDARD",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(
|
|
||||||
list2.some(
|
|
||||||
(t) =>
|
|
||||||
t.id === "t1" && t.url === "https://www.derstandard.at/" && t.title === "DER STANDARD",
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
|
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
|
||||||
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
|
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
|
||||||
@@ -699,7 +688,10 @@ describe("chrome extension relay server", () => {
|
|||||||
cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" }));
|
cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" }));
|
||||||
const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown };
|
const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown };
|
||||||
expect(res1.id).toBe(1);
|
expect(res1.id).toBe(1);
|
||||||
expect(JSON.stringify(res1.result ?? {})).toContain("t1");
|
const targetInfos = (
|
||||||
|
res1.result as { targetInfos?: Array<{ targetId?: string }> } | undefined
|
||||||
|
)?.targetInfos;
|
||||||
|
expect((targetInfos ?? []).some((target) => target.targetId === "t1")).toBe(true);
|
||||||
|
|
||||||
cdp.send(
|
cdp.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -719,11 +711,13 @@ describe("chrome extension relay server", () => {
|
|||||||
|
|
||||||
const res2 = received.find((m) => m.id === 2);
|
const res2 = received.find((m) => m.id === 2);
|
||||||
expect(res2?.id).toBe(2);
|
expect(res2?.id).toBe(2);
|
||||||
expect(JSON.stringify(res2?.result ?? {})).toContain("cb-tab-1");
|
expect((res2?.result as { sessionId?: string } | undefined)?.sessionId).toBe("cb-tab-1");
|
||||||
|
|
||||||
const evt = received.find((m) => m.method === "Target.attachedToTarget");
|
const evt = received.find((m) => m.method === "Target.attachedToTarget");
|
||||||
expect(evt?.method).toBe("Target.attachedToTarget");
|
expect(evt?.method).toBe("Target.attachedToTarget");
|
||||||
expect(JSON.stringify(evt?.params ?? {})).toContain("t1");
|
expect(
|
||||||
|
(evt?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId,
|
||||||
|
).toBe("t1");
|
||||||
|
|
||||||
cdp.close();
|
cdp.close();
|
||||||
ext.close();
|
ext.close();
|
||||||
@@ -771,15 +765,13 @@ describe("chrome extension relay server", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedList = await waitForListMatch(
|
await waitForListMatch(
|
||||||
async () =>
|
async () =>
|
||||||
(await fetch(`${cdpUrl}/json/list`, {
|
(await fetch(`${cdpUrl}/json/list`, {
|
||||||
headers: relayAuthHeaders(cdpUrl),
|
headers: relayAuthHeaders(cdpUrl),
|
||||||
}).then((r) => r.json())) as Array<{ id?: string }>,
|
}).then((r) => r.json())) as Array<{ id?: string }>,
|
||||||
(list) => list.every((target) => target.id !== "t1"),
|
(list) => list.every((target) => target.id !== "t1"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(updatedList.some((target) => target.id === "t1")).toBe(false);
|
|
||||||
ext.close();
|
ext.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -860,14 +852,13 @@ describe("chrome extension relay server", () => {
|
|||||||
expect(response?.id).toBe(77);
|
expect(response?.id).toBe(77);
|
||||||
expect(response?.error?.message ?? "").toContain("No target with given id");
|
expect(response?.error?.message ?? "").toContain("No target with given id");
|
||||||
|
|
||||||
const updatedList = await waitForListMatch(
|
await waitForListMatch(
|
||||||
async () =>
|
async () =>
|
||||||
(await fetch(`${cdpUrl}/json/list`, {
|
(await fetch(`${cdpUrl}/json/list`, {
|
||||||
headers: relayAuthHeaders(cdpUrl),
|
headers: relayAuthHeaders(cdpUrl),
|
||||||
}).then((r) => r.json())) as Array<{ id?: string }>,
|
}).then((r) => r.json())) as Array<{ id?: string }>,
|
||||||
(list) => list.every((target) => target.id !== "t1"),
|
(list) => list.every((target) => target.id !== "t1"),
|
||||||
);
|
);
|
||||||
expect(updatedList.some((target) => target.id === "t1")).toBe(false);
|
|
||||||
|
|
||||||
cdp.close();
|
cdp.close();
|
||||||
ext.close();
|
ext.close();
|
||||||
@@ -903,7 +894,9 @@ describe("chrome extension relay server", () => {
|
|||||||
|
|
||||||
const first = JSON.parse(await q.next()) as { method?: string; params?: unknown };
|
const first = JSON.parse(await q.next()) as { method?: string; params?: unknown };
|
||||||
expect(first.method).toBe("Target.attachedToTarget");
|
expect(first.method).toBe("Target.attachedToTarget");
|
||||||
expect(JSON.stringify(first.params ?? {})).toContain("t1");
|
expect(
|
||||||
|
(first.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId,
|
||||||
|
).toBe("t1");
|
||||||
|
|
||||||
ext.send(
|
ext.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -930,8 +923,11 @@ describe("chrome extension relay server", () => {
|
|||||||
|
|
||||||
const detached = received.find((m) => m.method === "Target.detachedFromTarget");
|
const detached = received.find((m) => m.method === "Target.detachedFromTarget");
|
||||||
const attached = received.find((m) => m.method === "Target.attachedToTarget");
|
const attached = received.find((m) => m.method === "Target.attachedToTarget");
|
||||||
expect(JSON.stringify(detached?.params ?? {})).toContain("t1");
|
expect((detached?.params as { targetId?: string } | undefined)?.targetId).toBe("t1");
|
||||||
expect(JSON.stringify(attached?.params ?? {})).toContain("t2");
|
expect(
|
||||||
|
(attached?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo
|
||||||
|
?.targetId,
|
||||||
|
).toBe("t2");
|
||||||
|
|
||||||
cdp.close();
|
cdp.close();
|
||||||
ext.close();
|
ext.close();
|
||||||
@@ -1007,14 +1003,13 @@ describe("chrome extension relay server", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const list1 = await waitForListMatch(
|
await waitForListMatch(
|
||||||
async () =>
|
async () =>
|
||||||
(await fetch(`${cdpUrl}/json/list`, {
|
(await fetch(`${cdpUrl}/json/list`, {
|
||||||
headers: relayAuthHeaders(cdpUrl),
|
headers: relayAuthHeaders(cdpUrl),
|
||||||
}).then((r) => r.json())) as Array<{ id?: string }>,
|
}).then((r) => r.json())) as Array<{ id?: string }>,
|
||||||
(list) => list.some((t) => t.id === "t10"),
|
(list) => list.some((t) => t.id === "t10"),
|
||||||
);
|
);
|
||||||
expect(list1.some((t) => t.id === "t10")).toBe(true);
|
|
||||||
|
|
||||||
// Disconnect extension and wait for grace period cleanup.
|
// Disconnect extension and wait for grace period cleanup.
|
||||||
const ext1Closed = waitForClose(ext1, 2_000);
|
const ext1Closed = waitForClose(ext1, 2_000);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -18,6 +17,11 @@ import { buildOpenAiResponsesProviderConfig } from "./test-openai-responses-mode
|
|||||||
let writeConfigFile: typeof import("../config/config.js").writeConfigFile;
|
let writeConfigFile: typeof import("../config/config.js").writeConfigFile;
|
||||||
let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath;
|
let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath;
|
||||||
const GATEWAY_E2E_TIMEOUT_MS = 30_000;
|
const GATEWAY_E2E_TIMEOUT_MS = 30_000;
|
||||||
|
let gatewayTestSeq = 0;
|
||||||
|
|
||||||
|
function nextGatewayId(prefix: string): string {
|
||||||
|
return `${prefix}-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}-${gatewayTestSeq++}`;
|
||||||
|
}
|
||||||
|
|
||||||
describe("gateway e2e", () => {
|
describe("gateway e2e", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -49,14 +53,14 @@ describe("gateway e2e", () => {
|
|||||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
||||||
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
|
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
|
||||||
|
|
||||||
const token = `test-${randomUUID()}`;
|
const token = nextGatewayId("test-token");
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN = token;
|
process.env.OPENCLAW_GATEWAY_TOKEN = token;
|
||||||
|
|
||||||
const workspaceDir = path.join(tempHome, "openclaw");
|
const workspaceDir = path.join(tempHome, "openclaw");
|
||||||
await fs.mkdir(workspaceDir, { recursive: true });
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
|
|
||||||
const nonceA = randomUUID();
|
const nonceA = nextGatewayId("nonce-a");
|
||||||
const nonceB = randomUUID();
|
const nonceB = nextGatewayId("nonce-b");
|
||||||
const toolProbePath = path.join(workspaceDir, `.openclaw-tool-probe.${nonceA}.txt`);
|
const toolProbePath = path.join(workspaceDir, `.openclaw-tool-probe.${nonceA}.txt`);
|
||||||
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
|
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ describe("gateway e2e", () => {
|
|||||||
model: "openai/gpt-5.2",
|
model: "openai/gpt-5.2",
|
||||||
});
|
});
|
||||||
|
|
||||||
const runId = randomUUID();
|
const runId = nextGatewayId("run");
|
||||||
const payload = await client.request<{
|
const payload = await client.request<{
|
||||||
status?: unknown;
|
status?: unknown;
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
@@ -149,7 +153,7 @@ describe("gateway e2e", () => {
|
|||||||
delete process.env.OPENCLAW_STATE_DIR;
|
delete process.env.OPENCLAW_STATE_DIR;
|
||||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||||
|
|
||||||
const wizardToken = `wiz-${randomUUID()}`;
|
const wizardToken = nextGatewayId("wiz-token");
|
||||||
const port = await getFreeGatewayPort();
|
const port = await getFreeGatewayPort();
|
||||||
const server = await startGatewayServer(port, {
|
const server = await startGatewayServer(port, {
|
||||||
bind: "loopback",
|
bind: "loopback",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
writeTrustedProxyControlUiConfig,
|
writeTrustedProxyControlUiConfig,
|
||||||
} from "./server.auth.shared.js";
|
} from "./server.auth.shared.js";
|
||||||
|
|
||||||
|
let controlUiIdentityPathSeq = 0;
|
||||||
|
|
||||||
export function registerControlUiAndPairingSuite(): void {
|
export function registerControlUiAndPairingSuite(): void {
|
||||||
const trustedProxyControlUiCases: Array<{
|
const trustedProxyControlUiCases: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -195,7 +197,6 @@ export function registerControlUiAndPairingSuite(): void {
|
|||||||
const challenge = await challengePromise;
|
const challenge = await challengePromise;
|
||||||
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
|
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
|
||||||
expect(typeof nonce).toBe("string");
|
expect(typeof nonce).toBe("string");
|
||||||
const { randomUUID } = await import("node:crypto");
|
|
||||||
const os = await import("node:os");
|
const os = await import("node:os");
|
||||||
const path = await import("node:path");
|
const path = await import("node:path");
|
||||||
const scopes = [
|
const scopes = [
|
||||||
@@ -210,7 +211,10 @@ export function registerControlUiAndPairingSuite(): void {
|
|||||||
scopes,
|
scopes,
|
||||||
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||||
identityPath: path.join(os.tmpdir(), `openclaw-controlui-device-${randomUUID()}.json`),
|
identityPath: path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`openclaw-controlui-device-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}-${controlUiIdentityPathSeq++}.json`,
|
||||||
|
),
|
||||||
nonce: String(nonce),
|
nonce: String(nonce),
|
||||||
});
|
});
|
||||||
const res = await connectReq(ws, {
|
const res = await connectReq(ws, {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect } from "vitest";
|
import { expect } from "vitest";
|
||||||
@@ -22,6 +21,22 @@ import {
|
|||||||
withGatewayServer,
|
withGatewayServer,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
|
let authIdentityPathSeq = 0;
|
||||||
|
|
||||||
|
function nextAuthIdentityPath(prefix: string): string {
|
||||||
|
const poolId = process.env.VITEST_POOL_ID ?? "0";
|
||||||
|
const fileName =
|
||||||
|
prefix +
|
||||||
|
"-" +
|
||||||
|
String(process.pid) +
|
||||||
|
"-" +
|
||||||
|
poolId +
|
||||||
|
"-" +
|
||||||
|
String(authIdentityPathSeq++) +
|
||||||
|
".json";
|
||||||
|
return path.join(os.tmpdir(), fileName);
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean> {
|
async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean> {
|
||||||
if (ws.readyState === WebSocket.CLOSED) {
|
if (ws.readyState === WebSocket.CLOSED) {
|
||||||
return true;
|
return true;
|
||||||
@@ -287,10 +302,7 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() {
|
|||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const { server, ws, port, prevToken } = await startServerWithClient();
|
const { server, ws, port, prevToken } = await startServerWithClient();
|
||||||
const deviceIdentityPath = path.join(
|
const deviceIdentityPath = nextAuthIdentityPath("openclaw-auth-rate-limit");
|
||||||
os.tmpdir(),
|
|
||||||
"openclaw-auth-rate-limit-" + randomUUID() + ".json",
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const initial = await connectReq(ws, { token: "secret", deviceIdentityPath });
|
const initial = await connectReq(ws, { token: "secret", deviceIdentityPath });
|
||||||
if (!initial.ok) {
|
if (!initial.ok) {
|
||||||
@@ -321,10 +333,7 @@ async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise
|
|||||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||||
const { getPairedDevice } = await import("../infra/device-pairing.js");
|
const { getPairedDevice } = await import("../infra/device-pairing.js");
|
||||||
|
|
||||||
const deviceIdentityPath = path.join(
|
const deviceIdentityPath = nextAuthIdentityPath("openclaw-auth-device");
|
||||||
os.tmpdir(),
|
|
||||||
"openclaw-auth-device-" + randomUUID() + ".json",
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await connectReq(ws, { token: "secret", deviceIdentityPath });
|
const res = await connectReq(ws, { token: "secret", deviceIdentityPath });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const GATEWAY_TEST_ENV_KEYS = [
|
|||||||
let gatewayEnvSnapshot: ReturnType<typeof captureEnv> | undefined;
|
let gatewayEnvSnapshot: ReturnType<typeof captureEnv> | undefined;
|
||||||
let tempHome: string | undefined;
|
let tempHome: string | undefined;
|
||||||
let tempConfigRoot: string | undefined;
|
let tempConfigRoot: string | undefined;
|
||||||
|
let suiteConfigRootSeq = 0;
|
||||||
|
|
||||||
export async function writeSessionStore(params: {
|
export async function writeSessionStore(params: {
|
||||||
entries: Record<string, Partial<SessionEntry>>;
|
entries: Record<string, Partial<SessionEntry>>;
|
||||||
@@ -121,7 +122,11 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
|
|||||||
}
|
}
|
||||||
applyGatewaySkipEnv();
|
applyGatewaySkipEnv();
|
||||||
if (options.uniqueConfigRoot) {
|
if (options.uniqueConfigRoot) {
|
||||||
tempConfigRoot = await fs.mkdtemp(path.join(tempHome, "openclaw-test-"));
|
const suiteRoot = path.join(tempHome, ".openclaw-test-suite");
|
||||||
|
await fs.mkdir(suiteRoot, { recursive: true });
|
||||||
|
tempConfigRoot = path.join(suiteRoot, `case-${suiteConfigRootSeq++}`);
|
||||||
|
await fs.rm(tempConfigRoot, { recursive: true, force: true });
|
||||||
|
await fs.mkdir(tempConfigRoot, { recursive: true });
|
||||||
} else {
|
} else {
|
||||||
tempConfigRoot = path.join(tempHome, ".openclaw-test");
|
tempConfigRoot = path.join(tempHome, ".openclaw-test");
|
||||||
await fs.rm(tempConfigRoot, { recursive: true, force: true });
|
await fs.rm(tempConfigRoot, { recursive: true, force: true });
|
||||||
@@ -182,6 +187,9 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) {
|
|||||||
tempHome = undefined;
|
tempHome = undefined;
|
||||||
}
|
}
|
||||||
tempConfigRoot = undefined;
|
tempConfigRoot = undefined;
|
||||||
|
if (options.restoreEnv) {
|
||||||
|
suiteConfigRootSeq = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) {
|
export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type AnyMock = MockFn<(...args: unknown[]) => unknown>;
|
|||||||
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
|
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
|
||||||
|
|
||||||
const { sessionStorePath } = vi.hoisted(() => ({
|
const { sessionStorePath } = vi.hoisted(() => ({
|
||||||
sessionStorePath: `/tmp/openclaw-telegram-${Math.random().toString(16).slice(2)}.json`,
|
sessionStorePath: `/tmp/openclaw-telegram-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}.json`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { loadWebMedia } = vi.hoisted((): { loadWebMedia: AnyMock } => ({
|
const { loadWebMedia } = vi.hoisted((): { loadWebMedia: AnyMock } => ({
|
||||||
@@ -212,6 +212,17 @@ export const getOnHandler = (event: string) => {
|
|||||||
return handler as (ctx: Record<string, unknown>) => Promise<void>;
|
return handler as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TELEGRAM_TEST_CONFIG: OpenClawConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "utc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function makeTelegramMessageCtx(params: {
|
export function makeTelegramMessageCtx(params: {
|
||||||
chat: {
|
chat: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -265,16 +276,7 @@ export function makeForumGroupMessageCtx(params?: {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReset();
|
loadConfig.mockReset();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG);
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
envelopeTimezone: "utc",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
loadWebMedia.mockReset();
|
loadWebMedia.mockReset();
|
||||||
readChannelAllowFromStore.mockReset();
|
readChannelAllowFromStore.mockReset();
|
||||||
readChannelAllowFromStore.mockResolvedValue([]);
|
readChannelAllowFromStore.mockResolvedValue([]);
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ type EnvSnapshot = {
|
|||||||
stateDir: string | undefined;
|
stateDir: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SharedHomeRootState = {
|
||||||
|
rootPromise: Promise<string>;
|
||||||
|
nextCaseId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHARED_HOME_ROOTS = new Map<string, SharedHomeRootState>();
|
||||||
|
|
||||||
function snapshotEnv(): EnvSnapshot {
|
function snapshotEnv(): EnvSnapshot {
|
||||||
return {
|
return {
|
||||||
home: process.env.HOME,
|
home: process.env.HOME,
|
||||||
@@ -76,11 +83,27 @@ function setTempHome(base: string) {
|
|||||||
process.env.HOMEPATH = match[2] || "\\";
|
process.env.HOMEPATH = match[2] || "\\";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function allocateTempHomeBase(prefix: string): Promise<string> {
|
||||||
|
let state = SHARED_HOME_ROOTS.get(prefix);
|
||||||
|
if (!state) {
|
||||||
|
state = {
|
||||||
|
rootPromise: fs.mkdtemp(path.join(os.tmpdir(), prefix)),
|
||||||
|
nextCaseId: 0,
|
||||||
|
};
|
||||||
|
SHARED_HOME_ROOTS.set(prefix, state);
|
||||||
|
}
|
||||||
|
const root = await state.rootPromise;
|
||||||
|
const base = path.join(root, `case-${state.nextCaseId++}`);
|
||||||
|
await fs.mkdir(base, { recursive: true });
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
export async function withTempHome<T>(
|
export async function withTempHome<T>(
|
||||||
fn: (home: string) => Promise<T>,
|
fn: (home: string) => Promise<T>,
|
||||||
opts: { env?: Record<string, EnvValue>; prefix?: string } = {},
|
opts: { env?: Record<string, EnvValue>; prefix?: string } = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), opts.prefix ?? "openclaw-test-home-"));
|
const prefix = opts.prefix ?? "openclaw-test-home-";
|
||||||
|
const base = await allocateTempHomeBase(prefix);
|
||||||
const snapshot = snapshotEnv();
|
const snapshot = snapshotEnv();
|
||||||
const envKeys = Object.keys(opts.env ?? {});
|
const envKeys = Object.keys(opts.env ?? {});
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
|
|||||||
Reference in New Issue
Block a user