Skip to content

Commit 0a5ab57

Browse files
authored
feat(util-user-agent-node): populate typescript version in user agent when available (#7786)
1 parent b7c1aa2 commit 0a5ab57

File tree

6 files changed

+218
-0
lines changed

6 files changed

+218
-0
lines changed

packages-internal/util-user-agent-node/src/defaultUserAgent.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest"
55
import type { PreviouslyResolved } from "./defaultUserAgent";
66
import { createDefaultUserAgentProvider } from "./defaultUserAgent";
77
import { getRuntimeUserAgentPair } from "./getRuntimeUserAgentPair";
8+
import { getTypeScriptUserAgentPair } from "./getTypeScriptUserAgentPair";
89
import { isCrtAvailable } from "./is-crt-available";
910

1011
vi.mock("os");
1112
vi.mock("./getRuntimeUserAgentPair");
13+
vi.mock("./getTypeScriptUserAgentPair");
1214
vi.mock("./is-crt-available");
1315

1416
const validateUserAgent = (userAgent: UserAgent, expected: UserAgent) => {
@@ -25,6 +27,7 @@ describe("createDefaultUserAgentProvider", () => {
2527
vi.mocked(platform).mockReturnValue("darwin");
2628
vi.mocked(release).mockReturnValue("19.6.0");
2729
vi.mocked(getRuntimeUserAgentPair).mockReturnValue(["md/nodejs", "20.0.0"]);
30+
vi.mocked(getTypeScriptUserAgentPair).mockResolvedValue(undefined);
2831
vi.mocked(isCrtAvailable).mockReturnValue(null);
2932
delete process.env.AWS_EXECUTION_ENV;
3033
});
@@ -53,6 +56,13 @@ describe("createDefaultUserAgentProvider", () => {
5356
validateUserAgent(userAgent, basicUserAgent);
5457
});
5558

59+
it("should add typescript version if available", async () => {
60+
vi.mocked(getTypeScriptUserAgentPair).mockResolvedValue(["md/tsc", "5.9.3"]);
61+
const userAgentProvider = createDefaultUserAgentProvider({ serviceId: "s3", clientVersion: "0.1.0" });
62+
const userAgent = await userAgentProvider(mockConfig);
63+
expect(userAgent).toContainEqual(["md/tsc", "5.9.3"]);
64+
});
65+
5666
it("should set crt available key if aws-crt is available in runtime", async () => {
5767
vi.mocked(isCrtAvailable).mockReturnValue(["md/crt-avail"]);
5868
const userAgentProvider = createDefaultUserAgentProvider({ serviceId: "s3", clientVersion: "0.1.0" });

packages-internal/util-user-agent-node/src/defaultUserAgent.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { platform, release } from "node:os";
33
import { env } from "node:process";
44

55
import { getRuntimeUserAgentPair } from "./getRuntimeUserAgentPair";
6+
import { getTypeScriptUserAgentPair } from "./getTypeScriptUserAgentPair";
67
import { isCrtAvailable } from "./is-crt-available";
78

89
/**
@@ -45,6 +46,11 @@ export const createDefaultUserAgentProvider = ({ serviceId, clientVersion }: Def
4546
runtimeUserAgentPair,
4647
];
4748

49+
const typescriptUserAgentPair = await getTypeScriptUserAgentPair();
50+
if (typescriptUserAgentPair) {
51+
sections.push(typescriptUserAgentPair);
52+
}
53+
4854
const crtAvailable = isCrtAvailable();
4955
if (crtAvailable) {
5056
sections.push(crtAvailable);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { join, sep } from "node:path";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { getTypeScriptPackageJsonPath } from "./getTypeScriptPackageJsonPath";
5+
6+
describe(getTypeScriptPackageJsonPath.name, () => {
7+
it("returns node_modules/typescript/package.json when dirname is empty", () => {
8+
expect(getTypeScriptPackageJsonPath()).toBe(join("node_modules", "typescript", "package.json"));
9+
});
10+
11+
it("returns path relative to dirname when not inside node_modules", () => {
12+
const dirname = join("some", "path");
13+
expect(getTypeScriptPackageJsonPath(dirname)).toBe(join(dirname, "node_modules", "typescript", "package.json"));
14+
});
15+
16+
it("returns path relative to first node_modules when inside node_modules", () => {
17+
const dirname = join("project", "node_modules", "@aws-sdk", "client-s3");
18+
expect(getTypeScriptPackageJsonPath(dirname)).toBe(join("project", "node_modules", "typescript", "package.json"));
19+
});
20+
21+
it("handles nested node_modules by using the first occurrence", () => {
22+
const dirname = join("project", "node_modules", "pkg", "node_modules", "nested");
23+
expect(getTypeScriptPackageJsonPath(dirname)).toBe(join("project", "node_modules", "typescript", "package.json"));
24+
});
25+
26+
it.each([
27+
[["foo", "."].join(sep), "foo"],
28+
[["foo", "..", "bar"].join(sep), "bar"],
29+
[["foo", ".", "bar"].join(sep), join("foo", "bar")],
30+
])("normalizes mixed path separators for '%s'", (dirname, nodeModulesPath) => {
31+
expect(getTypeScriptPackageJsonPath(dirname)).toBe(
32+
join(nodeModulesPath, "node_modules", "typescript", "package.json")
33+
);
34+
});
35+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { join, normalize, sep } from "node:path";
2+
3+
/**
4+
* Returns the path to the TypeScript package.json file relative to the given directory.
5+
*
6+
* @param dirname - The directory path to resolve from.
7+
* @returns The path to the TypeScript package.json file.
8+
*
9+
* @internal
10+
*/
11+
export const getTypeScriptPackageJsonPath = (dirname = ""): string => {
12+
let nodeModulesPath: string;
13+
14+
// Normalize the path to handle mixed separators
15+
const normalizedPath = normalize(dirname);
16+
17+
// Split into parts
18+
const parts = normalizedPath.split(sep);
19+
20+
// Find the index of the first "node_modules" segment
21+
const nodeModulesIndex = parts.indexOf("node_modules");
22+
23+
if (nodeModulesIndex !== -1) {
24+
// If we are inside node_modules, we use the first occurrence of 'node_modules'
25+
nodeModulesPath = parts.slice(0, nodeModulesIndex).join(sep);
26+
} else {
27+
// If we are not inside node_modules, we can use the current directory.
28+
nodeModulesPath = dirname;
29+
}
30+
31+
return join(nodeModulesPath, "node_modules", "typescript", "package.json");
32+
};
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { readFile } from "node:fs/promises";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { getTypeScriptPackageJsonPath } from "./getTypeScriptPackageJsonPath";
5+
6+
vi.mock("node:fs/promises");
7+
vi.mock("./getTypeScriptPackageJsonPath");
8+
9+
describe("getTypeScriptUserAgentPair", () => {
10+
const mockTscVersion = "5.9.3";
11+
const mockPackageJsonPath = "/mock/node_modules/typescript/package.json";
12+
13+
beforeEach(() => {
14+
vi.mocked(getTypeScriptPackageJsonPath).mockReturnValue(mockPackageJsonPath);
15+
});
16+
17+
afterEach(() => {
18+
expect(readFile).toHaveBeenCalledWith(mockPackageJsonPath, "utf-8");
19+
vi.clearAllMocks();
20+
vi.resetModules();
21+
});
22+
23+
describe("when typescript/package.json is available", () => {
24+
beforeEach(() => {
25+
vi.mocked(readFile).mockResolvedValue(JSON.stringify({ version: mockTscVersion }));
26+
});
27+
28+
it("returns version", async () => {
29+
const { getTypeScriptUserAgentPair } = await import("./getTypeScriptUserAgentPair");
30+
await expect(getTypeScriptUserAgentPair()).resolves.toEqual(["md/tsc", mockTscVersion]);
31+
});
32+
33+
it("returns cached version on subsequent calls", async () => {
34+
const { getTypeScriptUserAgentPair } = await import("./getTypeScriptUserAgentPair");
35+
36+
await expect(getTypeScriptUserAgentPair()).resolves.toEqual(["md/tsc", mockTscVersion]);
37+
await expect(getTypeScriptUserAgentPair()).resolves.toEqual(["md/tsc", mockTscVersion]);
38+
39+
expect(readFile).toHaveBeenCalledTimes(1);
40+
});
41+
42+
it("returns cached version on subsequent calls even if it's an empty string", async () => {
43+
vi.mocked(readFile).mockResolvedValue(JSON.stringify({ version: "" }));
44+
45+
const { getTypeScriptUserAgentPair } = await import("./getTypeScriptUserAgentPair");
46+
await expect(getTypeScriptUserAgentPair()).resolves.toEqual(["md/tsc", ""]);
47+
await expect(getTypeScriptUserAgentPair()).resolves.toEqual(["md/tsc", ""]);
48+
49+
expect(readFile).toHaveBeenCalledTimes(1);
50+
});
51+
52+
it("returns cached version on subsequent calls if version is not defined", async () => {
53+
vi.mocked(readFile).mockResolvedValue(JSON.stringify({ name: "blah" }));
54+
55+
const { getTypeScriptUserAgentPair } = await import("./getTypeScriptUserAgentPair");
56+
await expect(getTypeScriptUserAgentPair()).resolves.toBeUndefined();
57+
await expect(getTypeScriptUserAgentPair()).resolves.toBeUndefined();
58+
59+
expect(readFile).toHaveBeenCalledTimes(1);
60+
});
61+
});
62+
63+
describe("when reading typescript/package.json throws error", () => {
64+
beforeEach(() => {
65+
vi.mocked(readFile).mockRejectedValue(new Error("File not found"));
66+
});
67+
68+
it("returns undefined when typescript package.json read fails", async () => {
69+
const { getTypeScriptUserAgentPair } = await import("./getTypeScriptUserAgentPair");
70+
await expect(getTypeScriptUserAgentPair()).resolves.toBeUndefined();
71+
});
72+
73+
it("returns undefined on subsequent calls after read failure", async () => {
74+
const { getTypeScriptUserAgentPair } = await import("./getTypeScriptUserAgentPair");
75+
76+
await expect(getTypeScriptUserAgentPair()).resolves.toBeUndefined();
77+
await expect(getTypeScriptUserAgentPair()).resolves.toBeUndefined();
78+
79+
expect(readFile).toHaveBeenCalledTimes(1);
80+
});
81+
});
82+
83+
describe("when typescript/package.json is not a valid JSON", () => {
84+
beforeEach(() => {
85+
vi.mocked(readFile).mockResolvedValue("Not a JSON");
86+
});
87+
88+
it("returns undefined when typescript package.json parse fails", async () => {
89+
const { getTypeScriptUserAgentPair } = await import("./getTypeScriptUserAgentPair");
90+
await expect(getTypeScriptUserAgentPair()).resolves.toBeUndefined();
91+
});
92+
93+
it("returns undefined on subsequent calls after parse failure", async () => {
94+
const { getTypeScriptUserAgentPair } = await import("./getTypeScriptUserAgentPair");
95+
96+
await expect(getTypeScriptUserAgentPair()).resolves.toBeUndefined();
97+
await expect(getTypeScriptUserAgentPair()).resolves.toBeUndefined();
98+
99+
expect(readFile).toHaveBeenCalledTimes(1);
100+
});
101+
});
102+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { UserAgentPair } from "@smithy/types";
2+
import { readFile } from "node:fs/promises";
3+
4+
import { getTypeScriptPackageJsonPath } from "./getTypeScriptPackageJsonPath";
5+
6+
let tscVersion: string | null | undefined;
7+
8+
/**
9+
* Returns the tyescript name and version as a user agent pair, if present.
10+
* @internal
11+
*/
12+
export const getTypeScriptUserAgentPair = async (): Promise<UserAgentPair | undefined> => {
13+
// If tscVersion is set from previous calls.
14+
if (tscVersion === null) {
15+
return undefined;
16+
} else if (typeof tscVersion === "string") {
17+
return ["md/tsc", tscVersion];
18+
}
19+
20+
try {
21+
const packageJson = await readFile(getTypeScriptPackageJsonPath(__dirname), "utf-8");
22+
const { version } = JSON.parse(packageJson);
23+
if (typeof version !== "string") {
24+
tscVersion = null;
25+
return undefined;
26+
}
27+
tscVersion = version;
28+
return ["md/tsc", tscVersion];
29+
} catch {
30+
// Ignore error in case of failure in file read or JSON parsing.
31+
tscVersion = null;
32+
}
33+
};

0 commit comments

Comments
 (0)