mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(release): repair beta.2 plugin and package validation
Repair the beta.2 validation candidate at its owning boundaries: - fail closed on conflicting approval channels and keep transient Slack member events retryable; - tolerate sparse assistant usage history and align QA/release-only evidence with current runtime contracts; - remove package/Docker harness dependency leaks and assert the installed `tsx` launcher path. Verified by exact-head CI, focused QA/plugin/package Testboxes, full release preflight, clean Codex autoreview, and exact-head ClawSweeper review with no actionable findings or security concerns.
This commit is contained in:
committed by
GitHub
parent
a652994a7c
commit
0a848362fd
@@ -1,6 +1,6 @@
|
||||
// Browser tests cover browser cli inspect plugin behavior.
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCliRuntimeCapture } from "../../test-support.js";
|
||||
import * as browserCliSharedModule from "./browser-cli-shared.js";
|
||||
import * as cliCoreApiModule from "./core-api.js";
|
||||
@@ -58,15 +58,6 @@ const sharedMocks = vi.hoisted(() => ({
|
||||
},
|
||||
),
|
||||
}));
|
||||
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(
|
||||
sharedMocks.callBrowserRequest,
|
||||
);
|
||||
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockImplementation(configMocks.loadConfig);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(runtime.writeJson);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(runtime.error);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(runtime.exit);
|
||||
|
||||
let registerBrowserInspectCommands: typeof import("./browser-cli-inspect.js").registerBrowserInspectCommands;
|
||||
|
||||
type SnapshotDefaultsCase = {
|
||||
@@ -75,11 +66,22 @@ type SnapshotDefaultsCase = {
|
||||
expectMode: "efficient" | undefined;
|
||||
};
|
||||
|
||||
function installInspectSpies() {
|
||||
vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation(
|
||||
sharedMocks.callBrowserRequest,
|
||||
);
|
||||
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockImplementation(configMocks.loadConfig);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(runtime.writeJson);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(runtime.error);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(runtime.exit);
|
||||
}
|
||||
|
||||
describe("browser cli snapshot defaults", () => {
|
||||
const runBrowserInspect = async (args: string[], withJson = false) => {
|
||||
const program = new Command();
|
||||
const browser = program.command("browser").option("--json", "JSON output", false);
|
||||
registerBrowserInspectCommands(browser, () => ({}));
|
||||
registerBrowserInspectCommands(browser, (cmd) => cmd.parent?.opts() ?? {});
|
||||
await program.parseAsync(withJson ? ["browser", "--json", ...args] : ["browser", ...args], {
|
||||
from: "user",
|
||||
});
|
||||
@@ -91,11 +93,17 @@ describe("browser cli snapshot defaults", () => {
|
||||
const runSnapshot = async (args: string[]) => await runBrowserInspect(["snapshot", ...args]);
|
||||
|
||||
beforeAll(async () => {
|
||||
installInspectSpies();
|
||||
({ registerBrowserInspectCommands } = await import("./browser-cli-inspect.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
installInspectSpies();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
resetRuntimeCapture();
|
||||
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||
});
|
||||
|
||||
@@ -180,6 +180,7 @@ describe("iMessage approval reaction poller", () => {
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
channel: "imessage",
|
||||
accountId,
|
||||
senderId: "+15551230000",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
@@ -412,6 +413,7 @@ describe("iMessage approval reaction poller", () => {
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
channel: "imessage",
|
||||
accountId,
|
||||
senderId: "+15551239999",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
@@ -461,6 +463,7 @@ describe("iMessage approval reaction poller", () => {
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
channel: "imessage",
|
||||
accountId,
|
||||
senderId: "+15551230000",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
@@ -568,6 +571,7 @@ describe("iMessage approval reaction poller", () => {
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
channel: "imessage",
|
||||
accountId,
|
||||
senderId: "+15551230000",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
|
||||
@@ -130,11 +130,14 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
source: "oc://openclaw.config/session/maintenance/mode",
|
||||
value: "warn",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
{
|
||||
id: "agents-defaults-memory-session-transcripts",
|
||||
kind: "memorySessionTranscriptIndexing",
|
||||
scope: "global",
|
||||
source: "oc://openclaw.config/memory/search/rememberAcrossConversations",
|
||||
value: true,
|
||||
}),
|
||||
value: false,
|
||||
explicit: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.findings).toEqual(
|
||||
@@ -149,11 +152,6 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
ocPath: "oc://openclaw.config/session/maintenance/mode",
|
||||
requirement: "oc://policy.jsonc/dataHandling/retention/requireSessionMaintenance",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
checkId: "policy/data-handling-session-transcript-memory-enabled",
|
||||
ocPath: "oc://openclaw.config/memory/search/rememberAcrossConversations",
|
||||
requirement: "oc://policy.jsonc/dataHandling/memory/denySessionTranscriptIndexing",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ function normalizeModelRef(raw: string) {
|
||||
async function runToolContinuity(
|
||||
alternateTools: string[],
|
||||
params?: {
|
||||
alternateWireToolName?: "read" | "exec";
|
||||
primaryOutboundText?: string;
|
||||
primaryDelivery?: { status: string; resultCount: number } | null;
|
||||
alternateReplyText?: string;
|
||||
@@ -34,8 +35,14 @@ async function runToolContinuity(
|
||||
) {
|
||||
const state = createQaBusState();
|
||||
let call = 0;
|
||||
const requests: Array<{
|
||||
cursor: number;
|
||||
allInputText: string;
|
||||
plannedToolName: string;
|
||||
plannedWireToolName: string;
|
||||
}> = [];
|
||||
const runAgentPrompt = vi.fn(
|
||||
async (_env: unknown, prompt: { provider?: string; model?: string }) => {
|
||||
async (_env: unknown, prompt: { provider?: string; model?: string; message: string }) => {
|
||||
call += 1;
|
||||
const runId = `run-${call}`;
|
||||
const provider = prompt.provider ?? "openai";
|
||||
@@ -68,6 +75,13 @@ async function runToolContinuity(
|
||||
});
|
||||
}
|
||||
const terminalDelivery = call === 1 ? params?.primaryDelivery : params?.alternateDelivery;
|
||||
const wireToolName = call === 1 ? "read" : (params?.alternateWireToolName ?? "read");
|
||||
requests.push({
|
||||
cursor: call,
|
||||
allInputText: prompt.message,
|
||||
plannedToolName: "read",
|
||||
plannedWireToolName: wireToolName,
|
||||
});
|
||||
return {
|
||||
started: { runId },
|
||||
waited: {
|
||||
@@ -84,7 +98,7 @@ async function runToolContinuity(
|
||||
turnId: `turn-${call}`,
|
||||
requested: { provider, model },
|
||||
effective: { provider, model, responseModel: model },
|
||||
successfulToolNames: call === 1 ? ["read"] : alternateTools,
|
||||
successfulToolNames: call === 1 ? [wireToolName] : alternateTools,
|
||||
rerouted: false,
|
||||
terminalDisposition: "visible",
|
||||
},
|
||||
@@ -99,8 +113,15 @@ async function runToolContinuity(
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "openai/primary-model",
|
||||
alternateModel: "OPENAI/alternate-alias",
|
||||
mock: { baseUrl: "http://mock.test" },
|
||||
gateway: {},
|
||||
},
|
||||
fetchJson: async (url: string) =>
|
||||
url.includes("request-cursor")
|
||||
? { cursor: call }
|
||||
: requests.filter(
|
||||
(request) => request.cursor > Number(url.match(/after=(\d+)/)?.[1] ?? -1),
|
||||
),
|
||||
splitModelRef,
|
||||
normalizeModelRef,
|
||||
normalizeLowercaseStringOrEmpty: (value: unknown) =>
|
||||
@@ -135,12 +156,26 @@ describe("model-switch tool continuity terminal evidence", () => {
|
||||
text: "the **model handoff** preserved the QA mission after rereading the scenario pack",
|
||||
},
|
||||
terminalDelivery: { status: "sent", resultCount: 1 },
|
||||
alternateTool: { logical: "read", wire: "read" },
|
||||
});
|
||||
expect(result.steps[0]?.details).toBe(
|
||||
"the **model handoff** preserved the QA mission after rereading the scenario pack",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts Code Mode exec receipts for logically planned reads", async () => {
|
||||
const { result } = await runToolContinuity(["exec"], {
|
||||
alternateWireToolName: "exec",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.modelSwitchEvidence).toMatchObject({
|
||||
primary: { successfulToolNames: ["read"] },
|
||||
alternate: { successfulToolNames: ["exec"] },
|
||||
alternateTool: { logical: "read", wire: "exec" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a successful prior-run read satisfy the alternate run", async () => {
|
||||
await expect(runToolContinuity([])).rejects.toThrow(
|
||||
"alternate-model run did not return exact owned successful read evidence",
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("qa scenario catalog", () => {
|
||||
expect(marked.map((scenario) => scenario.id).toSorted()).toEqual(expected);
|
||||
const ssh = readQaScenarioById("gateway-ssh-tunnels");
|
||||
expect(ssh.execution).toMatchObject({ kind: "script", parallelSafe: true });
|
||||
expect(ssh.execution).not.toHaveProperty("allowBlockedEvidence");
|
||||
expect(ssh.execution).toMatchObject({ allowBlockedEvidence: true });
|
||||
});
|
||||
|
||||
it("rejects invalid provider metadata at the catalog boundary", () => {
|
||||
|
||||
@@ -66,6 +66,7 @@ export function registerSlackMemberEvents(params: {
|
||||
ctx.runtime.error?.(
|
||||
danger(`slack ${paramsLocal.verb} handler failed: ${formatErrorMessage(err)}`),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
} from "../monitor.test-helpers.js";
|
||||
import { formatSlackChannelResolved, formatSlackUserResolved } from "./provider-support.js";
|
||||
|
||||
const webClientMocks = vi.hoisted(() => ({ constructed: vi.fn() }));
|
||||
|
||||
const { monitorSlackProvider } = await import("./provider.js");
|
||||
const slackTestState = getSlackTestState();
|
||||
|
||||
beforeEach(() => {
|
||||
resetSlackTestState();
|
||||
webClientMocks.constructed.mockClear();
|
||||
});
|
||||
|
||||
function createRuntimeContextCapture(): {
|
||||
@@ -103,6 +106,23 @@ describe("slack allowlist log formatting", () => {
|
||||
|
||||
describe("slack startup user allowlist resolution", () => {
|
||||
it("registers one native approval client per Enterprise Grid team", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("../client.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client.js")>();
|
||||
const webApi = await vi.importActual<typeof import("@slack/web-api")>("@slack/web-api");
|
||||
return {
|
||||
...actual,
|
||||
createSlackStartupAuthClient: () => getSlackClient(),
|
||||
createSlackWebClient: (
|
||||
token: string,
|
||||
options?: import("@slack/web-api").WebClientOptions,
|
||||
) => {
|
||||
webClientMocks.constructed(token, options);
|
||||
return new webApi.WebClient(token, options);
|
||||
},
|
||||
};
|
||||
});
|
||||
const { monitorSlackProvider: cacheAwareMonitor } = await import("./provider.js");
|
||||
resetSlackTestState({
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -120,13 +140,15 @@ describe("slack startup user allowlist resolution", () => {
|
||||
},
|
||||
});
|
||||
getSlackClient().auth.test.mockResolvedValueOnce({
|
||||
user_id: "U_BOT",
|
||||
bot_id: "B_BOT",
|
||||
enterprise_id: "E123",
|
||||
app_id: "A123",
|
||||
is_enterprise_install: true,
|
||||
});
|
||||
const { channelRuntime, register } = createRuntimeContextCapture();
|
||||
|
||||
const monitor = startSlackMonitor(monitorSlackProvider, {
|
||||
const monitor = startSlackMonitor(cacheAwareMonitor, {
|
||||
channelRuntime,
|
||||
appToken: "xapp-1-A123-test",
|
||||
});
|
||||
@@ -139,16 +161,27 @@ describe("slack startup user allowlist resolution", () => {
|
||||
| undefined;
|
||||
const resolveClient = registration?.context?.resolveClient;
|
||||
expect(resolveClient).toBeTypeOf("function");
|
||||
const teamOne = resolveClient?.("T111") as { teamId?: string };
|
||||
const teamOneAgain = resolveClient?.("T111") as { teamId?: string };
|
||||
const teamTwo = resolveClient?.("T222") as { teamId?: string };
|
||||
const teamOne = resolveClient?.("T111");
|
||||
const teamOneAgain = resolveClient?.("T111");
|
||||
const teamTwo = resolveClient?.("T222");
|
||||
|
||||
expect(webClientMocks.constructed).toHaveBeenCalledTimes(2);
|
||||
expect(teamOneAgain).toBe(teamOne);
|
||||
expect(teamTwo).not.toBe(teamOne);
|
||||
expect(teamOne.teamId).toBe("T111");
|
||||
expect(teamTwo.teamId).toBe("T222");
|
||||
expect(webClientMocks.constructed).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"bot-token",
|
||||
expect.objectContaining({ teamId: "T111" }),
|
||||
);
|
||||
expect(webClientMocks.constructed).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"bot-token",
|
||||
expect.objectContaining({ teamId: "T222" }),
|
||||
);
|
||||
} finally {
|
||||
await stopSlackMonitor(monitor);
|
||||
vi.doUnmock("../client.js");
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const spawnSyncMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...actual, spawnSync: spawnSyncMock };
|
||||
});
|
||||
|
||||
import { teamsMeetingsConfig } from "./config.js";
|
||||
import { handleTeamsMeetingsNodeHostCommand } from "./node-host.js";
|
||||
let handleTeamsMeetingsNodeHostCommand: typeof import("./node-host.js").handleTeamsMeetingsNodeHostCommand;
|
||||
|
||||
const successfulProbe = {
|
||||
pid: 123,
|
||||
@@ -29,13 +23,20 @@ function setupParams() {
|
||||
}
|
||||
|
||||
describe("Teams meeting node-host prerequisite deadline", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...actual, spawnSync: spawnSyncMock };
|
||||
});
|
||||
({ handleTeamsMeetingsNodeHostCommand } = await import("./node-host.js"));
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
spawnSyncMock.mockReset();
|
||||
spawnSyncMock.mockReturnValue(successfulProbe);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -58,32 +59,6 @@ describe("Teams meeting node-host prerequisite deadline", () => {
|
||||
).toEqual([10_000, 7_000, 3_000]);
|
||||
});
|
||||
|
||||
it("probes the default sox executable only once", async () => {
|
||||
await expect(
|
||||
handleTeamsMeetingsNodeHostCommand(
|
||||
JSON.stringify({
|
||||
action: "setup",
|
||||
audioInputCommand: teamsMeetingsConfig.defaultAudioInputCommand,
|
||||
audioOutputCommand: teamsMeetingsConfig.defaultAudioOutputCommand,
|
||||
}),
|
||||
),
|
||||
).resolves.toBe(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
audioBackend: "blackhole-2ch",
|
||||
audioDeviceLabel: "BlackHole 2ch",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(spawnSyncMock).toHaveBeenCalledTimes(2);
|
||||
expect(spawnSyncMock.mock.calls[1]?.[1]).toEqual([
|
||||
"-lc",
|
||||
'command -v "$1" >/dev/null 2>&1',
|
||||
"sh",
|
||||
"sox",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not start another probe after the shared deadline expires", async () => {
|
||||
const now = vi.spyOn(Date, "now");
|
||||
for (const value of [1_000, 1_000, 11_000]) {
|
||||
|
||||
@@ -32,6 +32,9 @@ describe("Microsoft Teams meetings node invoke policy", () => {
|
||||
expect(invokeNode).toHaveBeenCalledWith({
|
||||
params: {
|
||||
action: "setup",
|
||||
audioBackend: "auto",
|
||||
audioBufferBytes: 4_096,
|
||||
audioFormat: "pcm16-24khz",
|
||||
audioInputCommand: ["trusted-input", "--read"],
|
||||
audioOutputCommand: ["trusted-output", "--write"],
|
||||
bargeInInputCommand: ["trusted-barge-in"],
|
||||
|
||||
@@ -32,6 +32,9 @@ describe("Zoom meetings node invoke policy", () => {
|
||||
expect(invokeNode).toHaveBeenCalledWith({
|
||||
params: {
|
||||
action: "setup",
|
||||
audioBackend: "auto",
|
||||
audioBufferBytes: 4_096,
|
||||
audioFormat: "pcm16-24khz",
|
||||
audioInputCommand: ["trusted-input", "--read"],
|
||||
audioOutputCommand: ["trusted-output", "--write"],
|
||||
bargeInInputCommand: ["trusted-barge-in"],
|
||||
|
||||
@@ -243,6 +243,27 @@ describe("calculateContextTokens", () => {
|
||||
});
|
||||
expect(estimateContextTokens(messages).trailingTokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("scans past a sparse assistant row to retain older valid usage", () => {
|
||||
const validUsage = createUsage(20);
|
||||
const sparseAssistant = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "seeded without provider usage" }],
|
||||
api: "test-api",
|
||||
provider: "test-provider",
|
||||
model: "test-model",
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
} as AgentMessage;
|
||||
const messages = [createAssistant("complete", validUsage, 1), sparseAssistant];
|
||||
|
||||
expect(getLastAssistantUsage(messages.map(createMessageEntry))).toBe(validUsage);
|
||||
expect(estimateContextTokens(messages)).toMatchObject({
|
||||
usageTokens: 20,
|
||||
lastUsageIndex: 0,
|
||||
});
|
||||
expect(estimateContextTokens(messages).trailingTokens).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session-entry compaction budgeting", () => {
|
||||
|
||||
@@ -128,13 +128,17 @@ function isUnavailableContextBarrier(message: AgentMessage): boolean {
|
||||
if (message.role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
if (message.api === "cli" && message.usage.contextUsage === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (message.usage.contextUsage?.state !== "unavailable") {
|
||||
const usage = "usage" in message ? message.usage : undefined;
|
||||
if (!usage) {
|
||||
return false;
|
||||
}
|
||||
return calculateContextTokens(message.usage) === 0;
|
||||
if (message.api === "cli" && usage.contextUsage === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (usage.contextUsage?.state !== "unavailable") {
|
||||
return false;
|
||||
}
|
||||
return calculateContextTokens(usage) === 0;
|
||||
}
|
||||
|
||||
/** Return usage from the last valid assistant message in session entries. */
|
||||
|
||||
@@ -18,7 +18,7 @@ scenario:
|
||||
- docs/gateway/protocol.md
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- src/gateway/server-methods/agent-dedupe.ts
|
||||
- src/gateway/server-methods/agent.ts
|
||||
- src/gateway/server-methods/agent-wait.ts
|
||||
- test/e2e/qa-lab/runtime/agent-session-dedup-reconnect.e2e.test.ts
|
||||
execution:
|
||||
|
||||
@@ -17,7 +17,7 @@ scenario:
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- extensions/qa-lab/src/qa-transport.ts
|
||||
- src/channels/turn/kernel.ts
|
||||
- src/channels/message-access/sender-gates.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Exercise sender allowlist rejection and the configured driver override in one ordered flow.
|
||||
|
||||
@@ -30,7 +30,7 @@ scenario:
|
||||
docsRefs:
|
||||
- docs/channels/qa-channel.md
|
||||
codeRefs:
|
||||
- src/auto-reply/reply/provider-request-error-classifier.ts
|
||||
- src/agents/failover/request-error-facets.ts
|
||||
- src/auto-reply/reply/agent-runner-error-handler.ts
|
||||
- extensions/qa-lab/src/providers/mock-openai/server.ts
|
||||
execution:
|
||||
|
||||
@@ -18,7 +18,7 @@ scenario:
|
||||
codeRefs:
|
||||
- src/agents/tools/image-generate-tool.ts
|
||||
- src/agents/tools/media-generate-background-shared.ts
|
||||
- src/agents/image-generation-task-status.ts
|
||||
- src/agents/tools/media-generate-background.ts
|
||||
- test/e2e/qa-lab/media/image-generation-lifecycle.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
|
||||
@@ -53,11 +53,14 @@ flow:
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 30000)
|
||||
- assert:
|
||||
expr: "(() => { const expected = normalizeModelRef(env.primaryModel); const receipt = primaryRun?.waited?.terminalReceipt; return receipt?.runId === primaryRun?.started?.runId && normalizeLowercaseStringOrEmpty(receipt.requested?.provider) === expected.provider && receipt.requested?.model === expected.model && receipt.successfulToolNames?.includes('read') && receipt.terminalDisposition === 'visible'; })()"
|
||||
message: default-model run did not return owned successful read evidence
|
||||
expr: "(() => { const expected = normalizeModelRef(env.primaryModel); const receipt = primaryRun?.waited?.terminalReceipt; return receipt?.runId === primaryRun?.started?.runId && Boolean(receipt.sessionId) && Boolean(receipt.turnId) && normalizeLowercaseStringOrEmpty(receipt.requested?.provider) === expected.provider && receipt.requested?.model === expected.model && receipt.terminalDisposition === 'visible'; })()"
|
||||
message: default-model run did not return exact owned terminal evidence
|
||||
- assert:
|
||||
expr: "primaryRun?.waited?.terminalDelivery?.status === 'sent' && typeof primaryRun.waited.terminalDelivery.resultCount === 'number' && primaryRun.waited.terminalDelivery.resultCount > 0"
|
||||
message: default-model run did not return owned sent delivery evidence
|
||||
- set: alternateRequestCursor
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor : null"
|
||||
- call: runAgentPrompt
|
||||
saveAs: alternateRun
|
||||
args:
|
||||
@@ -71,8 +74,27 @@ flow:
|
||||
expr: expectedAlternate.model
|
||||
timeoutMs:
|
||||
expr: resolveQaLiveTurnTimeoutMs(env, 30000, env.alternateModel)
|
||||
- set: alternateToolRequests
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${alternateRequestCursor}`)).filter((request) => String(request.allInputText ?? '').includes(config.followupPrompt) && request.plannedToolName === 'read') : []"
|
||||
- assert:
|
||||
expr: "(() => { const receipt = alternateRun?.waited?.terminalReceipt; return receipt?.runId === alternateRun?.started?.runId && Boolean(receipt.sessionId) && Boolean(receipt.turnId) && normalizeLowercaseStringOrEmpty(receipt.requested?.provider) === expectedAlternate.provider && receipt.requested?.model === expectedAlternate.model && receipt.effective?.model === receipt.effective?.responseModel && receipt.successfulToolNames?.includes('read') && receipt.terminalDisposition === 'visible' && typeof receipt.rerouted === 'boolean' && `${receipt.effective?.provider}/${receipt.effective?.responseModel}` !== `${primaryRun.waited.terminalReceipt.effective?.provider}/${primaryRun.waited.terminalReceipt.effective?.responseModel}`; })()"
|
||||
expr: "!env.mock || alternateToolRequests.length > 0"
|
||||
message: alternate-model debug evidence did not plan the logical read tool
|
||||
- set: alternateToolRequest
|
||||
value:
|
||||
expr: "env.mock ? alternateToolRequests.find((request) => alternateRun?.waited?.terminalReceipt?.successfulToolNames?.includes(request.plannedWireToolName ?? request.plannedToolName)) : null"
|
||||
- assert:
|
||||
expr: "!env.mock || Boolean(alternateToolRequest)"
|
||||
message:
|
||||
expr: "`alternate-model run did not return exact owned successful read evidence: planned=${JSON.stringify(alternateToolRequests.map((request) => ({ logical: request.plannedToolName ?? null, wire: request.plannedWireToolName ?? request.plannedToolName ?? null })))} receipt=${JSON.stringify(alternateRun?.waited?.terminalReceipt ?? null)}`"
|
||||
- set: alternateWireToolName
|
||||
value:
|
||||
expr: "alternateToolRequest?.plannedWireToolName ?? alternateToolRequest?.plannedToolName ?? (alternateRun?.waited?.terminalReceipt?.successfulToolNames?.includes('read') ? 'read' : 'exec')"
|
||||
- assert:
|
||||
expr: "alternateWireToolName === 'read' || alternateWireToolName === 'exec'"
|
||||
message: alternate-model read did not resolve to a supported direct or Code Mode wire tool
|
||||
- assert:
|
||||
expr: "(() => { const receipt = alternateRun?.waited?.terminalReceipt; return receipt?.runId === alternateRun?.started?.runId && Boolean(receipt.sessionId) && Boolean(receipt.turnId) && normalizeLowercaseStringOrEmpty(receipt.requested?.provider) === expectedAlternate.provider && receipt.requested?.model === expectedAlternate.model && receipt.effective?.model === receipt.effective?.responseModel && receipt.successfulToolNames?.includes(alternateWireToolName) && receipt.terminalDisposition === 'visible' && typeof receipt.rerouted === 'boolean' && `${receipt.effective?.provider}/${receipt.effective?.responseModel}` !== `${primaryRun.waited.terminalReceipt.effective?.provider}/${primaryRun.waited.terminalReceipt.effective?.responseModel}`; })()"
|
||||
message: alternate-model run did not return exact owned successful read evidence
|
||||
- assert:
|
||||
expr: "alternateRun?.waited?.terminalReply?.disposition === 'visible' && hasModelSwitchContinuitySignal(alternateRun.waited.terminalReply.text)"
|
||||
@@ -86,6 +108,11 @@ flow:
|
||||
ref: primaryRun.waited.terminalReceipt
|
||||
alternate:
|
||||
ref: alternateRun.waited.terminalReceipt
|
||||
alternateTool:
|
||||
logical:
|
||||
ref: alternateToolRequest.plannedToolName
|
||||
wire:
|
||||
ref: alternateWireToolName
|
||||
terminalReply:
|
||||
ref: alternateRun.waited.terminalReply
|
||||
terminalDelivery:
|
||||
|
||||
@@ -22,7 +22,7 @@ scenario:
|
||||
codeRefs:
|
||||
- src/cli/plugins-search-command.ts
|
||||
- src/plugins/catalog-search.ts
|
||||
- src/infra/clawhub.ts
|
||||
- src/plugins/clawhub.ts
|
||||
- scripts/e2e/lib/clawhub-fixture-server.cjs
|
||||
- src/cli/plugins-search-command.clawhub.e2e.test.ts
|
||||
execution:
|
||||
|
||||
@@ -28,7 +28,7 @@ scenario:
|
||||
codeRefs:
|
||||
- packages/sdk/src/client.ts
|
||||
- packages/sdk/src/app-sdk-composed-resources.e2e.test.ts
|
||||
- src/gateway/server-methods/agent-job.ts
|
||||
- src/gateway/server-methods/agent-run-handler.ts
|
||||
- src/gateway/server-methods/artifacts.ts
|
||||
- src/gateway/server-methods/environments.ts
|
||||
execution:
|
||||
|
||||
@@ -19,7 +19,7 @@ scenario:
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- src/gateway/server-methods/agent.ts
|
||||
- src/gateway/server-methods/agent-delivery-phase.ts
|
||||
- src/gateway/server-methods/agent-run-handler.ts
|
||||
- src/gateway/server.agent.rpc-contracts.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
|
||||
@@ -45,7 +45,7 @@ run_suspension_phase() {
|
||||
-e "GW_MODE=suspension-$stage-restart" \
|
||||
-e "GW_STATE_PATH=$SUSPENSION_STATE_PATH" \
|
||||
"$GW_NAME" \
|
||||
node --import tsx scripts/e2e/lib/gateway-network/client.mts
|
||||
tsx scripts/e2e/lib/gateway-network/client.mts
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
@@ -84,7 +84,7 @@ DOCKER_COMMAND_TIMEOUT="$CLIENT_TIMEOUT" run_logged gateway-network-client docke
|
||||
-e "GW_URL=ws://$GW_NAME:$PORT" \
|
||||
-e "GW_TOKEN=$TOKEN" \
|
||||
"$IMAGE_NAME" \
|
||||
node --import tsx scripts/e2e/lib/gateway-network/client.mts
|
||||
tsx scripts/e2e/lib/gateway-network/client.mts
|
||||
|
||||
phase_started="$SECONDS"
|
||||
echo "Running cooperative suspension lifecycle before container stop..."
|
||||
|
||||
@@ -52,7 +52,7 @@ echo "Running kitchen-sink RPC Docker E2E..."
|
||||
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
docker_e2e_harness_mount_args
|
||||
DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run --name "$CONTAINER_NAME" "${DOCKER_E2E_HARNESS_ARGS[@]}" "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" \
|
||||
node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mts >"$RUN_LOG" 2>&1 &
|
||||
tsx scripts/e2e/kitchen-sink-rpc-walk.mts >"$RUN_LOG" 2>&1 &
|
||||
docker_pid="$!"
|
||||
|
||||
docker_e2e_sample_stats_until_exit \
|
||||
|
||||
@@ -12,7 +12,7 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { asRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { asRecord, isRecord } from "../../packages/normalization-core/src/record-coerce.ts";
|
||||
import {
|
||||
createBoundedResponseTooLargeError,
|
||||
readBoundedResponseText,
|
||||
@@ -1208,7 +1208,8 @@ function isRetryableTransientNetworkError(error: unknown, seen = new Set<unknown
|
||||
const message =
|
||||
candidate instanceof Error ? candidate.message : typeof candidate === "string" ? candidate : "";
|
||||
const code = asRecord(candidate).code;
|
||||
const text = `${String(code ?? "")} ${message}`;
|
||||
const normalizedCode = typeof code === "string" || typeof code === "number" ? String(code) : "";
|
||||
const text = `${normalizedCode} ${message}`;
|
||||
if (
|
||||
/\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EHOSTUNREACH|ENETUNREACH)\b/iu.test(text) ||
|
||||
/\b(?:fetch failed|socket hang up|connection reset)\b/iu.test(text)
|
||||
@@ -1971,7 +1972,7 @@ export async function assertOperatorRpcDenied(
|
||||
} catch (error) {
|
||||
const candidate = asRecord(error);
|
||||
const gatewayCode = candidate.gatewayCode;
|
||||
const message = String(candidate.message ?? "");
|
||||
const message = typeof candidate.message === "string" ? candidate.message : "";
|
||||
if (gatewayCode === "INVALID_REQUEST" && message.includes("unauthorized role: operator")) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,11 +167,7 @@ docker_e2e_test_state_entrypoint() {
|
||||
docker_e2e_run_test_state() {
|
||||
local entrypoint
|
||||
entrypoint="$(docker_e2e_test_state_entrypoint)" || return
|
||||
if [[ "$entrypoint" == *.mts ]]; then
|
||||
node --import tsx "$entrypoint" "$@"
|
||||
else
|
||||
node "$entrypoint" "$@"
|
||||
fi
|
||||
node "$entrypoint" "$@"
|
||||
}
|
||||
|
||||
docker_e2e_test_state_shell_b64() {
|
||||
|
||||
@@ -268,6 +268,7 @@ docker_e2e_harness_mount_args() {
|
||||
DOCKER_E2E_HARNESS_ARGS=(
|
||||
-v "$harness_root/scripts/e2e:/app/scripts/e2e:ro"
|
||||
-v "$harness_root/scripts/lib:/app/scripts/lib:ro"
|
||||
-v "$harness_root/packages/gateway-client/src:/app/packages/gateway-client/src:ro"
|
||||
-v "$harness_root/packages/normalization-core/src:/app/packages/normalization-core/src:ro"
|
||||
-v "$harness_root/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"
|
||||
-v "$harness_root/test/helpers:/app/test/helpers:ro"
|
||||
|
||||
@@ -31,9 +31,9 @@ type TestStateOptions = {
|
||||
|
||||
function usage() {
|
||||
return `Usage:
|
||||
node --import tsx scripts/lib/openclaw-test-state.mts -- create [--label <name>] [--scenario <name>] [--env-file <path>] [--json]
|
||||
node --import tsx scripts/lib/openclaw-test-state.mts shell [--label <name>] [--scenario <name>]
|
||||
node --import tsx scripts/lib/openclaw-test-state.mts shell-function
|
||||
node scripts/lib/openclaw-test-state.mts -- create [--label <name>] [--scenario <name>] [--env-file <path>] [--json]
|
||||
node scripts/lib/openclaw-test-state.mts shell [--label <name>] [--scenario <name>]
|
||||
node scripts/lib/openclaw-test-state.mts shell-function
|
||||
|
||||
Scenarios: ${[...SCENARIOS].join(", ")}
|
||||
`;
|
||||
|
||||
@@ -250,6 +250,11 @@ export function doesApprovalRequestSelectChannelAccount(params: {
|
||||
if (!accountId) {
|
||||
return false;
|
||||
}
|
||||
const expectedChannel = normalizeOptionalChannel(params.channel);
|
||||
const turnSourceChannel = normalizeOptionalChannel(params.request.request.turnSourceChannel);
|
||||
if (!expectedChannel || (turnSourceChannel && turnSourceChannel !== expectedChannel)) {
|
||||
return false;
|
||||
}
|
||||
const boundAccountId = resolveApprovalRequestChannelAccountId(params);
|
||||
if (accountId === normalizeOptionalAccountId(boundAccountId)) {
|
||||
return true;
|
||||
|
||||
@@ -67,6 +67,23 @@ const baseRequest: ExecApprovalRequest = {
|
||||
};
|
||||
|
||||
describe("native approval account selection", () => {
|
||||
it("does not let a conflicting turn-source channel fall through to the sole account", () => {
|
||||
const discordRequest = buildRequest({
|
||||
turnSourceChannel: "discord",
|
||||
turnSourceAccountId: "default",
|
||||
});
|
||||
expect(
|
||||
doesApprovalRequestSelectChannelAccount({
|
||||
cfg: {},
|
||||
request: discordRequest,
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
defaultAccountId: "default",
|
||||
eligibleAccountIds: ["default"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("selects only the sole eligible account when no owner is recorded", () => {
|
||||
expect(
|
||||
doesApprovalRequestSelectChannelAccount({
|
||||
|
||||
@@ -11,7 +11,12 @@ vi.mock("node:child_process", async (importOriginal) => ({
|
||||
|
||||
import { createMeetingConfiguredNodeHost } from "./configured-node-host.js";
|
||||
|
||||
function createHost() {
|
||||
function createHost(
|
||||
commands: {
|
||||
input?: string[];
|
||||
output?: string[];
|
||||
} = {},
|
||||
) {
|
||||
return createMeetingConfiguredNodeHost({
|
||||
agentMode: "agent",
|
||||
bridgeIdPrefix: "test-node-",
|
||||
@@ -28,8 +33,8 @@ function createHost() {
|
||||
bufferBytes: 4_096,
|
||||
format: "pcm16-24khz",
|
||||
},
|
||||
defaultAudioInputCommand: ["legacy-capture"],
|
||||
defaultAudioOutputCommand: ["legacy-playback"],
|
||||
defaultAudioInputCommand: commands.input ?? ["legacy-capture"],
|
||||
defaultAudioOutputCommand: commands.output ?? ["legacy-playback"],
|
||||
displayName: "Test meeting",
|
||||
meetingLabel: "Test meeting",
|
||||
normalizeMeetingKey: (url) => url,
|
||||
@@ -147,4 +152,22 @@ describe("configured meeting node host", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("probes one executable once when every configured audio command shares it", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
childProcessMocks.spawnSync.mockImplementation((command: string) => ({
|
||||
status: 0,
|
||||
stdout: command.endsWith("system_profiler") ? "BlackHole 2ch" : "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await createHost({ input: ["sox", "capture"], output: ["sox", "play"] })(
|
||||
JSON.stringify({ action: "setup", bargeInInputCommand: ["sox", "barge"] }),
|
||||
);
|
||||
|
||||
const soxProbes = childProcessMocks.spawnSync.mock.calls.filter(
|
||||
(call) => Array.isArray(call[1]) && call[1].at(-1) === "sox",
|
||||
);
|
||||
expect(soxProbes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,6 @@ const REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDING_COUNTS = new Map<string, nu
|
||||
["@openclaw/codex:dangerous-exec:src/app-server/transport-stdio.ts", 1],
|
||||
["@openclaw/codex:dangerous-exec:src/node-cli-sessions.ts", 1],
|
||||
["@openclaw/discord:dangerous-exec:src/voice/audio.ts", 1],
|
||||
["@openclaw/google-meet:dangerous-exec:src/node-host.ts", 1],
|
||||
["@openclaw/imessage:dangerous-exec:src/client.ts", 1],
|
||||
["@openclaw/mxc-sandbox:dangerous-exec:src/readiness.ts", 2],
|
||||
["@openclaw/opencode-provider:dangerous-exec:session-catalog.ts", 1],
|
||||
|
||||
@@ -268,7 +268,7 @@ describe("resolvePluginRuntimeLoadContext", () => {
|
||||
...metadataSnapshot,
|
||||
index: {
|
||||
installRecords: {
|
||||
demo: { source: "registry", version: "1.0.0" },
|
||||
demo: { source: "npm", version: "1.0.0" },
|
||||
},
|
||||
plugins: [],
|
||||
policyHash: "policy",
|
||||
@@ -282,10 +282,10 @@ describe("resolvePluginRuntimeLoadContext", () => {
|
||||
});
|
||||
|
||||
expect(context.installRecords).toEqual({
|
||||
demo: { source: "registry", version: "1.0.0" },
|
||||
demo: { source: "npm", version: "1.0.0" },
|
||||
});
|
||||
expect(buildPluginRuntimeLoadOptions(context).installRecords).toEqual({
|
||||
demo: { source: "registry", version: "1.0.0" },
|
||||
demo: { source: "npm", version: "1.0.0" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -565,6 +565,10 @@ print_log_tail "$LOG_PATH"
|
||||
const compiledResult = resolveEntrypoint(compiledRoot);
|
||||
expect(compiledResult.status, compiledResult.stderr).toBe(0);
|
||||
expect(compiledResult.stdout.trim()).toBe(compiledEntrypoint);
|
||||
|
||||
const helper = readFileSync(DOCKER_E2E_IMAGE_HELPER_PATH, "utf8");
|
||||
expect(helper).toContain('node "$entrypoint" "$@"');
|
||||
expect(helper).not.toContain('node --import tsx "$entrypoint"');
|
||||
});
|
||||
|
||||
it("rejects malformed Docker E2E resource limits before a suite starts", () => {
|
||||
@@ -4060,9 +4064,14 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
||||
|
||||
it("keeps the kitchen-sink RPC Docker watchdog above the internal walk budgets", () => {
|
||||
const runner = readFileSync(KITCHEN_SINK_RPC_DOCKER_E2E_PATH, "utf8");
|
||||
const walk = readFileSync("scripts/e2e/kitchen-sink-rpc-walk.mts", "utf8");
|
||||
expect(runner).toContain(
|
||||
'DOCKER_RUN_TIMEOUT="${OPENCLAW_KITCHEN_SINK_RPC_DOCKER_RUN_TIMEOUT:-1500s}"',
|
||||
);
|
||||
expect(runner).toContain("tsx scripts/e2e/kitchen-sink-rpc-walk.mts");
|
||||
expect(runner).not.toContain("node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mts");
|
||||
expect(walk).toContain("../../packages/normalization-core/src/record-coerce.ts");
|
||||
expect(walk).not.toContain("@openclaw/normalization-core");
|
||||
});
|
||||
|
||||
it("bounds kitchen-sink plugin CLI commands inside the Docker sweep", () => {
|
||||
@@ -4197,6 +4206,8 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
||||
expect(runner).not.toContain(
|
||||
'run_logged gateway-network-client timeout "$CLIENT_TIMEOUT" docker run --rm',
|
||||
);
|
||||
expect(runner).toContain("tsx scripts/e2e/lib/gateway-network/client.mts");
|
||||
expect(runner).not.toContain("node --import tsx scripts/e2e/lib/gateway-network/client.mts");
|
||||
});
|
||||
|
||||
it("proves gateway suspension across a same-container process restart", () => {
|
||||
@@ -4280,6 +4291,7 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
|
||||
"--allow-unreleased-changelog",
|
||||
'local harness_root="${DOCKER_E2E_HARNESS_ROOT_DIR:-$ROOT_DIR}"',
|
||||
'-v "$harness_root/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"',
|
||||
'-v "$harness_root/packages/gateway-client/src:/app/packages/gateway-client/src:ro"',
|
||||
'-v "$harness_root/packages/normalization-core/src:/app/packages/normalization-core/src:ro"',
|
||||
'-v "$harness_root/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"',
|
||||
'-v "$harness_root/test/helpers:/app/test/helpers:ro"',
|
||||
|
||||
@@ -268,7 +268,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
|
||||
expect(script).toContain("OPENCLAW_KITCHEN_SINK_COMMAND_MAX_RSS_MIB");
|
||||
expect(script).toContain("docker_e2e_sample_stats_until_exit");
|
||||
expect(script).toContain("scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs");
|
||||
expect(script).toContain("node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mts");
|
||||
expect(script).toContain("tsx scripts/e2e/kitchen-sink-rpc-walk.mts");
|
||||
expect(walkScript).toContain("commands.list");
|
||||
expect(walkScript).toContain("tools.invoke");
|
||||
expect(walkScript).toContain("tts.providers");
|
||||
|
||||
Reference in New Issue
Block a user