Files
modelstudioai__cli/packages/commands/tests/sandbox.test.ts
T
2026-09-07 15:11:51 +08:00

501 lines
15 KiB
TypeScript

import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BailianError, ExitCode, type AnyCommand, type Settings } from "bailian-cli-core";
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import {
buildSandboxCreateBody,
sandboxConnect,
sandboxCreate,
sandboxDelete,
sandboxGet,
sandboxList,
sandboxPause,
sandboxResume,
} from "../src/commands/sandbox/instance.ts";
import {
buildTemplateCreateBody,
buildTemplateUpdateBody,
sandboxTemplateBuildStatus,
sandboxTemplateCreate,
sandboxTemplateDelete,
sandboxTemplateGet,
sandboxTemplateList,
sandboxTemplateUpdate,
waitForTemplateBuild,
} from "../src/commands/sandbox/template.ts";
import {
readRequestBody,
redactConnectionCredentials,
redactRequestSecrets,
} from "../src/commands/sandbox/shared.ts";
afterEach(() => {
vi.restoreAllMocks();
});
const SETTINGS: Settings = {
output: "json",
outputExplicit: true,
timeout: 30,
verbose: false,
quiet: true,
dryRun: false,
telemetry: false,
};
interface RecordedRequest {
path: string;
method?: string;
body?: unknown;
timeout?: number;
}
async function runCommand(
command: AnyCommand,
flags: Record<string, unknown>,
response: unknown,
): Promise<RecordedRequest> {
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const requestJson = vi.fn(async (_request: RecordedRequest) => response);
const request = vi.fn(async (requestOptions: RecordedRequest) => {
void requestOptions;
return new Response(null, { status: 204 });
});
await command.run({
identity: {
binName: "bl",
version: "test",
npmPackage: "bailian-cli",
clientName: "bailian-cli",
},
settings: SETTINGS,
flags,
client: { requestJson, request },
} as never);
const recorded = requestJson.mock.calls[0]?.[0] ?? request.mock.calls[0]?.[0];
if (!recorded) throw new Error("Expected the command to issue one request.");
expect(requestJson.mock.calls.length + request.mock.calls.length).toBe(1);
return recorded;
}
describe("Sandbox request input", () => {
test("--body accepts inline and @file JSON objects", async () => {
expect(await readRequestBody('{"name":"inline"}')).toEqual({ name: "inline" });
const directory = mkdtempSync(join(tmpdir(), "bl-sandbox-body-"));
const bodyPath = join(directory, "body.json");
writeFileSync(bodyPath, '{"name":"file"}', "utf8");
try {
expect(await readRequestBody(`@${bodyPath}`)).toEqual({ name: "file" });
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("instance flags override body scalars and merge key-value maps", async () => {
const body = await buildSandboxCreateBody({
body: JSON.stringify({
templateID: "template-body",
timeout: 600,
metadata: { body: "kept", override: "body" },
envVars: { BODY: "kept", OVERRIDE: "body" },
network: { allowOut: ["body.example"], maskRequestHost: "body.example" },
}),
templateId: "template-flag",
instanceTimeout: 900,
metadata: ["override=flag"],
env: ["OVERRIDE=flag"],
allowOut: ["flag.example"],
maskRequestHost: "flag.example",
showCredentials: false,
});
expect(body).toEqual({
templateID: "template-flag",
timeout: 900,
metadata: { body: "kept", override: "flag" },
envVars: { BODY: "kept", OVERRIDE: "flag" },
network: { allowOut: ["flag.example"], maskRequestHost: "flag.example" },
});
});
test("validates instance timeout and paired template CPU/memory fields", async () => {
await expect(
buildSandboxCreateBody({
templateId: "template-test",
instanceTimeout: 299,
showCredentials: false,
}),
).rejects.toMatchObject({ exitCode: ExitCode.USAGE });
await expect(
buildTemplateUpdateBody({
templateId: "template-test",
cpuCount: 2,
async: false,
}),
).rejects.toMatchObject({ exitCode: ExitCode.USAGE });
});
test("template create flags map max-running-time and nested overrides", async () => {
const body = await buildTemplateCreateBody({
body: JSON.stringify({ networkConfig: { allowOut: ["body.example"] } }),
name: "python",
cpuCount: 2,
memoryMb: 4096,
maxRunningTime: 3600,
allowOut: ["flag.example"],
tag: ["latest"],
async: false,
});
expect(body).toEqual({
name: "python",
cpuCount: 2,
memoryMB: 4096,
maxRunningTimeout: 3600,
tags: ["latest"],
networkConfig: { allowOut: ["flag.example"] },
});
});
test("redacts returned connection credentials and request environment secrets recursively", () => {
expect(
redactConnectionCredentials({
sandboxID: "sandbox-test",
envdAccessToken: "envd-secret",
connection: { trafficAccessToken: "traffic-secret", hostname: "example.test" },
}),
).toEqual({
sandboxID: "sandbox-test",
envdAccessToken: "[REDACTED]",
connection: { trafficAccessToken: "[REDACTED]", hostname: "example.test" },
});
expect(
redactRequestSecrets({ envVars: { PUBLIC_NAME: "secret" }, nested: { apiKey: "secret" } }),
).toEqual({
envVars: { PUBLIC_NAME: "[REDACTED]" },
nested: { apiKey: "[REDACTED]" },
});
});
});
describe("Sandbox command transport", () => {
test.each([
{
name: "create instance",
command: sandboxCreate,
flags: { workspaceId: "ws-test", templateId: "template-test", showCredentials: false },
response: { sandboxID: "sandbox-test" },
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes",
body: { templateID: "template-test" },
},
},
{
name: "list instances",
command: sandboxList,
flags: { workspaceId: "ws-test", templateId: "template-test", state: "running", limit: 10 },
response: [],
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/v2/sandboxes?templateID=template-test&state=running&limit=10",
},
},
{
name: "get instance",
command: sandboxGet,
flags: { workspaceId: "ws-test", sandboxId: "sandbox/a", showCredentials: false },
response: { sandboxID: "sandbox/a" },
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox%2Fa",
},
},
{
name: "connect instance",
command: sandboxConnect,
flags: {
workspaceId: "ws-test",
sandboxId: "sandbox-test",
instanceTimeout: 900,
showCredentials: false,
},
response: { sandboxID: "sandbox-test" },
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test/connect",
body: { timeout: 900 },
},
},
{
name: "pause instance",
command: sandboxPause,
flags: { workspaceId: "ws-test", sandboxId: "sandbox-test" },
response: undefined,
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test/pause",
},
},
{
name: "resume instance",
command: sandboxResume,
flags: { workspaceId: "ws-test", sandboxId: "sandbox-test", showCredentials: false },
response: { sandboxID: "sandbox-test" },
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test/resume",
},
},
{
name: "delete instance",
command: sandboxDelete,
flags: { workspaceId: "ws-test", sandboxId: "sandbox-test" },
response: undefined,
request: {
method: "DELETE",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/sandboxes/sandbox-test",
},
},
{
name: "create template",
command: sandboxTemplateCreate,
flags: {
workspaceId: "ws-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: true,
},
response: { templateID: "template-test", buildID: "build-test" },
request: {
method: "POST",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/v3/templates",
body: { name: "python", cpuCount: 1, memoryMB: 2048 },
},
},
{
name: "list templates",
command: sandboxTemplateList,
flags: { workspaceId: "ws-test", limit: 20, cursor: "cursor/a" },
response: [],
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/v2/templates?limit=20&cursor=cursor%2Fa",
},
},
{
name: "get template",
command: sandboxTemplateGet,
flags: { workspaceId: "ws-test", templateId: "template/a" },
response: { templateID: "template/a" },
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template%2Fa",
},
},
{
name: "update template",
command: sandboxTemplateUpdate,
flags: {
workspaceId: "ws-test",
templateId: "template-test",
description: "updated",
async: true,
},
response: { templateID: "template-test", buildID: "build-test" },
request: {
method: "PUT",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test",
body: { description: "updated" },
},
},
{
name: "get template build status",
command: sandboxTemplateBuildStatus,
flags: { workspaceId: "ws-test", templateId: "template-test", buildId: "build/a" },
response: { status: "ready" },
request: {
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test/builds/build%2Fa/status",
},
},
{
name: "delete template",
command: sandboxTemplateDelete,
flags: { workspaceId: "ws-test", templateId: "template-test" },
response: undefined,
request: {
method: "DELETE",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test",
},
},
])(
"maps $name to its documented method and path",
async ({ command, flags, response, request }) => {
expect(await runCommand(command, flags, response)).toEqual(request);
},
);
test.each([
{ showCredentials: false, expectedToken: "[REDACTED]" },
{ showCredentials: true, expectedToken: "envd-secret" },
])("show-credentials=$showCredentials controls connection token output", async (testCase) => {
let stdout = "";
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
await sandboxGet.run({
identity: { binName: "bl" },
settings: { ...SETTINGS, quiet: false },
flags: {
workspaceId: "ws-test",
sandboxId: "sandbox-test",
showCredentials: testCase.showCredentials,
},
client: {
requestJson: async () => ({
sandboxID: "sandbox-test",
envdAccessToken: "envd-secret",
}),
},
} as never);
expect(JSON.parse(stdout)).toMatchObject({ envdAccessToken: testCase.expectedToken });
});
});
describe("Sandbox template build polling", () => {
test("polls building until ready", async () => {
const requestJson = vi
.fn()
.mockResolvedValueOnce({ status: "building" })
.mockResolvedValueOnce({ status: "ready", buildID: "build-test" });
const result = await waitForTemplateBuild(
{ requestJson } as never,
SETTINGS,
"https://example.test/build/status",
0,
);
expect(result).toMatchObject({ status: "ready", buildID: "build-test" });
expect(requestJson).toHaveBeenCalledTimes(2);
});
test("preserves the service build error message", async () => {
const requestJson = vi.fn().mockResolvedValue({
status: "error",
reason: { code: "BuildFailed", message: "image download failed" },
});
await expect(
waitForTemplateBuild(
{ requestJson } as never,
SETTINGS,
"https://example.test/build/status",
0,
),
).rejects.toMatchObject({
message: "image download failed",
exitCode: ExitCode.GENERAL,
});
});
test("returns a timeout error when the global timeout expires", async () => {
const requestJson = vi.fn();
await expect(
waitForTemplateBuild(
{ requestJson } as never,
{ ...SETTINGS, timeout: 0 },
"https://example.test/build/status",
1,
),
).rejects.toEqual(expect.objectContaining({ exitCode: ExitCode.TIMEOUT }));
expect(requestJson).not.toHaveBeenCalled();
});
test("async template creation returns after the submit request", async () => {
let stdout = "";
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const requestJson = vi.fn().mockResolvedValue({
templateID: "template-test",
buildID: "build-test",
buildStatus: "building",
});
await sandboxTemplateCreate.run({
identity: { binName: "bl" },
settings: SETTINGS,
flags: {
workspaceId: "ws-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: true,
},
client: { requestJson },
} as never);
expect(requestJson).toHaveBeenCalledTimes(1);
expect(stdout).toBe("template-test\tbuild-test\n");
});
test("default template creation polls build-status and emits the final envelope", async () => {
let stdout = "";
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
stdout += String(chunk);
return true;
});
const requestJson = vi
.fn()
.mockResolvedValueOnce({
templateID: "template-test",
buildID: "build-test",
buildStatus: "building",
})
.mockResolvedValueOnce({
templateID: "template-test",
buildID: "build-test",
status: "ready",
});
await sandboxTemplateCreate.run({
identity: { binName: "bl" },
settings: { ...SETTINGS, quiet: false },
flags: {
workspaceId: "ws-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: false,
pollInterval: 1,
},
client: { requestJson },
} as never);
expect(requestJson).toHaveBeenCalledTimes(2);
expect(requestJson.mock.calls[1]?.[0]).toMatchObject({
method: "GET",
path: "https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio/sandbox/templates/template-test/builds/build-test/status",
});
expect(JSON.parse(stdout)).toEqual({
template: {
templateID: "template-test",
buildID: "build-test",
buildStatus: "building",
},
build: {
templateID: "template-test",
buildID: "build-test",
status: "ready",
},
});
});
});
test("Sandbox validation errors use CLI usage exit codes", async () => {
await expect(buildSandboxCreateBody({ showCredentials: false })).rejects.toBeInstanceOf(
BailianError,
);
});