File size: 5,479 Bytes
c0af099
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// @vitest-environment node
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";

const packageJson = JSON.parse(
  readFileSync(resolve(__dirname, "../package.json"), "utf8"),
) as {
  name: string;
  main: string;
  module: string;
  types: string;
  exports: Record<string, unknown>;
  scripts: Record<string, string>;
  dependencies?: Record<string, string>;
  devDependencies?: Record<string, string>;
};

const EXACT_SEMVER_PATTERN =
  /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;

describe("package library metadata", () => {
  const ALLOWED_STACK_PIN_DEPS = new Set([
    "@openhands/extensions",
    "@openhands/typescript-client",
  ]);

  it("publishes the agent-canvas package entrypoints", () => {
    expect(packageJson.name).toBe("@openhands/agent-canvas");
    expect(packageJson.main).toBe("./dist/index.cjs");
    expect(packageJson.module).toBe("./dist/index.js");
    expect(packageJson.types).toBe("./dist/index.d.ts");
    expect(packageJson.exports).toMatchObject({
      ".": {
        types: "./dist/index.d.ts",
        import: "./dist/index.js",
        require: "./dist/index.cjs",
      },
      "./conversation": {
        types: "./dist/components/conversation/index.d.ts",
        import: "./dist/components/conversation/index.js",
        require: "./dist/components/conversation/index.cjs",
      },
      "./settings": {
        types: "./dist/components/settings/index.d.ts",
        import: "./dist/components/settings/index.js",
        require: "./dist/components/settings/index.cjs",
      },
      "./terminal": {
        types: "./dist/components/terminal/index.d.ts",
        import: "./dist/components/terminal/index.js",
        require: "./dist/components/terminal/index.cjs",
      },
      "./i18n": {
        types: "./dist/i18n/index.d.ts",
        import: "./dist/i18n/index.js",
        require: "./dist/i18n/index.cjs",
      },
    });
  });

  // Git dependencies break `npm install -g` because npm clones the repo and
  // runs the prepare script without devDependencies. All packages should be
  // referenced from a registry. @openhands/extensions is allowed until it is
  // published to npm; @openhands/typescript-client is temporarily allowed while
  // this stacked PR waits for the subscription client branch to merge/release.
  // TODO(#917): remove @openhands/typescript-client exemption once
  // OpenHands/typescript-client#178 merges and publishes to npm.
  it("does not use git dependencies except approved stack pins", () => {
    const GIT_DEP_PATTERN =
      /^(git[+:]|github:|bitbucket:|gitlab:|[a-zA-Z0-9_-]+\/)/;
    const allDeps = {
      ...packageJson.dependencies,
      ...packageJson.devDependencies,
    };

    const violations = Object.entries(allDeps)
      .filter(
        ([name, version]) =>
          GIT_DEP_PATTERN.test(version) && !ALLOWED_STACK_PIN_DEPS.has(name),
      )
      .map(([name, version]) => `${name}: ${version}`);

    expect(violations).toEqual([]);
  });

  it("pins direct dependency versions exactly", () => {
    const allDepsBySection = {
      dependencies: packageJson.dependencies,
      devDependencies: packageJson.devDependencies,
    };

    const violations = Object.entries(allDepsBySection).flatMap(
      ([section, dependencies]) =>
        Object.entries(dependencies ?? {})
          .filter(
            ([name, version]) =>
              !EXACT_SEMVER_PATTERN.test(version) &&
              !ALLOWED_STACK_PIN_DEPS.has(name),
          )
          .map(([name, version]) => `${section}.${name}: ${version}`),
    );

    expect(violations).toEqual([]);
  });

  it("prints startup guidance only for global installs", () => {
    const runPostinstall = (isGlobal: boolean) => {
      const env = { ...process.env };
      if (isGlobal) {
        env.npm_config_global = "true";
      } else {
        delete env.npm_config_global;
      }

      return spawnSync(packageJson.scripts.postinstall, {
        encoding: "utf8",
        env,
        shell: true,
      });
    };

    const dependencyInstall = runPostinstall(false);
    const globalInstall = runPostinstall(true);

    expect(dependencyInstall.status).toBe(0);
    expect(dependencyInstall.stdout).toBe("");
    expect(globalInstall.status).toBe(0);
    expect(globalInstall.stdout).toContain("To start Agent Canvas, run:");
  });

  it("ships runtime logger dependencies for the published CLI", () => {
    expect(packageJson.dependencies).toMatchObject({
      winston: "3.19.0",
      "winston-daily-rotate-file": "5.0.0",
    });
    expect(packageJson.devDependencies?.winston).toBeUndefined();
    expect(
      packageJson.devDependencies?.["winston-daily-rotate-file"],
    ).toBeUndefined();
  });

  it("uses local dev commands without Docker", () => {
    expect(packageJson.scripts.dev).toBe(
      "node --env-file-if-exists=.env scripts/dev-with-automation.mjs",
    );
    expect(packageJson.scripts["dev:static"]).toBe(
      "node --env-file-if-exists=.env scripts/dev-static.mjs",
    );
    expect(packageJson.scripts["dev:minimal"]).toBe(
      "node --env-file-if-exists=.env scripts/dev-safe.mjs",
    );
    expect(packageJson.scripts["dev:docker"]).toBeUndefined();
    expect(packageJson.scripts["dev:docker:dynamic"]).toBeUndefined();
    expect(packageJson.scripts["dev:dangerously-dockerless"]).toBeUndefined();
  });
});