Files
pikasTech-unidesk/scripts/src/provider-apps.test.ts
T
2026-07-20 18:40:54 +02:00

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 });
}
});
});