218 lines
11 KiB
TypeScript
218 lines
11 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { readProviderAppsConfig } from "../../src/components/provider-apps/config";
|
|
import { readProviderRelease, releaseResponse } from "../../src/components/provider-apps/release";
|
|
import { providerAppsHelp } from "./provider-apps";
|
|
import { runProviderAppsLinuxCommand } from "./provider-apps-linux";
|
|
|
|
const repositoryRoot = resolve(import.meta.dir, "../..");
|
|
|
|
describe("Provider Apps L1 release", () => {
|
|
const config = readProviderAppsConfig(repositoryRoot);
|
|
const release = readProviderRelease(repositoryRoot, config);
|
|
|
|
test("owning YAML declares a fixed native HTTPS origin", () => {
|
|
expect(config.target.id).toBe("NC01");
|
|
expect(config.runtime.port).toBe(18121);
|
|
expect(config.publicExposure.publicBaseUrl).toBe("https://apps.hwpod.com");
|
|
expect(config.install.linux.targets["D601-VM-HOST"]?.server).toBe(
|
|
config.install.defaults.server,
|
|
);
|
|
expect(config.install.linux.targets["D601-VM-HOST"]?.network).toEqual({
|
|
bypassKubernetesServiceNat: true,
|
|
systemdUnit: "unideskcprovider-network.service",
|
|
});
|
|
expect(config.install.linux.targets["D601-VM-HOST"]?.user).toBe("root");
|
|
expect(config.install.linux.targets["D601-VM-HOST"]?.home).toBe("/root");
|
|
});
|
|
|
|
test("serves release files and rejects unknown or mutating requests", async () => {
|
|
expect((await releaseResponse("GET", config.release.routes.health, config, release).json()).ok).toBe(true);
|
|
expect(releaseResponse("GET", config.release.routes.manifest, config, release).status).toBe(200);
|
|
expect(releaseResponse("HEAD", config.release.routes.provider, config, release).status).toBe(200);
|
|
expect(releaseResponse("GET", "/private", config, release).status).toBe(404);
|
|
expect(releaseResponse("POST", config.release.routes.provider, config, release).status).toBe(405);
|
|
});
|
|
|
|
test("serves human and agent installer surfaces from one L1 service", async () => {
|
|
const index = releaseResponse(
|
|
"GET",
|
|
config.install.routes.index,
|
|
config,
|
|
release,
|
|
"https://apps.hwpod.com",
|
|
);
|
|
expect(index.status).toBe(200);
|
|
expect(index.headers.get("content-security-policy")).toContain("default-src 'self'");
|
|
const indexHtml = await index.text();
|
|
expect(indexHtml).toContain("32 条 TCP warm channel");
|
|
expect(indexHtml).toContain('<option value="auto" selected>自动选择(推荐)</option>');
|
|
expect(indexHtml).toContain('<link rel="icon" href="/assets/provider-icon.svg"');
|
|
const appScript = await releaseResponse(
|
|
"GET",
|
|
config.install.routes.script,
|
|
config,
|
|
release,
|
|
).text();
|
|
const referencedIds = [...appScript.matchAll(/querySelector\("#([^"]+)"\)/gu)]
|
|
.map((match) => match[1]);
|
|
expect(referencedIds.length).toBeGreaterThan(0);
|
|
for (const id of referencedIds) {
|
|
expect(indexHtml).toContain(`id="${id}"`);
|
|
}
|
|
const contract = await releaseResponse(
|
|
"GET",
|
|
config.install.routes.contract,
|
|
config,
|
|
release,
|
|
"https://apps.hwpod.com",
|
|
).json() as Record<string, any>;
|
|
expect(contract.schemaVersion).toBe(4);
|
|
expect(contract.platformsSupported).toEqual(["windows", "linux"]);
|
|
expect(contract.humanUrl).toBe("https://apps.hwpod.com/");
|
|
expect(contract.sensitiveInputs.acceptedByWeb).toBe(false);
|
|
expect(contract.installer.acceptsArguments).toBe(false);
|
|
expect(contract.config.mustExistBeforeInstall).toBe(true);
|
|
expect(contract.platforms.windows.oneLineTemplate).toBe(
|
|
'certutil -urlcache -f https://apps.hwpod.com/win.cmd "%TEMP%\\u.cmd"&&call "%TEMP%\\u.cmd"',
|
|
);
|
|
expect(contract.platforms.windows.launchers.cmd.command).toContain("https://apps.hwpod.com/win.cmd");
|
|
expect(contract.platforms.windows.launchers.cmd.support).toContain("explicit runtime modes never fall back");
|
|
expect(contract.platforms.linux.oneLineTemplate).toBe("curl -fsSL https://apps.hwpod.com/linux.sh | bash");
|
|
expect(contract.platforms.linux.configPath).toBe("~/.unidesk/provider.yaml");
|
|
expect(contract.platforms.linux.launchers.bash.support).toContain("no Docker or Node");
|
|
expect(contract.config.pythonRuntime.behavior.auto).toContain("system Python, then a private Python");
|
|
expect(contract.config.pythonRuntime.behavior.system).toContain("fail");
|
|
expect(contract.config.pythonRuntime.behavior.private).toContain("without scanning system Python");
|
|
expect(contract.endpoints.runtimes.windows7.version).toBe("3.8.10");
|
|
expect(contract.endpoints.runtimes.windowsModern.version).toBe("3.11.9");
|
|
const fallbackContract = await releaseResponse(
|
|
"GET",
|
|
config.install.routes.contract,
|
|
config,
|
|
release,
|
|
"http://127.0.0.1:18121",
|
|
).json() as Record<string, any>;
|
|
expect(fallbackContract.humanUrl).toBe("https://apps.hwpod.com/");
|
|
});
|
|
|
|
test("PowerShell installer has release URLs resolved and accepts no arguments", async () => {
|
|
const installer = await releaseResponse(
|
|
"GET",
|
|
config.install.routes.installer,
|
|
config,
|
|
release,
|
|
).text();
|
|
expect(installer).toContain("https://apps.hwpod.com/v1/unideskcprovider/update.json");
|
|
expect(installer).toContain("https://apps.hwpod.com/v1/unideskcprovider/unideskcprovider.py");
|
|
expect(installer).toContain("$ErrorActionPreference = \"Continue\"");
|
|
expect(installer).toContain("sys.modules[spec.name] = module");
|
|
expect(installer).toContain("\"config-probe-$PID.py\"");
|
|
expect(installer).not.toContain("\"-c\",\n $configProbeCode");
|
|
expect(installer).toContain("\"$providerPath.install.bak\"");
|
|
expect(installer).not.toContain("$providerPath, $null");
|
|
expect(installer).toContain("param()");
|
|
expect(installer).not.toContain("__MANIFEST_URL__");
|
|
expect(installer).not.toContain("__PROVIDER_URL__");
|
|
expect(installer).not.toContain("[Parameter(Mandatory");
|
|
expect(installer).not.toContain("nodeRequired");
|
|
expect(releaseResponse("HEAD", config.install.routes.style, config, release).status).toBe(200);
|
|
expect(releaseResponse("HEAD", config.install.routes.script, config, release).status).toBe(200);
|
|
expect(releaseResponse("HEAD", config.install.routes.favicon, config, release).status).toBe(200);
|
|
expect(releaseResponse("HEAD", config.install.routes.panelImage, config, release).status).toBe(200);
|
|
const startCmd = await releaseResponse(
|
|
"GET",
|
|
config.install.routes.startCmd,
|
|
config,
|
|
release,
|
|
).text();
|
|
expect(startCmd).toContain("[reuse] private Python");
|
|
expect(startCmd).toContain("system_python_unavailable");
|
|
expect(startCmd.indexOf("Find-Python $runtime.minimumVersion")).toBeLessThan(
|
|
startCmd.indexOf('Test-Python ([System.IO.Path]::Combine($runtimeRoot'),
|
|
);
|
|
expect(startCmd).toContain("speed={3:N1} KiB/s elapsed={4}s eta={5}s");
|
|
expect(startCmd).toContain("registry.npmmirror.com");
|
|
expect(startCmd).toContain("www.python.org");
|
|
expect(startCmd).not.toMatch(/__[A-Z_]+__/u);
|
|
const bootstrap = await releaseResponse(
|
|
"GET",
|
|
config.install.routes.bootstrap,
|
|
config,
|
|
release,
|
|
).text();
|
|
expect(bootstrap).toContain("https://apps.hwpod.com/v1/unideskcprovider/update.json");
|
|
expect(bootstrap).toContain("schedule_provider_handoff");
|
|
expect(bootstrap).toContain("([wmiclass]'Win32_Process').Create");
|
|
expect(bootstrap).not.toContain("stop_existing_provider");
|
|
expect(bootstrap).not.toMatch(/__[A-Z_]+__/u);
|
|
});
|
|
|
|
test("Linux installer is zero-argument, YAML-first, and systemd native", async () => {
|
|
const installer = await releaseResponse(
|
|
"GET",
|
|
config.install.routes.linuxInstaller,
|
|
config,
|
|
release,
|
|
).text();
|
|
expect(installer).toContain('config_path="${config_root}/provider.yaml"');
|
|
expect(installer).toContain("pythonRuntime");
|
|
expect(installer).toContain("systemctl enable");
|
|
expect(installer).toContain("Restart=always");
|
|
expect(installer).toContain('"$config_root/logs"');
|
|
expect(installer).toContain("curl --fail --location --show-error --progress-bar");
|
|
expect(installer).not.toContain("docker");
|
|
expect(installer).not.toContain("node");
|
|
expect(installer).not.toMatch(/__[A-Z_]+__/u);
|
|
expect(releaseResponse("HEAD", config.install.routes.linuxInstaller, config, release).status).toBe(200);
|
|
});
|
|
|
|
test("CLI help keeps one YAML-owned native lifecycle", () => {
|
|
const help = providerAppsHelp();
|
|
expect(help.config).toBe("config/platform-infra/apps.yaml");
|
|
expect(help.usage).toContain("bun scripts/cli.ts provider-apps server status");
|
|
expect(help.usage).toContain("bun scripts/cli.ts provider-apps linux help");
|
|
});
|
|
|
|
test("Linux apply jobs expose bounded status and logs without touching a runtime", async () => {
|
|
const stateDir = mkdtempSync(join(tmpdir(), "provider-apps-linux-job-test-"));
|
|
const jobId = "abcdef123456";
|
|
const jobRoot = join(stateDir, "linux-jobs", jobId);
|
|
const testConfig = { ...config, runtime: { ...config.runtime, stateDir, maxLogTailBytes: 8 } };
|
|
try {
|
|
mkdirSync(jobRoot, { recursive: true });
|
|
writeFileSync(join(jobRoot, "status.json"), JSON.stringify({
|
|
jobId,
|
|
operation: "provider-apps.linux.apply",
|
|
target: "D601-VM-HOST",
|
|
status: "succeeded",
|
|
}));
|
|
writeFileSync(join(jobRoot, "stdout.log"), "prefix-complete\n");
|
|
const status = await runProviderAppsLinuxCommand(testConfig, ["apply-status", "--target", "D601-VM-HOST", "--id", jobId]);
|
|
const logs = await runProviderAppsLinuxCommand(testConfig, ["apply-logs", "--target", "D601-VM-HOST", "--id", jobId]);
|
|
expect(status).toMatchObject({ ok: true, operation: "provider-apps.linux.apply-status", jobId, status: "succeeded", running: false });
|
|
expect(logs).toMatchObject({ ok: true, operation: "provider-apps.linux.apply-logs", jobId, stdoutTail: "omplete\n", stderrTail: "" });
|
|
|
|
writeFileSync(join(jobRoot, "status.json"), JSON.stringify({
|
|
jobId,
|
|
operation: "provider-apps.linux.apply",
|
|
target: "D601-VM-HOST",
|
|
status: "queued",
|
|
}));
|
|
writeFileSync(join(jobRoot, "runner.pid"), "0\n");
|
|
const stopped = await runProviderAppsLinuxCommand(testConfig, ["apply-status", "--target", "D601-VM-HOST", "--id", jobId]);
|
|
expect(stopped).toMatchObject({
|
|
ok: false,
|
|
status: "failed",
|
|
recordedStatus: "queued",
|
|
running: false,
|
|
error: expect.stringContaining("exited before recording a terminal status"),
|
|
});
|
|
} finally {
|
|
rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|