mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-10 08:41:13 +00:00
fix: stabilize live and docker test lanes
This commit is contained in:
@@ -14,7 +14,7 @@ RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt
|
||||
git
|
||||
|
||||
WORKDIR /repo
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY packages ./packages
|
||||
COPY extensions ./extensions
|
||||
|
||||
@@ -27,7 +27,7 @@ COPY --chown=appuser:appuser scripts/postinstall-bundled-plugins.mjs scripts/npm
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts vitest.performance-config.ts openclaw.mjs ./
|
||||
COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts vitest.performance-config.ts vitest.shared.config.ts vitest.bundled-plugin-paths.ts openclaw.mjs ./
|
||||
COPY --chown=appuser:appuser src ./src
|
||||
COPY --chown=appuser:appuser test ./test
|
||||
COPY --chown=appuser:appuser scripts ./scripts
|
||||
|
||||
@@ -45,7 +45,9 @@ start_gateway() {
|
||||
gateway_pid=$!
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if grep -q "listening on ws://" "$log_file"; then
|
||||
# Gateway startup logs changed; accept both the legacy listener line and the
|
||||
# current structured ready line so this smoke stays stable across formats.
|
||||
if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$gateway_pid" 2>/dev/null; then
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
} from "./bundle-mcp.test-harness.js";
|
||||
import { runCliAgent } from "./cli-runner.js";
|
||||
|
||||
const E2E_TIMEOUT_MS = 20_000;
|
||||
// This e2e spins a real stdio MCP server plus a spawned CLI process, which is
|
||||
// notably slower under Docker and cold Vitest imports.
|
||||
const E2E_TIMEOUT_MS = 40_000;
|
||||
|
||||
describe("runCliAgent bundle MCP e2e", () => {
|
||||
it(
|
||||
|
||||
@@ -1167,6 +1167,8 @@ describe("createOpenAIWebSocketStreamFn", () => {
|
||||
releaseWsSession("sess-store-default");
|
||||
releaseWsSession("sess-store-compat");
|
||||
releaseWsSession("sess-max-tokens-zero");
|
||||
releaseWsSession("sess-runtime-fallback");
|
||||
releaseWsSession("sess-drop");
|
||||
openAIWsStreamTesting.setDepsForTest();
|
||||
});
|
||||
|
||||
@@ -1424,6 +1426,35 @@ describe("createOpenAIWebSocketStreamFn", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to HTTP when WebSocket errors before any output in auto mode", async () => {
|
||||
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-runtime-fallback");
|
||||
const stream = streamFn(
|
||||
modelStub as Parameters<typeof streamFn>[0],
|
||||
contextStub as Parameters<typeof streamFn>[1],
|
||||
{ transport: "auto" } as Parameters<typeof streamFn>[2],
|
||||
);
|
||||
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const manager = MockManager.lastInstance!;
|
||||
manager.simulateEvent({
|
||||
type: "error",
|
||||
message: "temporary upstream glitch",
|
||||
code: "ws_runtime_error",
|
||||
});
|
||||
|
||||
const events: Array<{ type?: string; message?: { content?: Array<{ text?: string }> } }> = [];
|
||||
for await (const ev of await resolveStream(stream)) {
|
||||
events.push(ev as { type?: string; message?: { content?: Array<{ text?: string }> } });
|
||||
}
|
||||
|
||||
expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(manager.closeCallCount).toBeGreaterThanOrEqual(1);
|
||||
expect(events.filter((event) => event.type === "start")).toHaveLength(1);
|
||||
expect(events.some((event) => event.type === "error")).toBe(false);
|
||||
const doneEvent = events.find((event) => event.type === "done");
|
||||
expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response");
|
||||
});
|
||||
|
||||
it("tracks previous_response_id across turns (incremental send)", async () => {
|
||||
const sessionId = "sess-incremental";
|
||||
const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId);
|
||||
@@ -1924,12 +1955,12 @@ describe("createOpenAIWebSocketStreamFn", () => {
|
||||
expect(sent.tool_choice).toBe("auto");
|
||||
});
|
||||
|
||||
it("rejects promise when WebSocket drops mid-request", async () => {
|
||||
it("keeps explicit websocket mode surfacing mid-request drops", async () => {
|
||||
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-drop");
|
||||
const stream = streamFn(
|
||||
modelStub as Parameters<typeof streamFn>[0],
|
||||
contextStub as Parameters<typeof streamFn>[1],
|
||||
{} as Parameters<typeof streamFn>[2],
|
||||
{ transport: "websocket" } as Parameters<typeof streamFn>[2],
|
||||
);
|
||||
// Let the send go through, then simulate connection drop before response.completed
|
||||
await new Promise<void>((resolve) => {
|
||||
|
||||
@@ -222,6 +222,15 @@ function resolveWsWarmup(options: Parameters<StreamFn>[2]): boolean {
|
||||
return warmup === true;
|
||||
}
|
||||
|
||||
function resetWsSession(params: { sessionId: string; session: WsSession }): void {
|
||||
try {
|
||||
params.session.manager.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRegistry.delete(params.sessionId);
|
||||
}
|
||||
|
||||
async function runWarmUp(params: {
|
||||
manager: OpenAIWebSocketManager;
|
||||
modelId: string;
|
||||
@@ -523,12 +532,7 @@ export function createOpenAIWebSocketStreamFn(
|
||||
);
|
||||
// Fully reset session state so the next WS turn doesn't use stale
|
||||
// previous_response_id or lastContextLength from before the failure.
|
||||
try {
|
||||
session.manager.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRegistry.delete(sessionId);
|
||||
resetWsSession({ sessionId, session });
|
||||
return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal);
|
||||
}
|
||||
|
||||
@@ -543,72 +547,101 @@ export function createOpenAIWebSocketStreamFn(
|
||||
|
||||
// ── 5. Wait for response.completed ───────────────────────────────────
|
||||
const capturedContextLength = context.messages.length;
|
||||
let sawWsOutput = false;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Honour abort signal
|
||||
const abortHandler = () => {
|
||||
cleanup();
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
// If the WebSocket drops mid-request, reject so we don't hang forever.
|
||||
const closeHandler = (code: number, reason: string) => {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(`WebSocket closed mid-request (code=${code}, reason=${reason || "unknown"})`),
|
||||
);
|
||||
};
|
||||
session.manager.on("close", closeHandler);
|
||||
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
session.manager.off("close", closeHandler);
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
const unsubscribe = session.manager.onMessage((event) => {
|
||||
if (event.type === "response.completed") {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Honour abort signal
|
||||
const abortHandler = () => {
|
||||
cleanup();
|
||||
// Update session state
|
||||
session.lastContextLength = capturedContextLength;
|
||||
// Build and emit the assistant message
|
||||
const assistantMsg = buildAssistantMessageFromResponse(event.response, {
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
});
|
||||
const reason: Extract<StopReason, "stop" | "length" | "toolUse"> =
|
||||
assistantMsg.stopReason === "toolUse" ? "toolUse" : "stop";
|
||||
eventStream.push({ type: "done", reason, message: assistantMsg });
|
||||
resolve();
|
||||
} else if (event.type === "response.failed") {
|
||||
cleanup();
|
||||
const errMsg = event.response?.error?.message ?? "Response failed";
|
||||
reject(new Error(`OpenAI WebSocket response failed: ${errMsg}`));
|
||||
} else if (event.type === "error") {
|
||||
cleanup();
|
||||
reject(new Error(`OpenAI WebSocket error: ${event.message} (code=${event.code})`));
|
||||
} else if (event.type === "response.output_text.delta") {
|
||||
// Stream partial text updates for responsive UI
|
||||
const partialMsg: AssistantMessage = buildAssistantMessageWithZeroUsage({
|
||||
model,
|
||||
content: [{ type: "text", text: event.delta }],
|
||||
stopReason: "stop",
|
||||
});
|
||||
eventStream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: event.delta,
|
||||
partial: partialMsg,
|
||||
});
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
// If the WebSocket drops mid-request, reject so we don't hang forever.
|
||||
const closeHandler = (code: number, reason: string) => {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`WebSocket closed mid-request (code=${code}, reason=${reason || "unknown"})`,
|
||||
),
|
||||
);
|
||||
};
|
||||
session.manager.on("close", closeHandler);
|
||||
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
session.manager.off("close", closeHandler);
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
const unsubscribe = session.manager.onMessage((event) => {
|
||||
if (
|
||||
event.type === "response.output_item.added" ||
|
||||
event.type === "response.output_item.done" ||
|
||||
event.type === "response.content_part.added" ||
|
||||
event.type === "response.content_part.done" ||
|
||||
event.type === "response.output_text.delta" ||
|
||||
event.type === "response.output_text.done" ||
|
||||
event.type === "response.function_call_arguments.delta" ||
|
||||
event.type === "response.function_call_arguments.done"
|
||||
) {
|
||||
sawWsOutput = true;
|
||||
}
|
||||
|
||||
if (event.type === "response.completed") {
|
||||
cleanup();
|
||||
// Update session state
|
||||
session.lastContextLength = capturedContextLength;
|
||||
// Build and emit the assistant message
|
||||
const assistantMsg = buildAssistantMessageFromResponse(event.response, {
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
});
|
||||
const reason: Extract<StopReason, "stop" | "length" | "toolUse"> =
|
||||
assistantMsg.stopReason === "toolUse" ? "toolUse" : "stop";
|
||||
eventStream.push({ type: "done", reason, message: assistantMsg });
|
||||
resolve();
|
||||
} else if (event.type === "response.failed") {
|
||||
cleanup();
|
||||
const errMsg = event.response?.error?.message ?? "Response failed";
|
||||
reject(new Error(`OpenAI WebSocket response failed: ${errMsg}`));
|
||||
} else if (event.type === "error") {
|
||||
cleanup();
|
||||
reject(new Error(`OpenAI WebSocket error: ${event.message} (code=${event.code})`));
|
||||
} else if (event.type === "response.output_text.delta") {
|
||||
// Stream partial text updates for responsive UI
|
||||
const partialMsg: AssistantMessage = buildAssistantMessageWithZeroUsage({
|
||||
model,
|
||||
content: [{ type: "text", text: event.delta }],
|
||||
stopReason: "stop",
|
||||
});
|
||||
eventStream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: event.delta,
|
||||
partial: partialMsg,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (wsRunErr) {
|
||||
if (transport !== "websocket" && !signal?.aborted && !sawWsOutput) {
|
||||
log.warn(
|
||||
`[ws-stream] session=${sessionId} runtime failure before output; falling back to HTTP. error=${String(wsRunErr)}`,
|
||||
);
|
||||
resetWsSession({ sessionId, session });
|
||||
return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal, {
|
||||
suppressStart: true,
|
||||
});
|
||||
}
|
||||
throw wsRunErr;
|
||||
}
|
||||
};
|
||||
|
||||
queueMicrotask(() =>
|
||||
@@ -638,18 +671,22 @@ export function createOpenAIWebSocketStreamFn(
|
||||
async function fallbackToHttp(
|
||||
model: Parameters<StreamFn>[0],
|
||||
context: Parameters<StreamFn>[1],
|
||||
options: Parameters<StreamFn>[2],
|
||||
streamOptions: Parameters<StreamFn>[2],
|
||||
apiKey: string,
|
||||
eventStream: AssistantMessageEventStreamLike,
|
||||
signal?: AbortSignal,
|
||||
fallbackOptions?: { suppressStart?: boolean },
|
||||
): Promise<void> {
|
||||
const mergedOptions = {
|
||||
...options,
|
||||
...streamOptions,
|
||||
apiKey,
|
||||
...(signal ? { signal } : {}),
|
||||
};
|
||||
const httpStream = openAIWsStreamDeps.streamSimple(model, context, mergedOptions);
|
||||
for await (const event of httpStream) {
|
||||
if (fallbackOptions?.suppressStart && event.type === "start") {
|
||||
continue;
|
||||
}
|
||||
eventStream.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("docker build cache layout", () => {
|
||||
expect(
|
||||
indexOfPattern(
|
||||
dockerfile,
|
||||
/^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.\/$/m,
|
||||
/^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.npmrc \.\/$/m,
|
||||
),
|
||||
).toBeLessThan(installIndex);
|
||||
expect(
|
||||
@@ -114,7 +114,7 @@ describe("docker build cache layout", () => {
|
||||
expect(
|
||||
indexOfPattern(
|
||||
dockerfile,
|
||||
/^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts vitest\.e2e\.config\.ts vitest\.performance-config\.ts openclaw\.mjs \.\/$/m,
|
||||
/^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts vitest\.e2e\.config\.ts vitest\.performance-config\.ts vitest\.shared\.config\.ts vitest\.bundled-plugin-paths\.ts openclaw\.mjs \.\/$/m,
|
||||
),
|
||||
).toBeGreaterThan(installIndex);
|
||||
expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+src \.\/src$/m)).toBeGreaterThan(
|
||||
@@ -160,4 +160,19 @@ describe("docker build cache layout", () => {
|
||||
installIndex,
|
||||
);
|
||||
});
|
||||
|
||||
it("copies .npmrc before install in the cleanup smoke image", async () => {
|
||||
const dockerfile = await readRepoFile("scripts/docker/cleanup-smoke/Dockerfile");
|
||||
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||
|
||||
expect(
|
||||
indexOfPattern(
|
||||
dockerfile,
|
||||
/^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.npmrc \.\/$/m,
|
||||
),
|
||||
).toBeLessThan(installIndex);
|
||||
expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+\.\s+\.$/m)).toBeGreaterThan(
|
||||
installIndex,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ const GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS = new Set([
|
||||
const GATEWAY_LIVE_EXEC_READ_NONCE_MISS_SKIP_MODEL_KEYS = new Set([
|
||||
"google/gemini-3.1-flash-lite-preview",
|
||||
]);
|
||||
const GATEWAY_LIVE_TOOL_NONCE_MISS_SKIP_MODEL_KEYS = new Set(["google/gemini-3-flash-preview"]);
|
||||
const GATEWAY_LIVE_MAX_MODELS = resolveGatewayLiveMaxModels();
|
||||
const GATEWAY_LIVE_SUITE_TIMEOUT_MS = resolveGatewayLiveSuiteTimeoutMs(GATEWAY_LIVE_MAX_MODELS);
|
||||
const QUIET_LIVE_LOGS = process.env.OPENCLAW_LIVE_TEST_QUIET !== "0";
|
||||
@@ -594,28 +595,43 @@ function isPromptProbeMiss(error: string): boolean {
|
||||
return msg.includes("not meaningful:") || msg.includes("missing required keywords:");
|
||||
}
|
||||
|
||||
function shouldSkipToolNonceProbeMiss(provider: string): boolean {
|
||||
return (
|
||||
function shouldSkipToolNonceProbeMissForLiveModel(modelKey?: string): boolean {
|
||||
if (!modelKey) {
|
||||
return false;
|
||||
}
|
||||
if (GATEWAY_LIVE_TOOL_NONCE_MISS_SKIP_MODEL_KEYS.has(modelKey)) {
|
||||
return true;
|
||||
}
|
||||
const [provider, ...rest] = modelKey.split("/");
|
||||
if (
|
||||
provider === "anthropic" ||
|
||||
provider === "minimax" ||
|
||||
provider === "opencode" ||
|
||||
provider === "opencode-go" ||
|
||||
provider === "xai" ||
|
||||
provider === "zai"
|
||||
);
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (provider !== "google" || rest.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalizedKey = `${provider}/${normalizeGoogleModelId(rest.join("/"))}`;
|
||||
return GATEWAY_LIVE_TOOL_NONCE_MISS_SKIP_MODEL_KEYS.has(normalizedKey);
|
||||
}
|
||||
|
||||
describe("shouldSkipToolNonceProbeMiss", () => {
|
||||
describe("shouldSkipToolNonceProbeMissForLiveModel", () => {
|
||||
it.each([
|
||||
{ provider: "anthropic", expected: true },
|
||||
{ provider: "minimax", expected: true },
|
||||
{ provider: "opencode", expected: true },
|
||||
{ provider: "opencode-go", expected: true },
|
||||
{ provider: "xai", expected: true },
|
||||
{ provider: "zai", expected: true },
|
||||
{ provider: "openai", expected: false },
|
||||
])("returns $expected for $provider", ({ provider, expected }) => {
|
||||
expect(shouldSkipToolNonceProbeMiss(provider)).toBe(expected);
|
||||
{ modelKey: "anthropic/claude-opus-4-6", expected: true },
|
||||
{ modelKey: "minimax/minimax-m1", expected: true },
|
||||
{ modelKey: "opencode/big-pickle", expected: true },
|
||||
{ modelKey: "opencode-go/glm-5", expected: true },
|
||||
{ modelKey: "xai/grok-4.1-fast", expected: true },
|
||||
{ modelKey: "zai/glm-4.7", expected: true },
|
||||
{ modelKey: "google/gemini-3-flash-preview", expected: true },
|
||||
{ modelKey: "openai/gpt-5.2", expected: false },
|
||||
])("returns $expected for $modelKey", ({ modelKey, expected }) => {
|
||||
expect(shouldSkipToolNonceProbeMissForLiveModel(modelKey)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1724,9 +1740,9 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
logProgress(`${progressLabel}: skip (exec/read workspace isolation)`);
|
||||
break;
|
||||
}
|
||||
if (shouldSkipToolNonceProbeMiss(model.provider) && isToolNonceProbeMiss(message)) {
|
||||
if (shouldSkipToolNonceProbeMissForLiveModel(modelKey) && isToolNonceProbeMiss(message)) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (${model.provider} tool probe nonce miss)`);
|
||||
logProgress(`${progressLabel}: skip (${modelKey} tool probe nonce miss)`);
|
||||
break;
|
||||
}
|
||||
if (isMissingProfileError(message)) {
|
||||
|
||||
18
src/infra/vitest-e2e-config.test.ts
Normal file
18
src/infra/vitest-e2e-config.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUNDLED_PLUGIN_E2E_TEST_GLOB } from "../../vitest.bundled-plugin-paths.ts";
|
||||
import e2eConfig from "../../vitest.e2e.config.ts";
|
||||
|
||||
describe("e2e vitest config", () => {
|
||||
it("runs as a standalone config instead of inheriting unit projects", () => {
|
||||
expect(e2eConfig.test?.projects).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes e2e test globs and runtime setup", () => {
|
||||
expect(e2eConfig.test?.include).toEqual([
|
||||
"test/**/*.e2e.test.ts",
|
||||
"src/**/*.e2e.test.ts",
|
||||
BUNDLED_PLUGIN_E2E_TEST_GLOB,
|
||||
]);
|
||||
expect(e2eConfig.test?.setupFiles).toContain("test/setup-openclaw-runtime.ts");
|
||||
});
|
||||
});
|
||||
17
src/infra/vitest-live-config.test.ts
Normal file
17
src/infra/vitest-live-config.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "../../vitest.bundled-plugin-paths.ts";
|
||||
import liveConfig from "../../vitest.live.config.ts";
|
||||
|
||||
describe("live vitest config", () => {
|
||||
it("runs as a standalone config instead of inheriting unit projects", () => {
|
||||
expect(liveConfig.test?.projects).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes live test globs and runtime setup", () => {
|
||||
expect(liveConfig.test?.include).toEqual([
|
||||
"src/**/*.live.test.ts",
|
||||
BUNDLED_PLUGIN_LIVE_TEST_GLOB,
|
||||
]);
|
||||
expect(liveConfig.test?.setupFiles).toContain("test/setup-openclaw-runtime.ts");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js";
|
||||
import {
|
||||
expectIntegrityDriftRejected,
|
||||
@@ -10,8 +9,10 @@ import {
|
||||
} from "../test-utils/npm-spec-install-test-helpers.js";
|
||||
import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } from "./install.js";
|
||||
|
||||
const runCommandWithTimeoutMock = vi.fn();
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: vi.fn(),
|
||||
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||
}));
|
||||
|
||||
let suiteTempRoot = "";
|
||||
@@ -127,7 +128,7 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -137,7 +138,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
|
||||
const run = vi.mocked(runCommandWithTimeout);
|
||||
const run = runCommandWithTimeoutMock;
|
||||
const voiceCallArchiveBuffer = readVoiceCallArchiveBuffer("0.0.1");
|
||||
|
||||
let packTmpDir = "";
|
||||
@@ -180,7 +181,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
expect(result.npmResolution?.integrity).toBe("sha512-plugin-test");
|
||||
|
||||
expectSingleNpmPackIgnoreScriptsCall({
|
||||
calls: run.mock.calls,
|
||||
calls: run.mock.calls as Array<[unknown, unknown]>,
|
||||
expectedSpec: "@openclaw/voice-call@0.0.1",
|
||||
});
|
||||
|
||||
@@ -205,7 +206,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
});
|
||||
const archiveBuffer = fs.readFileSync(archivePath);
|
||||
|
||||
const run = vi.mocked(runCommandWithTimeout);
|
||||
const run = runCommandWithTimeoutMock;
|
||||
let packTmpDir = "";
|
||||
const packedName = "dangerous-plugin-1.0.0.tgz";
|
||||
run.mockImplementation(async (argv, opts) => {
|
||||
@@ -253,7 +254,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
expectSingleNpmPackIgnoreScriptsCall({
|
||||
calls: run.mock.calls,
|
||||
calls: run.mock.calls as Array<[unknown, unknown]>,
|
||||
expectedSpec: "dangerous-plugin@1.0.0",
|
||||
});
|
||||
expect(packTmpDir).not.toBe("");
|
||||
@@ -270,7 +271,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
});
|
||||
|
||||
it("aborts when integrity drift callback rejects the fetched artifact", async () => {
|
||||
const run = vi.mocked(runCommandWithTimeout);
|
||||
const run = runCommandWithTimeoutMock;
|
||||
mockNpmPackMetadataResult(run, {
|
||||
id: "@openclaw/voice-call@0.0.1",
|
||||
name: "@openclaw/voice-call",
|
||||
@@ -295,7 +296,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
});
|
||||
|
||||
it("classifies npm package-not-found errors with a stable error code", async () => {
|
||||
const run = vi.mocked(runCommandWithTimeout);
|
||||
const run = runCommandWithTimeoutMock;
|
||||
run.mockResolvedValue({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
@@ -326,7 +327,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
};
|
||||
|
||||
{
|
||||
const run = vi.mocked(runCommandWithTimeout);
|
||||
const run = runCommandWithTimeoutMock;
|
||||
mockNpmPackMetadataResult(run, prereleaseMetadata);
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
@@ -340,10 +341,10 @@ describe("installPluginFromNpmSpec", () => {
|
||||
}
|
||||
}
|
||||
|
||||
vi.clearAllMocks();
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
|
||||
{
|
||||
const run = vi.mocked(runCommandWithTimeout);
|
||||
const run = runCommandWithTimeoutMock;
|
||||
let packTmpDir = "";
|
||||
const packedName = "voice-call-0.0.2-beta.1.tgz";
|
||||
const voiceCallArchiveBuffer = readVoiceCallArchiveBuffer("0.0.1");
|
||||
@@ -378,7 +379,7 @@ describe("installPluginFromNpmSpec", () => {
|
||||
expect(result.npmResolution?.version).toBe("0.0.2-beta.1");
|
||||
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1");
|
||||
expectSingleNpmPackIgnoreScriptsCall({
|
||||
calls: run.mock.calls,
|
||||
calls: run.mock.calls as Array<[unknown, unknown]>,
|
||||
expectedSpec: "@openclaw/voice-call@beta",
|
||||
});
|
||||
expect(packTmpDir).not.toBe("");
|
||||
|
||||
@@ -47,6 +47,12 @@ type LaneState = {
|
||||
generation: number;
|
||||
};
|
||||
|
||||
type ActiveTaskWaiter = {
|
||||
activeTaskIds: Set<number>;
|
||||
resolve: (value: { drained: boolean }) => void;
|
||||
timeout?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
function isExpectedNonErrorLaneFailure(err: unknown): boolean {
|
||||
return err instanceof Error && err.name === "LiveSessionModelSwitchError";
|
||||
}
|
||||
@@ -61,6 +67,7 @@ function getQueueState() {
|
||||
return resolveGlobalSingleton(COMMAND_QUEUE_STATE_KEY, () => ({
|
||||
gatewayDraining: false,
|
||||
lanes: new Map<string, LaneState>(),
|
||||
activeTaskWaiters: new Set<ActiveTaskWaiter>(),
|
||||
nextTaskId: 1,
|
||||
}));
|
||||
}
|
||||
@@ -99,6 +106,38 @@ function completeTask(state: LaneState, taskId: number, taskGeneration: number):
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasPendingActiveTasks(taskIds: Set<number>): boolean {
|
||||
const queueState = getQueueState();
|
||||
for (const state of queueState.lanes.values()) {
|
||||
for (const taskId of state.activeTaskIds) {
|
||||
if (taskIds.has(taskId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveActiveTaskWaiter(waiter: ActiveTaskWaiter, result: { drained: boolean }): void {
|
||||
const queueState = getQueueState();
|
||||
if (!queueState.activeTaskWaiters.delete(waiter)) {
|
||||
return;
|
||||
}
|
||||
if (waiter.timeout) {
|
||||
clearTimeout(waiter.timeout);
|
||||
}
|
||||
waiter.resolve(result);
|
||||
}
|
||||
|
||||
function notifyActiveTaskWaiters(): void {
|
||||
const queueState = getQueueState();
|
||||
for (const waiter of Array.from(queueState.activeTaskWaiters)) {
|
||||
if (waiter.activeTaskIds.size === 0 || !hasPendingActiveTasks(waiter.activeTaskIds)) {
|
||||
resolveActiveTaskWaiter(waiter, { drained: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drainLane(lane: string) {
|
||||
const state = getLaneState(lane);
|
||||
if (state.draining) {
|
||||
@@ -136,6 +175,7 @@ function drainLane(lane: string) {
|
||||
const result = await entry.task();
|
||||
const completedCurrentGeneration = completeTask(state, taskId, taskGeneration);
|
||||
if (completedCurrentGeneration) {
|
||||
notifyActiveTaskWaiters();
|
||||
diag.debug(
|
||||
`lane task done: lane=${lane} durationMs=${Date.now() - startTime} active=${state.activeTaskIds.size} queued=${state.queue.length}`,
|
||||
);
|
||||
@@ -155,6 +195,7 @@ function drainLane(lane: string) {
|
||||
);
|
||||
}
|
||||
if (completedCurrentGeneration) {
|
||||
notifyActiveTaskWaiters();
|
||||
pump();
|
||||
}
|
||||
entry.reject(err);
|
||||
@@ -263,6 +304,9 @@ export function resetCommandQueueStateForTest(): void {
|
||||
const queueState = getQueueState();
|
||||
queueState.gatewayDraining = false;
|
||||
queueState.lanes.clear();
|
||||
for (const waiter of Array.from(queueState.activeTaskWaiters)) {
|
||||
resolveActiveTaskWaiter(waiter, { drained: true });
|
||||
}
|
||||
queueState.nextTaskId = 1;
|
||||
}
|
||||
|
||||
@@ -296,6 +340,7 @@ export function resetAllLanes(): void {
|
||||
for (const lane of lanesToDrain) {
|
||||
drainLane(lane);
|
||||
}
|
||||
notifyActiveTaskWaiters();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,9 +365,6 @@ export function getActiveTaskCount(): number {
|
||||
* already executing are waited on.
|
||||
*/
|
||||
export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolean }> {
|
||||
// Keep shutdown/drain checks responsive without busy looping.
|
||||
const POLL_INTERVAL_MS = 50;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const queueState = getQueueState();
|
||||
const activeAtStart = new Set<number>();
|
||||
for (const state of queueState.lanes.values()) {
|
||||
@@ -331,36 +373,22 @@ export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolea
|
||||
}
|
||||
}
|
||||
|
||||
if (activeAtStart.size === 0) {
|
||||
return Promise.resolve({ drained: true });
|
||||
}
|
||||
if (timeoutMs <= 0) {
|
||||
return Promise.resolve({ drained: false });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (activeAtStart.size === 0) {
|
||||
resolve({ drained: true });
|
||||
return;
|
||||
}
|
||||
|
||||
let hasPending = false;
|
||||
for (const state of queueState.lanes.values()) {
|
||||
for (const taskId of state.activeTaskIds) {
|
||||
if (activeAtStart.has(taskId)) {
|
||||
hasPending = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasPending) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPending) {
|
||||
resolve({ drained: true });
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
resolve({ drained: false });
|
||||
return;
|
||||
}
|
||||
setTimeout(check, POLL_INTERVAL_MS);
|
||||
const waiter: ActiveTaskWaiter = {
|
||||
activeTaskIds: activeAtStart,
|
||||
resolve,
|
||||
};
|
||||
check();
|
||||
waiter.timeout = setTimeout(() => {
|
||||
resolveActiveTaskWaiter(waiter, { drained: false });
|
||||
}, timeoutMs);
|
||||
queueState.activeTaskWaiters.add(waiter);
|
||||
notifyActiveTaskWaiters();
|
||||
});
|
||||
}
|
||||
|
||||
5
vitest.bundled-plugin-paths.ts
Normal file
5
vitest.bundled-plugin-paths.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const BUNDLED_PLUGIN_ROOT_DIR = "extensions";
|
||||
export const BUNDLED_PLUGIN_PATH_PREFIX = `${BUNDLED_PLUGIN_ROOT_DIR}/`;
|
||||
export const BUNDLED_PLUGIN_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.test.ts`;
|
||||
export const BUNDLED_PLUGIN_E2E_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.e2e.test.ts`;
|
||||
export const BUNDLED_PLUGIN_LIVE_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.live.test.ts`;
|
||||
@@ -1,6 +1,6 @@
|
||||
import os from "node:os";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { BUNDLED_PLUGIN_E2E_TEST_GLOB } from "./scripts/lib/bundled-plugin-paths.mjs";
|
||||
import { BUNDLED_PLUGIN_E2E_TEST_GLOB } from "./vitest.bundled-plugin-paths.ts";
|
||||
import baseConfig from "./vitest.config.ts";
|
||||
|
||||
const base = baseConfig as unknown as Record<string, unknown>;
|
||||
@@ -15,8 +15,14 @@ const e2eWorkers =
|
||||
: defaultWorkers;
|
||||
const verboseE2E = process.env.OPENCLAW_E2E_VERBOSE === "1";
|
||||
|
||||
const baseTest =
|
||||
(baseConfig as { test?: { exclude?: string[]; setupFiles?: string[] } }).test ?? {};
|
||||
const baseTestWithProjects =
|
||||
(baseConfig as { test?: { exclude?: string[]; projects?: string[]; setupFiles?: string[] } })
|
||||
.test ?? {};
|
||||
const { projects: _projects, ...baseTest } = baseTestWithProjects as {
|
||||
exclude?: string[];
|
||||
projects?: string[];
|
||||
setupFiles?: string[];
|
||||
};
|
||||
const exclude = (baseTest.exclude ?? []).filter((p) => p !== "**/*.e2e.test.ts");
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BUNDLED_PLUGIN_TEST_GLOB } from "./scripts/lib/bundled-plugin-paths.mjs";
|
||||
import { BUNDLED_PLUGIN_TEST_GLOB } from "./vitest.bundled-plugin-paths.ts";
|
||||
import { extensionExcludedChannelTestGlobs } from "./vitest.channel-paths.mjs";
|
||||
import { loadPatternListFromEnv } from "./vitest.pattern-file.ts";
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "./scripts/lib/bundled-plugin-paths.mjs";
|
||||
import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "./vitest.bundled-plugin-paths.ts";
|
||||
import baseConfig from "./vitest.config.ts";
|
||||
|
||||
const base = baseConfig as unknown as Record<string, unknown>;
|
||||
const baseTest =
|
||||
const baseTestWithProjects =
|
||||
(baseConfig as { test?: { exclude?: string[]; setupFiles?: string[] } }).test ?? {};
|
||||
const { projects: _projects, ...baseTest } = baseTestWithProjects as {
|
||||
exclude?: string[];
|
||||
projects?: string[];
|
||||
setupFiles?: string[];
|
||||
};
|
||||
const exclude = (baseTest.exclude ?? []).filter((p) => p !== "**/*.live.test.ts");
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pluginSdkSubpaths } from "./scripts/lib/plugin-sdk-entries.mjs";
|
||||
import {
|
||||
BUNDLED_PLUGIN_ROOT_DIR,
|
||||
BUNDLED_PLUGIN_TEST_GLOB,
|
||||
} from "./scripts/lib/bundled-plugin-paths.mjs";
|
||||
import { pluginSdkSubpaths } from "./scripts/lib/plugin-sdk-entries.mjs";
|
||||
} from "./vitest.bundled-plugin-paths.ts";
|
||||
import { loadVitestExperimentalConfig } from "./vitest.performance-config.ts";
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
Reference in New Issue
Block a user