File size: 7,401 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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// Type definitions for the module exports
type NormalizeVersion = (version: string | null) => string | null;
type VersionsEqual = (v1: string, v2: string) => boolean;
type ParseSdkVersions = (requiresDist: string[]) => Record<string, string>;
type FindClientPinMismatch = (
  pinned: string | null,
  expected: string,
) => { package: string; expected: string; actual: string | null } | null;
type ReadClientPin = () => string | null;

// Import after mocking - need dynamic import since the script has side effects
describe("check-sdk-version-sync helpers", () => {
  let normalizeVersion: NormalizeVersion;
  let versionsEqual: VersionsEqual;
  let parseSdkVersionsFromRequiresDist: ParseSdkVersions;
  let SDK_PACKAGES: string[];
  let findClientPinMismatch: FindClientPinMismatch;
  let readClientPin: ReadClientPin;
  let CLIENT_PACKAGE_NAME: string;

  beforeEach(async () => {
    // Reset modules to get fresh imports
    vi.resetModules();

    // Mock console to suppress output during tests
    vi.spyOn(console, "log").mockImplementation(() => {});
    vi.spyOn(console, "error").mockImplementation(() => {});

    // Dynamic import to get fresh module
    const module = await import("../../scripts/check-sdk-version-sync.mjs");
    normalizeVersion = module.normalizeVersion as NormalizeVersion;
    versionsEqual = module.versionsEqual as VersionsEqual;
    parseSdkVersionsFromRequiresDist =
      module.parseSdkVersionsFromRequiresDist as ParseSdkVersions;
    SDK_PACKAGES = module.SDK_PACKAGES as string[];
    findClientPinMismatch =
      module.findClientPinMismatch as FindClientPinMismatch;
    readClientPin = module.readClientPin as ReadClientPin;
    CLIENT_PACKAGE_NAME = module.CLIENT_PACKAGE_NAME as string;
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe("normalizeVersion", () => {
    it("returns null for null input", () => {
      expect(normalizeVersion(null)).toBe(null);
    });

    it("pads two-part versions to three parts", () => {
      expect(normalizeVersion("1.22")).toBe("1.22.0");
    });

    it("keeps three-part versions as-is", () => {
      expect(normalizeVersion("1.22.0")).toBe("1.22.0");
    });

    it("truncates versions with more than three parts", () => {
      expect(normalizeVersion("1.22.0.1")).toBe("1.22.0");
    });

    it("strips pre-release metadata", () => {
      expect(normalizeVersion("1.22.0-beta.1")).toBe("1.22.0");
      expect(normalizeVersion("1.22.0-alpha")).toBe("1.22.0");
    });

    it("strips build metadata", () => {
      expect(normalizeVersion("1.22.0+build.123")).toBe("1.22.0");
    });

    it("handles versions with both pre-release and build metadata", () => {
      expect(normalizeVersion("1.22.0-beta+build")).toBe("1.22.0");
    });
  });

  describe("versionsEqual", () => {
    it("returns true for identical versions", () => {
      expect(versionsEqual("1.22.0", "1.22.0")).toBe(true);
    });

    it("returns true for semantically equivalent versions", () => {
      expect(versionsEqual("1.22", "1.22.0")).toBe(true);
      expect(versionsEqual("1.22.0", "1.22")).toBe(true);
    });

    it("returns false for different versions", () => {
      expect(versionsEqual("1.21.0", "1.22.0")).toBe(false);
      expect(versionsEqual("1.22.0", "1.22.1")).toBe(false);
    });

    it("ignores pre-release metadata for base comparison", () => {
      expect(versionsEqual("1.22.0-beta", "1.22.0")).toBe(true);
    });
  });

  describe("parseSdkVersionsFromRequiresDist", () => {
    it("parses standard PEP 508 format with >=", () => {
      const deps = [
        "openhands-sdk>=1.22.0,<2.0.0",
        "openhands-workspace>=1.22.0",
      ];
      const versions = parseSdkVersionsFromRequiresDist(deps);

      expect(versions["openhands-sdk"]).toBe("1.22.0");
      expect(versions["openhands-workspace"]).toBe("1.22.0");
    });

    it("parses exact version pins with ==", () => {
      const deps = ["openhands-tools==1.21.1"];
      const versions = parseSdkVersionsFromRequiresDist(deps);

      expect(versions["openhands-tools"]).toBe("1.21.1");
    });

    it("parses parenthesized format", () => {
      const deps = ["openhands-sdk (>=1.22.0)"];
      const versions = parseSdkVersionsFromRequiresDist(deps);

      expect(versions["openhands-sdk"]).toBe("1.22.0");
    });

    it("returns empty object for no matching packages", () => {
      const deps = ["requests>=2.0.0", "flask>=1.0.0"];
      const versions = parseSdkVersionsFromRequiresDist(deps);

      expect(Object.keys(versions)).toHaveLength(0);
    });

    it("handles mixed dependencies", () => {
      const deps = [
        "requests>=2.0.0",
        "openhands-sdk>=1.22.0",
        "flask>=1.0.0",
        "openhands-workspace (>=1.21.0)",
      ];
      const versions = parseSdkVersionsFromRequiresDist(deps);

      expect(versions["openhands-sdk"]).toBe("1.22.0");
      expect(versions["openhands-workspace"]).toBe("1.21.0");
      expect(versions["requests"]).toBeUndefined();
    });

    it("handles tilde version specifier", () => {
      const deps = ["openhands-sdk~=1.22.0"];
      const versions = parseSdkVersionsFromRequiresDist(deps);

      expect(versions["openhands-sdk"]).toBe("1.22.0");
    });

    it("handles version with extras", () => {
      // PyPI sometimes includes extras in requires_dist
      const deps = ["openhands-sdk[all]>=1.22.0"];
      const versions = parseSdkVersionsFromRequiresDist(deps);

      // Current implementation may not handle extras perfectly,
      // but should at least not crash
      expect(versions).toBeDefined();
    });
  });

  describe("SDK_PACKAGES", () => {
    it("contains the expected SDK packages", () => {
      expect(SDK_PACKAGES).toContain("openhands-sdk");
      expect(SDK_PACKAGES).toContain("openhands-tools");
      expect(SDK_PACKAGES).toContain("openhands-workspace");
      expect(SDK_PACKAGES).toContain("openhands-agent-server");
      expect(SDK_PACKAGES).toHaveLength(4);
    });
  });

  describe("findClientPinMismatch", () => {
    it("accepts a pin equal to the expected agent-server version", () => {
      expect(findClientPinMismatch("1.46.0", "1.46.0")).toBeNull();
      expect(findClientPinMismatch("1.46", "1.46.0")).toBeNull();
    });

    it("reports the skew that ships a stale ACP picker", () => {
      // 1.39.0 against agent-server 1.46.0 is the drift that left GPT-6 Astra
      // out of the Codex picker while offering three ids the server rejects.
      expect(findClientPinMismatch("1.39.0", "1.46.0")).toEqual({
        package: CLIENT_PACKAGE_NAME,
        expected: "1.46.0",
        actual: "1.39.0",
      });
    });

    it("rejects a range, which would let the skew back in", () => {
      for (const range of ["^1.46.0", "~1.46.0", ">=1.46.0", "latest"]) {
        expect(findClientPinMismatch(range, "1.46.0"), range).not.toBeNull();
      }
    });

    it("reports an absent dependency rather than passing silently", () => {
      expect(findClientPinMismatch(null, "1.46.0")).toEqual({
        package: CLIENT_PACKAGE_NAME,
        expected: "1.46.0",
        actual: null,
      });
    });
  });

  describe("readClientPin", () => {
    it("reads an exact pin this repo actually ships", () => {
      expect(readClientPin()).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/);
    });
  });
});