File size: 5,342 Bytes
3201ca6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// @vitest-environment node
//
// Drift-detection for the examples/acp-docker quickstart. Invariants enforced:
// the generated pin must equal versions.agentServer from the single source of
// truth (config/defaults.json); the no-config compose fallback must stay on
// `latest-python`; and the pinned tag must never fall below the Canvas
// compatibility floor (compatibility.minimumAgentServer). Any of those drifting
// fails this test.
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";

import {
  computeAgentServerImage,
  renderEnvLine,
  upsertEnvLine,
} from "../../scripts/gen-acp-docker-env.mjs";

const repoRoot = path.resolve(
  path.dirname(fileURLToPath(import.meta.url)),
  "../..",
);

function read(rel: string): string {
  return readFileSync(path.join(repoRoot, rel), "utf-8");
}

const config = JSON.parse(read("config/defaults.json")) as {
  images: { agentServer: string };
  versions: { agentServer: string };
  compatibility: { minimumAgentServer: string };
};

const pinnedImage = `${config.images.agentServer}:${config.versions.agentServer}-python`;

// Numeric-semver comparison. Throws (rather than silently comparing NaN) if a
// pin carries a non-numeric segment — these defaults.json fields are dotted
// numeric version pins, so a sha or pre-release tag landing here is a config
// error the floor check should surface loudly.
function parseSemver(v: string): number[] {
  if (!/^\d+(\.\d+)*$/.test(v)) {
    throw new Error(`expected a dotted numeric version, got "${v}"`);
  }
  return v.split(".").map(Number);
}

function gte(a: string, b: string): boolean {
  const pa = parseSemver(a);
  const pb = parseSemver(b);
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const da = pa[i] ?? 0;
    const db = pb[i] ?? 0;
    if (da !== db) return da > db;
  }
  return true;
}

describe("examples/acp-docker stays in sync with config/defaults.json", () => {
  it("computeAgentServerImage derives the pinned SoT tag", () => {
    expect(computeAgentServerImage(config)).toBe(pinnedImage);
  });

  it("renders the AGENT_SERVER_IMAGE line with the pinned tag", () => {
    expect(renderEnvLine(config)).toBe(`AGENT_SERVER_IMAGE=${pinnedImage}`);
  });

  it("the pinned SoT version satisfies the Canvas compatibility floor", () => {
    expect(
      gte(config.versions.agentServer, config.compatibility.minimumAgentServer),
    ).toBe(true);
  });

  it("the no-config compose fallback uses the SoT registry with the latest-python tag", () => {
    const compose = read("examples/acp-docker/docker-compose.yml");
    const match = compose.match(/AGENT_SERVER_IMAGE:-([^}]+)\}/);
    expect(match?.[1]).toBe(`${config.images.agentServer}:latest-python`);
  });
});

describe("gen-acp-docker-env.mjs is safe to import", () => {
  // The module exports helpers (imported above, and by future consumers), so
  // importing it must not run main(). The entrypoint guard compares
  // import.meta.url against process.argv[1] — but argv[1] is undefined in some
  // ESM contexts (e.g. `node --input-type=module -e "import(...)"`), and an
  // unguarded pathToFileURL(argv[1]) throws ERR_INVALID_ARG_TYPE at import,
  // before any export is reachable. Reproduces that exact context.
  const scriptPath = path.join(repoRoot, "scripts", "gen-acp-docker-env.mjs");

  it("imports without throwing when process.argv[1] is undefined", () => {
    const url = pathToFileURL(scriptPath).href;
    const res = spawnSync(
      process.execPath,
      ["--input-type=module", "-e", `import(${JSON.stringify(url)})`],
      { encoding: "utf-8" },
    );
    expect(res.stderr).not.toMatch(/ERR_INVALID_ARG_TYPE/);
    expect(res.status).toBe(0);
  });
});

describe("upsertEnvLine", () => {
  const line =
    "AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:1.28.1-python";

  it("appends the line to an empty file", () => {
    expect(upsertEnvLine("", line)).toBe(`${line}\n`);
  });

  it("replaces an existing assignment in place, preserving other lines", () => {
    const existing =
      "SESSION_API_KEY=abc\n" +
      "AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:1.25.0-python\n" +
      "CLAUDE_CODE_OAUTH_TOKEN=zzz\n";
    expect(upsertEnvLine(existing, line)).toBe(
      `SESSION_API_KEY=abc\n${line}\nCLAUDE_CODE_OAUTH_TOKEN=zzz\n`,
    );
  });

  it("is idempotent — re-running yields one assignment, unchanged content", () => {
    const once = upsertEnvLine("", line);
    expect(upsertEnvLine(once, line)).toBe(once);
    expect(once.match(/^AGENT_SERVER_IMAGE=/gm)?.length).toBe(1);
  });

  it("treats a commented template line as documentation and appends the real value", () => {
    // .env.example ships `# AGENT_SERVER_IMAGE=...` as a documented knob; after
    // `cp .env.example .env` the comment stays and the generator adds the value.
    const existing =
      "# AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:latest-python\n";
    expect(upsertEnvLine(existing, line)).toBe(
      `${existing.trimEnd()}\n${line}\n`,
    );
  });

  it("throws rather than rewrite every line when given a keyless line", () => {
    expect(() => upsertEnvLine("FOO=bar\n", "novalue")).toThrow();
  });
});