236 lines
8.5 KiB
TypeScript
236 lines
8.5 KiB
TypeScript
import path from "node:path";
|
|
import { describe, expect, test } from "bun:test";
|
|
|
|
import {
|
|
buildDetachedRespawnArgs,
|
|
buildHeadlessCorsOrigins,
|
|
buildHeadlessRuntimeManifest,
|
|
buildHeadlessServerLaunch,
|
|
buildOpenworkServerArgs,
|
|
isHeadlessStackCommand,
|
|
mergeHeadlessServerConfig,
|
|
normalizeDenTarget,
|
|
resolveHeadlessRuntimeManifestPath,
|
|
resolveHeadlessServerConfigPath,
|
|
resolveHeadlessTokens,
|
|
} from "./dev-headless-web-lib";
|
|
|
|
describe("dev-headless-web helpers", () => {
|
|
test("isolates server config under tmp by default", () => {
|
|
const cwd = "/repo/openwork";
|
|
expect(resolveHeadlessServerConfigPath(cwd)).toBe(
|
|
path.join(cwd, "tmp", "headless-server.json"),
|
|
);
|
|
expect(resolveHeadlessRuntimeManifestPath(cwd)).toBe(
|
|
path.join(cwd, "tmp", "dev-headless-web.json"),
|
|
);
|
|
expect(
|
|
resolveHeadlessServerConfigPath(cwd, "tmp/custom-server.json"),
|
|
).toBe(path.join(cwd, "tmp", "custom-server.json"));
|
|
});
|
|
|
|
test("bootstraps a fresh config with the workspace registered and authorized", () => {
|
|
expect(mergeHeadlessServerConfig(null, "/Users/me/project")).toEqual({
|
|
authorizedRoots: ["/Users/me/project"],
|
|
workspaces: [{ path: "/Users/me/project" }],
|
|
});
|
|
});
|
|
|
|
test("relaunch merge preserves server-persisted workspaces and roots", () => {
|
|
const persistedByServer = JSON.stringify({
|
|
authorizedRoots: ["/Users/me/project", "/Users/me/added-folder"],
|
|
workspaces: [
|
|
{ id: "ws_added", path: "/Users/me/added-folder", name: "added-folder" },
|
|
{ id: "ws_main", path: "/Users/me/project", name: "project" },
|
|
],
|
|
readOnly: false,
|
|
});
|
|
expect(mergeHeadlessServerConfig(persistedByServer, "/Users/me/project")).toEqual({
|
|
authorizedRoots: ["/Users/me/project", "/Users/me/added-folder"],
|
|
workspaces: [
|
|
{ id: "ws_added", path: "/Users/me/added-folder", name: "added-folder" },
|
|
{ id: "ws_main", path: "/Users/me/project", name: "project" },
|
|
],
|
|
readOnly: false,
|
|
});
|
|
});
|
|
|
|
test("merge registers the workspace when missing and survives corrupt config", () => {
|
|
const missingWorkspace = JSON.stringify({
|
|
authorizedRoots: ["/Users/me/other"],
|
|
workspaces: [{ path: "/Users/me/other" }],
|
|
});
|
|
expect(mergeHeadlessServerConfig(missingWorkspace, "/Users/me/project")).toEqual({
|
|
authorizedRoots: ["/Users/me/other", "/Users/me/project"],
|
|
workspaces: [{ path: "/Users/me/other" }, { path: "/Users/me/project" }],
|
|
});
|
|
expect(mergeHeadlessServerConfig("not-json{{", "/Users/me/project")).toEqual({
|
|
authorizedRoots: ["/Users/me/project"],
|
|
workspaces: [{ path: "/Users/me/project" }],
|
|
});
|
|
});
|
|
|
|
test("tokens are reused from the previous manifest across relaunches", () => {
|
|
expect(
|
|
resolveHeadlessTokens({
|
|
envToken: undefined,
|
|
envHostToken: undefined,
|
|
previous: { token: "kept-token", hostToken: "kept-host-token" },
|
|
generate: () => "fresh",
|
|
}),
|
|
).toEqual({ token: "kept-token", hostToken: "kept-host-token" });
|
|
expect(
|
|
resolveHeadlessTokens({
|
|
envToken: "env-token",
|
|
envHostToken: undefined,
|
|
previous: { token: "kept-token", hostToken: "kept-host-token" },
|
|
generate: () => "fresh",
|
|
}),
|
|
).toEqual({ token: "env-token", hostToken: "kept-host-token" });
|
|
// `--replace` drops the previous manifest so leaked credentials die with
|
|
// the old process. `--keep-tokens` is the opt-in that passes previous.
|
|
expect(
|
|
resolveHeadlessTokens({
|
|
envToken: undefined,
|
|
envHostToken: undefined,
|
|
previous: null,
|
|
generate: () => "fresh",
|
|
}),
|
|
).toEqual({ token: "fresh", hostToken: "fresh" });
|
|
});
|
|
|
|
test("server args use --config only, never --workspace (would drop persisted workspaces)", () => {
|
|
const args = buildOpenworkServerArgs({
|
|
host: "127.0.0.1",
|
|
port: 8787,
|
|
configPath: "/repo/tmp/headless-server.json",
|
|
corsOrigins: ["http://127.0.0.1:5178"],
|
|
});
|
|
expect(args.slice(0, 2)).toEqual([
|
|
"--config",
|
|
"/repo/tmp/headless-server.json",
|
|
]);
|
|
expect(args).not.toContain("--workspace");
|
|
expect(args).not.toContain("--token");
|
|
expect(args).not.toContain("--host-token");
|
|
});
|
|
|
|
test("server launch always uses current source instead of compiled output", () => {
|
|
expect(buildHeadlessServerLaunch("/repo/openwork", ["--port", "8787"])).toEqual({
|
|
command: "bun",
|
|
args: [
|
|
"--conditions=development",
|
|
path.join("/repo/openwork", "apps/server/src/cli.ts"),
|
|
"--port",
|
|
"8787",
|
|
],
|
|
});
|
|
});
|
|
|
|
test("CORS is pinned to the web app origins, never wildcarded", () => {
|
|
const corsOrigins = buildHeadlessCorsOrigins({
|
|
webUrl: "http://127.0.0.1:5178",
|
|
webPort: 5178,
|
|
});
|
|
expect(corsOrigins).toEqual([
|
|
"http://127.0.0.1:5178",
|
|
"http://localhost:5178",
|
|
]);
|
|
// A public host still gets its own origin alongside the loopback ones.
|
|
expect(
|
|
buildHeadlessCorsOrigins({ webUrl: "http://dev.local:5178", webPort: 5178 }),
|
|
).toEqual([
|
|
"http://dev.local:5178",
|
|
"http://127.0.0.1:5178",
|
|
"http://localhost:5178",
|
|
]);
|
|
|
|
const args = buildOpenworkServerArgs({
|
|
host: "127.0.0.1",
|
|
port: 8787,
|
|
configPath: "/repo/tmp/headless-server.json",
|
|
corsOrigins,
|
|
});
|
|
expect(args[args.indexOf("--cors") + 1]).toBe(
|
|
"http://127.0.0.1:5178,http://localhost:5178",
|
|
);
|
|
expect(args).not.toContain("*");
|
|
});
|
|
|
|
test("runtime manifest carries agent-facing local-server fields", () => {
|
|
const manifest = buildHeadlessRuntimeManifest({
|
|
webUrl: "http://127.0.0.1:5178",
|
|
openworkUrl: "http://127.0.0.1:8778",
|
|
workspace: "/Users/me/project",
|
|
token: "client-token",
|
|
hostToken: "host-token",
|
|
serverConfigPath: "/repo/tmp/headless-server.json",
|
|
runtimeManifestPath: "/repo/tmp/dev-headless-web.json",
|
|
webLogPath: "/repo/tmp/dev-web.log",
|
|
headlessLogPath: "/repo/tmp/dev-headless.log",
|
|
denTarget: "https://app.openworklabs.com",
|
|
pid: 42,
|
|
webPid: 43,
|
|
openworkServerPid: 44,
|
|
startedAt: "2026-08-12T00:00:00.000Z",
|
|
});
|
|
|
|
expect(manifest.mode).toBe("local-server");
|
|
expect(manifest.healthUrl).toBe("http://127.0.0.1:8778/health");
|
|
expect(manifest.denTarget).toBe("https://app.openworklabs.com");
|
|
expect(manifest.denApiUrl).toBe("http://127.0.0.1:5178/api/den");
|
|
expect(manifest.token).toBe("client-token");
|
|
expect(manifest.notes).toContain("same-origin");
|
|
expect(manifest.pid).toBe(42);
|
|
expect(manifest.pids).toEqual({ launcher: 42, web: 43, openworkServer: 44 });
|
|
});
|
|
|
|
test("manifest omits Den fields when the Den wiring is disabled", () => {
|
|
const manifest = buildHeadlessRuntimeManifest({
|
|
webUrl: "http://127.0.0.1:5178",
|
|
openworkUrl: "http://127.0.0.1:8778",
|
|
workspace: "/Users/me/project",
|
|
token: "t",
|
|
hostToken: "h",
|
|
serverConfigPath: "/repo/tmp/headless-server.json",
|
|
runtimeManifestPath: "/repo/tmp/dev-headless-web.json",
|
|
webLogPath: "/repo/tmp/dev-web.log",
|
|
headlessLogPath: "/repo/tmp/dev-headless.log",
|
|
denTarget: null,
|
|
});
|
|
expect(manifest.denTarget).toBeNull();
|
|
expect(manifest.denApiUrl).toBeNull();
|
|
});
|
|
|
|
test("normalizes Den targets to origins", () => {
|
|
expect(normalizeDenTarget("https://app.openworklabs.com/api/den")).toBe(
|
|
"https://app.openworklabs.com",
|
|
);
|
|
expect(normalizeDenTarget("http://127.0.0.1:3005")).toBe(
|
|
"http://127.0.0.1:3005",
|
|
);
|
|
expect(normalizeDenTarget(undefined)).toBe("https://app.openworklabs.com");
|
|
});
|
|
|
|
test("detached respawn forwards args except --detach", () => {
|
|
expect(buildDetachedRespawnArgs(["--detach", "--replace", "--silent"])).toEqual([
|
|
"--replace",
|
|
"--silent",
|
|
]);
|
|
expect(buildDetachedRespawnArgs(["--detach"])).toEqual([]);
|
|
});
|
|
|
|
test("stale-pid cleanup only targets processes from this stack", () => {
|
|
expect(isHeadlessStackCommand("bun scripts/dev-headless-web.ts")).toBe(true);
|
|
expect(
|
|
isHeadlessStackCommand("/repo/apps/server/dist/bin/openwork-server --config tmp/headless-server.json"),
|
|
).toBe(true);
|
|
expect(
|
|
isHeadlessStackCommand("bun --conditions=development /repo/apps/server/src/cli.ts --config tmp/headless-server.json"),
|
|
).toBe(true);
|
|
expect(isHeadlessStackCommand("node vite --host 127.0.0.1 --port 5178")).toBe(true);
|
|
expect(isHeadlessStackCommand("/usr/bin/some-unrelated-daemon")).toBe(false);
|
|
expect(isHeadlessStackCommand("ssh user@host")).toBe(false);
|
|
});
|
|
});
|