426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
/**
|
|
* Live e2e driving the SelfControl website/app blocker through chat against a spawned
|
|
* runtime and API. Gated on ELIZA_LIVE_TEST.
|
|
*/
|
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { describeIf } from "../../../packages/app-core/test/helpers/conditional-tests.ts";
|
|
import {
|
|
createConversation,
|
|
postConversationMessage,
|
|
req,
|
|
} from "../../../packages/app-core/test/helpers/http";
|
|
import { createLiveRuntimeChildEnv } from "../../../packages/app-core/test/helpers/live-child-env.ts";
|
|
import { selectLiveProvider } from "../../../packages/app-core/test/helpers/live-provider.ts";
|
|
import { stochasticTest } from "../../../packages/app-core/test/helpers/stochastic-test";
|
|
|
|
const LIVE_TESTS_ENABLED = process.env.ELIZA_LIVE_TEST === "1";
|
|
const REPO_ROOT = path.resolve(import.meta.dirname, "..", "..", "..", "..");
|
|
const ENV_PATH = path.join(REPO_ROOT, ".env");
|
|
|
|
try {
|
|
const { config } = await import("dotenv");
|
|
config({ path: ENV_PATH });
|
|
} catch {
|
|
// dotenv is optional in this test environment.
|
|
}
|
|
|
|
const selectedLiveProvider = selectLiveProvider();
|
|
const selectedLiveProviderPlugin = selectedLiveProvider?.pluginPackage ?? null;
|
|
const liveSelfcontrolChatEnabled =
|
|
LIVE_TESTS_ENABLED &&
|
|
Boolean(selectedLiveProvider) &&
|
|
Boolean(selectedLiveProviderPlugin);
|
|
|
|
type StartedRuntime = {
|
|
close: () => Promise<void>;
|
|
getLogTail: () => string;
|
|
hostsFilePath: string;
|
|
port: number;
|
|
};
|
|
|
|
async function getFreePort(): Promise<number> {
|
|
return await new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close();
|
|
reject(new Error("Could not allocate a loopback port"));
|
|
return;
|
|
}
|
|
|
|
server.close((error) => {
|
|
if (error) reject(error);
|
|
else resolve(address.port);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForChildExit(
|
|
child: ChildProcessWithoutNullStreams,
|
|
timeoutMs: number,
|
|
): Promise<boolean> {
|
|
if (child.exitCode != null) {
|
|
return true;
|
|
}
|
|
|
|
return await new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
cleanup();
|
|
resolve(false);
|
|
}, timeoutMs);
|
|
|
|
const handleExit = () => {
|
|
cleanup();
|
|
resolve(true);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timeout);
|
|
child.off("exit", handleExit);
|
|
child.off("close", handleExit);
|
|
};
|
|
|
|
child.once("exit", handleExit);
|
|
child.once("close", handleExit);
|
|
});
|
|
}
|
|
|
|
async function waitForJsonPredicate<T>(
|
|
url: string,
|
|
predicate: (value: T) => boolean,
|
|
timeoutMs: number = 150_000,
|
|
): Promise<T> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError: unknown = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Request failed (${response.status}): ${url}`);
|
|
}
|
|
|
|
const data = (await response.json()) as T;
|
|
if (predicate(data)) {
|
|
return data;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
|
|
await sleep(1_000);
|
|
}
|
|
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error(`Timed out waiting for ${url}`);
|
|
}
|
|
|
|
async function waitForHostsBlock(
|
|
hostsFilePath: string,
|
|
websites: string[],
|
|
timeoutMs: number = 60_000,
|
|
): Promise<string> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const hosts = await readFile(hostsFilePath, "utf8");
|
|
if (websites.every((website) => hosts.includes(website))) {
|
|
return hosts;
|
|
}
|
|
await sleep(1_000);
|
|
}
|
|
|
|
throw new Error(
|
|
`Timed out waiting for hosts file block: ${websites.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
async function waitForWebsiteBlockStatus(
|
|
runtime: StartedRuntime,
|
|
websites: string[],
|
|
timeoutMs: number = 60_000,
|
|
): Promise<Record<string, unknown>> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastStatus: unknown = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
const response = await req(runtime.port, "GET", "/api/website-blocker");
|
|
lastStatus = response.data;
|
|
const data =
|
|
response.data && typeof response.data === "object"
|
|
? (response.data as {
|
|
active?: unknown;
|
|
websites?: unknown;
|
|
})
|
|
: null;
|
|
const active = data?.active === true;
|
|
const blockedWebsites = Array.isArray(data?.websites)
|
|
? data.websites.filter(
|
|
(website): website is string => typeof website === "string",
|
|
)
|
|
: [];
|
|
|
|
if (
|
|
active &&
|
|
websites.every((website) => blockedWebsites.includes(website))
|
|
) {
|
|
return response.data as Record<string, unknown>;
|
|
}
|
|
|
|
await sleep(1_000);
|
|
}
|
|
|
|
throw new Error(
|
|
`Timed out waiting for website blocker status: ${websites.join(", ")}\nstatus=${JSON.stringify(lastStatus)}\n${runtime.getLogTail()}`,
|
|
);
|
|
}
|
|
|
|
async function startLiveRuntime(options?: {
|
|
includeProviderPlugin?: boolean;
|
|
}): Promise<StartedRuntime> {
|
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "eliza-selfcontrol-"));
|
|
const stateDir = path.join(tempRoot, "state");
|
|
const configPath = path.join(tempRoot, "eliza.json");
|
|
const hostsFilePath = path.join(tempRoot, "hosts");
|
|
const apiPort = await getFreePort();
|
|
const logs: string[] = [];
|
|
const allowPlugins = ["selfcontrol"];
|
|
|
|
if (options?.includeProviderPlugin && selectedLiveProviderPlugin) {
|
|
allowPlugins.push(selectedLiveProviderPlugin);
|
|
}
|
|
|
|
await mkdir(stateDir, { recursive: true });
|
|
await writeFile(hostsFilePath, "127.0.0.1 localhost\n", "utf8");
|
|
await writeFile(
|
|
configPath,
|
|
`${JSON.stringify(
|
|
{
|
|
logging: { level: "info" },
|
|
plugins: {
|
|
allow: allowPlugins,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
const child = spawn("bun", ["run", "start:eliza"], {
|
|
cwd: REPO_ROOT,
|
|
env: createLiveRuntimeChildEnv({
|
|
...(selectedLiveProvider?.env ?? {}),
|
|
ELIZA_CONFIG_PATH: configPath,
|
|
ELIZA_STATE_DIR: stateDir,
|
|
ELIZA_PORT: String(apiPort),
|
|
ELIZA_API_PORT: String(apiPort),
|
|
WEBSITE_BLOCKER_HOSTS_FILE_PATH: hostsFilePath,
|
|
SELFCONTROL_HOSTS_FILE_PATH: hostsFilePath,
|
|
ELIZA_DISABLE_LOCAL_EMBEDDINGS: "1",
|
|
ALLOW_NO_DATABASE: "",
|
|
DISCORD_API_TOKEN: "",
|
|
DISCORD_BOT_TOKEN: "",
|
|
TELEGRAM_BOT_TOKEN: "",
|
|
}),
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => logs.push(chunk));
|
|
child.stderr.on("data", (chunk: string) => logs.push(chunk));
|
|
|
|
try {
|
|
await waitForJsonPredicate<{ ready?: boolean; runtime?: string }>(
|
|
`http://127.0.0.1:${apiPort}/api/health`,
|
|
(value) => value.ready === true && value.runtime === "ok",
|
|
);
|
|
} catch (error) {
|
|
const logTail = logs.join("").slice(-8_000);
|
|
if (child.exitCode == null) {
|
|
child.kill("SIGKILL");
|
|
await waitForChildExit(child, 5_000);
|
|
}
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
throw new Error(
|
|
`Live runtime failed to start: ${error instanceof Error ? error.message : String(error)}\n${logTail}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
getLogTail: () => logs.join("").slice(-8_000),
|
|
hostsFilePath,
|
|
port: apiPort,
|
|
close: async () => {
|
|
if (child.exitCode == null) {
|
|
child.kill("SIGTERM");
|
|
const exited = await waitForChildExit(child, 10_000);
|
|
if (!exited && child.exitCode == null) {
|
|
child.kill("SIGKILL");
|
|
await waitForChildExit(child, 5_000);
|
|
}
|
|
}
|
|
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runChatContextWebsiteBlockFlow(
|
|
runtime: StartedRuntime,
|
|
): Promise<void> {
|
|
const pluginsResponse = await req(runtime.port, "GET", "/api/plugins");
|
|
expect(pluginsResponse.status).toBe(200);
|
|
|
|
const { conversationId } = await createConversation(runtime.port, {
|
|
title: "Live SelfControl",
|
|
});
|
|
|
|
const firstTurn = await postConversationMessage(
|
|
runtime.port,
|
|
conversationId,
|
|
{
|
|
text: "The websites distracting me are x.com and twitter.com. Do not block them yet.",
|
|
},
|
|
);
|
|
expect(firstTurn.status).toBe(200);
|
|
assertNoProviderIssue(
|
|
"first turn",
|
|
String(firstTurn.data.text ?? ""),
|
|
runtime,
|
|
);
|
|
expect(await readFile(runtime.hostsFilePath, "utf8")).toBe(
|
|
"127.0.0.1 localhost\n",
|
|
);
|
|
const firstTurnStatus = await req(
|
|
runtime.port,
|
|
"GET",
|
|
"/api/website-blocker",
|
|
);
|
|
expect(firstTurnStatus.status).toBe(200);
|
|
expect(firstTurnStatus.data).toMatchObject({
|
|
active: false,
|
|
websites: [],
|
|
});
|
|
|
|
const secondTurn = await postConversationMessage(
|
|
runtime.port,
|
|
conversationId,
|
|
{
|
|
text: "Use self control now. Actually block the websites for 1 minute instead of giving advice.",
|
|
},
|
|
);
|
|
expect(secondTurn.status).toBe(200);
|
|
|
|
const secondText = String(secondTurn.data.text ?? "");
|
|
assertNoProviderIssue("second turn", secondText, runtime);
|
|
expect(secondText).not.toMatch(
|
|
/Provide at least one public website hostname/i,
|
|
);
|
|
|
|
const status = await waitForWebsiteBlockStatus(runtime, [
|
|
"x.com",
|
|
"twitter.com",
|
|
]);
|
|
expect(status).toMatchObject({
|
|
active: true,
|
|
websites: expect.arrayContaining(["x.com", "twitter.com"]),
|
|
});
|
|
const hosts = await waitForHostsBlock(runtime.hostsFilePath, [
|
|
"x.com",
|
|
"twitter.com",
|
|
"api.x.com",
|
|
]);
|
|
expect(hosts).toContain("x.com");
|
|
expect(hosts).toContain("twitter.com");
|
|
expect(hosts).toContain("api.x.com");
|
|
}
|
|
|
|
function assertNoProviderIssue(
|
|
turnName: string,
|
|
text: string,
|
|
runtime: StartedRuntime,
|
|
): void {
|
|
if (!/provider issue/i.test(text)) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
`${turnName} returned a provider issue reply.\n${runtime.getLogTail()}`,
|
|
);
|
|
}
|
|
|
|
describeIf(LIVE_TESTS_ENABLED)("Live: website blocker API roundtrip", () => {
|
|
let runtime: StartedRuntime | undefined;
|
|
|
|
beforeAll(async () => {
|
|
runtime = await startLiveRuntime({
|
|
includeProviderPlugin: Boolean(selectedLiveProviderPlugin),
|
|
});
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
if (runtime) {
|
|
await runtime.close();
|
|
}
|
|
});
|
|
|
|
it("blocks and unblocks websites through the real runtime API", async () => {
|
|
const startResponse = await req(
|
|
runtime.port,
|
|
"PUT",
|
|
"/api/website-blocker",
|
|
{
|
|
websites: ["x.com", "twitter.com"],
|
|
durationMinutes: 1,
|
|
},
|
|
);
|
|
expect(startResponse.status).toBe(200);
|
|
expect(startResponse.data).toMatchObject({
|
|
success: true,
|
|
request: {
|
|
websites: ["x.com", "twitter.com"],
|
|
durationMinutes: 1,
|
|
},
|
|
});
|
|
|
|
// Startup smoke should verify the runtime contract only. Dedicated dev
|
|
// and service tests already cover the concrete hosts-file mutation path.
|
|
const statusResponse = await waitForWebsiteBlockStatus(runtime, [
|
|
"x.com",
|
|
"twitter.com",
|
|
]);
|
|
expect(statusResponse).toMatchObject({
|
|
active: true,
|
|
engine: "hosts-file",
|
|
requiresElevation: false,
|
|
websites: ["x.com", "twitter.com"],
|
|
});
|
|
|
|
const stopResponse = await req(
|
|
runtime.port,
|
|
"DELETE",
|
|
"/api/website-blocker",
|
|
);
|
|
expect(stopResponse.status).toBe(200);
|
|
expect(stopResponse.data).toMatchObject({
|
|
success: true,
|
|
status: {
|
|
active: false,
|
|
},
|
|
});
|
|
expect(stopResponse.data.removed).toEqual(expect.any(Boolean));
|
|
}, 180_000);
|
|
});
|
|
|
|
describeIf(liveSelfcontrolChatEnabled)(
|
|
"Live: website blocker chat roundtrip",
|
|
() => {
|
|
describe("strict single-attempt", () => {
|
|
it("uses prior chat context to block websites through the real runtime on the first attempt", async () => {
|
|
const runtime = await startLiveRuntime({
|
|
includeProviderPlugin: true,
|
|
});
|
|
try {
|
|
await runChatContextWebsiteBlockFlow(runtime);
|
|
} finally {
|
|
await runtime.close();
|
|
}
|
|
}, 180_000);
|
|
});
|
|
|
|
describe("stability coverage", () => {
|
|
stochasticTest(
|
|
"uses prior chat context to block websites through the real runtime",
|
|
async () => {
|
|
const runtime = await startLiveRuntime({
|
|
includeProviderPlugin: true,
|
|
});
|
|
try {
|
|
await runChatContextWebsiteBlockFlow(runtime);
|
|
} finally {
|
|
await runtime.close();
|
|
}
|
|
},
|
|
{
|
|
perRunTimeoutMs: 180_000,
|
|
label: "selfcontrol-chat/block-after-context",
|
|
},
|
|
);
|
|
});
|
|
},
|
|
);
|