about-system 0.0.65 → 0.0.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -7
- package/dist/about-system-cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/system-info-api-C_y-O4VW.js +2409 -0
- package/dist/system-info-api-C_y-O4VW.js.map +1 -0
- package/dist/system-info-api.js +1 -1
- package/package.json +5 -3
- package/src/cache/cache.test.ts +235 -0
- package/src/info/platform.test.ts +190 -0
- package/src/utils/utils.test.ts +218 -0
- package/dist/system-info-api-Ddz5VwDd.js +0 -2409
- package/dist/system-info-api-Ddz5VwDd.js.map +0 -1
package/dist/system-info-api.js
CHANGED
|
@@ -2,7 +2,7 @@ import "os";
|
|
|
2
2
|
import "fs";
|
|
3
3
|
import "https";
|
|
4
4
|
import "path";
|
|
5
|
-
import { g as m, i as r, l as p, s as n } from "./system-info-api-
|
|
5
|
+
import { g as m, i as r, l as p, s as n } from "./system-info-api-C_y-O4VW.js";
|
|
6
6
|
export {
|
|
7
7
|
m as getSystemInfo,
|
|
8
8
|
r as infoFunctions,
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "about-system",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.67",
|
|
4
4
|
"description": "A Node.js script to display key system information with emojis. Cross-platform support for Windows, macOS, and Linux with customizable output and caching.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"about-system": "
|
|
8
|
+
"about-system": "dist/about-system-cli.js"
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"exports": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"app:dev": "cd native && npm run dev",
|
|
40
40
|
"app:build": "cd native && npm run build:desktop",
|
|
41
41
|
"test": "vitest run",
|
|
42
|
+
"test:ci": "vitest run --reporter=junit --outputFile=./junit.xml --coverage --coverage.reporter=lcov",
|
|
42
43
|
"test:watch": "vitest",
|
|
43
44
|
"coverage": "vitest run --coverage"
|
|
44
45
|
},
|
|
@@ -63,7 +64,8 @@
|
|
|
63
64
|
"license": "rights.institute/prosper",
|
|
64
65
|
"repository": {
|
|
65
66
|
"type": "git",
|
|
66
|
-
"url": "https://github.com/OpenSourceAGI/dev-tools-starter-agent
|
|
67
|
+
"url": "git+https://github.com/OpenSourceAGI/dev-tools-starter-agent.git",
|
|
68
|
+
"directory": "packages/about-system-info"
|
|
67
69
|
},
|
|
68
70
|
"homepage": "https://github.com/OpenSourceAGI/dev-tools-starter-agent/tree/master/packages/about-system-info",
|
|
69
71
|
"files": [
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Covers the on-disk cache that keeps `about-system` fast: most
|
|
3
|
+
* of the facts it reports are expensive to collect (shelling out to `wmic`,
|
|
4
|
+
* `sw_vers`, `lspci`) and change rarely, so a stale-but-valid entry is the
|
|
5
|
+
* difference between an instant readout and a multi-second one.
|
|
6
|
+
*
|
|
7
|
+
* The behaviours worth pinning are the ones that decide whether a user sees
|
|
8
|
+
* anything at all: a corrupt cache file must not throw, an unwritable temp
|
|
9
|
+
* directory must not throw, and an expired entry must never be served.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type Cache,
|
|
17
|
+
getCachedValue,
|
|
18
|
+
isCacheValid,
|
|
19
|
+
loadCache,
|
|
20
|
+
saveCache,
|
|
21
|
+
setCachedValue,
|
|
22
|
+
} from "./cache";
|
|
23
|
+
import { CACHE_DURATION, CACHE_FILE } from "./cache-config";
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("loadCache", () => {
|
|
30
|
+
it("returns an empty cache when no file exists yet", () => {
|
|
31
|
+
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
|
32
|
+
expect(loadCache()).toEqual({});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("reads a previously saved cache back", () => {
|
|
36
|
+
const stored: Cache = { cpu: { value: "M2 Pro", timestamp: 123 } };
|
|
37
|
+
vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
|
38
|
+
vi.spyOn(fs, "readFileSync").mockReturnValue(JSON.stringify(stored));
|
|
39
|
+
|
|
40
|
+
expect(loadCache()).toEqual(stored);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reads from the temp-directory cache file", () => {
|
|
44
|
+
vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
|
45
|
+
const read = vi.spyOn(fs, "readFileSync").mockReturnValue("{}");
|
|
46
|
+
|
|
47
|
+
loadCache();
|
|
48
|
+
|
|
49
|
+
expect(read).toHaveBeenCalledWith(CACHE_FILE, "utf8");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("recovers from a corrupt cache file rather than throwing", () => {
|
|
53
|
+
vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
|
54
|
+
vi.spyOn(fs, "readFileSync").mockReturnValue("{ not json");
|
|
55
|
+
|
|
56
|
+
expect(loadCache()).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("recovers from an unreadable cache file", () => {
|
|
60
|
+
vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
|
61
|
+
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
|
|
62
|
+
throw new Error("EACCES");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(loadCache()).toEqual({});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("saveCache", () => {
|
|
70
|
+
it("writes the cache as readable json", () => {
|
|
71
|
+
const write = vi.spyOn(fs, "writeFileSync").mockImplementation(() => {});
|
|
72
|
+
const cache: Cache = { cpu: { value: "M2 Pro", timestamp: 1 } };
|
|
73
|
+
|
|
74
|
+
saveCache(cache);
|
|
75
|
+
|
|
76
|
+
expect(write).toHaveBeenCalledWith(
|
|
77
|
+
CACHE_FILE,
|
|
78
|
+
JSON.stringify(cache, null, 2),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("stays silent when the cache file cannot be written", () => {
|
|
83
|
+
vi.spyOn(fs, "writeFileSync").mockImplementation(() => {
|
|
84
|
+
throw new Error("EROFS");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(() => saveCache({})).not.toThrow();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("isCacheValid", () => {
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
vi.useFakeTimers();
|
|
94
|
+
vi.setSystemTime(1_000_000);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
vi.useRealTimers();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("accepts an entry written just now", () => {
|
|
102
|
+
expect(isCacheValid({ value: "x", timestamp: Date.now() }, "cpu")).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rejects an entry older than its key's duration", () => {
|
|
106
|
+
const timestamp = Date.now() - CACHE_DURATION.ram_used - 1;
|
|
107
|
+
expect(isCacheValid({ value: "x", timestamp }, "ram_used")).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("uses each key's own duration, not one global one", () => {
|
|
111
|
+
// A minute-old entry is stale for RAM (10s) but fresh for the CPU (24h).
|
|
112
|
+
const timestamp = Date.now() - 60_000;
|
|
113
|
+
expect(isCacheValid({ value: "x", timestamp }, "ram_used")).toBe(false);
|
|
114
|
+
expect(isCacheValid({ value: "x", timestamp }, "cpu")).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("falls back to a one-minute window for an unknown key", () => {
|
|
118
|
+
expect(
|
|
119
|
+
isCacheValid({ value: "x", timestamp: Date.now() - 59_000 }, "unknown"),
|
|
120
|
+
).toBe(true);
|
|
121
|
+
expect(
|
|
122
|
+
isCacheValid({ value: "x", timestamp: Date.now() - 61_000 }, "unknown"),
|
|
123
|
+
).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it.each([
|
|
127
|
+
[undefined, "missing"],
|
|
128
|
+
[null, "null"],
|
|
129
|
+
[{ value: "x" }, "timestamp-less"],
|
|
130
|
+
[{ value: "x", timestamp: 0 }, "zero-timestamped"],
|
|
131
|
+
])("rejects a %s entry", (entry) => {
|
|
132
|
+
expect(isCacheValid(entry as never, "cpu")).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("getCachedValue", () => {
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
vi.useFakeTimers();
|
|
139
|
+
vi.setSystemTime(1_000_000);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
vi.useRealTimers();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns null for a key that was never cached", () => {
|
|
147
|
+
expect(getCachedValue({}, "cpu")).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns a fresh value", () => {
|
|
151
|
+
const cache: Cache = { cpu: { value: "M2 Pro", timestamp: Date.now() } };
|
|
152
|
+
expect(getCachedValue(cache, "cpu")).toBe("M2 Pro");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns a cached falsy value rather than mistaking it for a miss", () => {
|
|
156
|
+
const cache: Cache = { battery: { value: 0, timestamp: Date.now() } };
|
|
157
|
+
expect(getCachedValue(cache, "battery")).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns null for an expired value", () => {
|
|
161
|
+
const cache: Cache = {
|
|
162
|
+
ram_used: { value: "8 GB", timestamp: Date.now() - 60_000 },
|
|
163
|
+
};
|
|
164
|
+
expect(getCachedValue(cache, "ram_used")).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("evicts an expired entry so it is not re-checked", () => {
|
|
168
|
+
const cache: Cache = {
|
|
169
|
+
ram_used: { value: "8 GB", timestamp: Date.now() - 60_000 },
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
getCachedValue(cache, "ram_used");
|
|
173
|
+
|
|
174
|
+
expect(cache).not.toHaveProperty("ram_used");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("keeps a fresh entry in place", () => {
|
|
178
|
+
const cache: Cache = { cpu: { value: "M2 Pro", timestamp: Date.now() } };
|
|
179
|
+
getCachedValue(cache, "cpu");
|
|
180
|
+
expect(cache).toHaveProperty("cpu");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("setCachedValue", () => {
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
vi.useFakeTimers();
|
|
187
|
+
vi.setSystemTime(1_000_000);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
afterEach(() => {
|
|
191
|
+
vi.useRealTimers();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("stamps the value with the time it was cached", () => {
|
|
195
|
+
const cache: Cache = {};
|
|
196
|
+
setCachedValue(cache, "cpu", "M2 Pro");
|
|
197
|
+
|
|
198
|
+
expect(cache.cpu).toEqual({ value: "M2 Pro", timestamp: 1_000_000 });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("round-trips through getCachedValue", () => {
|
|
202
|
+
const cache: Cache = {};
|
|
203
|
+
setCachedValue(cache, "cpu", "M2 Pro");
|
|
204
|
+
expect(getCachedValue(cache, "cpu")).toBe("M2 Pro");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("replaces an existing entry and refreshes its timestamp", () => {
|
|
208
|
+
const cache: Cache = { cpu: { value: "old", timestamp: 1 } };
|
|
209
|
+
setCachedValue(cache, "cpu", "new");
|
|
210
|
+
|
|
211
|
+
expect(cache.cpu).toEqual({ value: "new", timestamp: 1_000_000 });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("stores structured values, not just strings", () => {
|
|
215
|
+
const cache: Cache = {};
|
|
216
|
+
setCachedValue(cache, "network_interfaces", [{ name: "en0" }]);
|
|
217
|
+
|
|
218
|
+
expect(getCachedValue(cache, "network_interfaces")).toEqual([
|
|
219
|
+
{ name: "en0" },
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("CACHE_DURATION", () => {
|
|
225
|
+
it("gives every cached key a positive window", () => {
|
|
226
|
+
for (const [key, duration] of Object.entries(CACHE_DURATION)) {
|
|
227
|
+
expect(duration, key).toBeGreaterThan(0);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("caches volatile readings for less time than fixed hardware facts", () => {
|
|
232
|
+
expect(CACHE_DURATION.ram_used).toBeLessThan(CACHE_DURATION.cpu);
|
|
233
|
+
expect(CACHE_DURATION.top_process).toBeLessThan(CACHE_DURATION.os);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Covers the platform facts `about-system` opens its readout
|
|
3
|
+
* with: user, hostname, OS name, kernel and device model.
|
|
4
|
+
*
|
|
5
|
+
* Each of these shells out to a different tool per platform and parses its
|
|
6
|
+
* output, so the tests drive the Linux path (the one CI runs on) end to end and
|
|
7
|
+
* pin the two behaviours that hold everywhere: the cache is consulted before
|
|
8
|
+
* any command runs, and a missing tool degrades to a fallback rather than
|
|
9
|
+
* throwing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
// These modules bind their imports by name, so the dependencies have to be
|
|
15
|
+
// mocked outright rather than spied on through a namespace object.
|
|
16
|
+
const execCommand = vi.hoisted(() => vi.fn());
|
|
17
|
+
const commandExists = vi.hoisted(() => vi.fn());
|
|
18
|
+
vi.mock("../utils/command", () => ({ execCommand, commandExists }));
|
|
19
|
+
|
|
20
|
+
const osMock = vi.hoisted(() => ({
|
|
21
|
+
platform: vi.fn(() => "linux"),
|
|
22
|
+
release: vi.fn(() => "6.1.0-generic"),
|
|
23
|
+
hostname: vi.fn(() => "workstation"),
|
|
24
|
+
userInfo: vi.fn(() => ({ username: "ada" })),
|
|
25
|
+
// cache-config resolves the cache file under the temp directory at import time.
|
|
26
|
+
tmpdir: vi.fn(() => "/tmp"),
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("os", () => ({ ...osMock, default: osMock }));
|
|
29
|
+
|
|
30
|
+
const fsMock = vi.hoisted(() => ({
|
|
31
|
+
existsSync: vi.fn(() => false),
|
|
32
|
+
readFileSync: vi.fn(() => ""),
|
|
33
|
+
}));
|
|
34
|
+
vi.mock("fs", () => ({ ...fsMock, default: fsMock }));
|
|
35
|
+
|
|
36
|
+
const { device, hostname, kernel, os_info, user } = await import("./platform");
|
|
37
|
+
const { IS_LINUX } = await import("../utils/platform");
|
|
38
|
+
import type { InfoContext } from "../types/internal-types";
|
|
39
|
+
|
|
40
|
+
const context = (): InfoContext => ({ cache: {} });
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
execCommand.mockReturnValue("");
|
|
44
|
+
commandExists.mockReturnValue(false);
|
|
45
|
+
fsMock.existsSync.mockReturnValue(false);
|
|
46
|
+
fsMock.readFileSync.mockReturnValue("");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.clearAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("user and hostname", () => {
|
|
54
|
+
it("reads the current username", () => {
|
|
55
|
+
expect(user()).toBe("ada");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("reads the machine's network name", () => {
|
|
59
|
+
expect(hostname()).toBe("workstation");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("kernel", () => {
|
|
64
|
+
it("reports the kernel release", () => {
|
|
65
|
+
expect(kernel(context())).toBe("6.1.0-generic");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("caches the result", () => {
|
|
69
|
+
const ctx = context();
|
|
70
|
+
kernel(ctx);
|
|
71
|
+
expect(ctx.cache.kernel?.value).toBe("6.1.0-generic");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("serves a cached kernel without asking the OS again", () => {
|
|
75
|
+
const ctx = context();
|
|
76
|
+
ctx.cache.kernel = { value: "cached-kernel", timestamp: Date.now() };
|
|
77
|
+
|
|
78
|
+
expect(kernel(ctx)).toBe("cached-kernel");
|
|
79
|
+
expect(osMock.release).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("os_info", () => {
|
|
84
|
+
it("serves a cached value without running any command", () => {
|
|
85
|
+
const ctx = context();
|
|
86
|
+
ctx.cache.os = { value: "Cached OS 1.0", timestamp: Date.now() };
|
|
87
|
+
|
|
88
|
+
expect(os_info(ctx)).toBe("Cached OS 1.0");
|
|
89
|
+
expect(execCommand).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("caches whatever it resolves", () => {
|
|
93
|
+
const ctx = context();
|
|
94
|
+
const resolved = os_info(ctx);
|
|
95
|
+
expect(ctx.cache.os?.value).toBe(resolved);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("never returns an empty string, whatever the platform", () => {
|
|
99
|
+
expect(os_info(context())).toBeTruthy();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it.runIf(IS_LINUX)("reads the distribution from /etc/os-release", () => {
|
|
103
|
+
fsMock.existsSync.mockReturnValue(true);
|
|
104
|
+
fsMock.readFileSync.mockReturnValue(
|
|
105
|
+
'NAME="Ubuntu"\nVERSION_ID="22.04"\nID=ubuntu\n',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(os_info(context())).toBe("Ubuntu 22.04");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it.runIf(IS_LINUX)("uses the name alone when there is no version id", () => {
|
|
112
|
+
fsMock.readFileSync.mockReturnValue('NAME="Arch Linux"\n');
|
|
113
|
+
expect(os_info(context())).toBe("Arch Linux");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it.runIf(IS_LINUX)("falls back to the kernel release with no os-release file", () => {
|
|
117
|
+
fsMock.readFileSync.mockImplementation(() => {
|
|
118
|
+
throw new Error("ENOENT");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(os_info(context())).toBe("Linux 6.1.0-generic");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.runIf(IS_LINUX)("falls back to a bare Linux when the file has no NAME", () => {
|
|
125
|
+
fsMock.readFileSync.mockReturnValue("ID=unknown\n");
|
|
126
|
+
expect(os_info(context())).toBe("Linux");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("device", () => {
|
|
131
|
+
it("serves a cached model without probing", () => {
|
|
132
|
+
const ctx = context();
|
|
133
|
+
ctx.cache.device = { value: "Cached Box", timestamp: Date.now() };
|
|
134
|
+
|
|
135
|
+
expect(device(ctx)).toBe("Cached Box");
|
|
136
|
+
expect(execCommand).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("caches an empty result too, so the probe is not repeated", () => {
|
|
140
|
+
const ctx = context();
|
|
141
|
+
device(ctx);
|
|
142
|
+
expect(ctx.cache.device).toBeDefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns an empty string when nothing identifies the machine", () => {
|
|
146
|
+
expect(device(context())).toBe("");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it.runIf(IS_LINUX)("reads the model from the DMI product name", () => {
|
|
150
|
+
fsMock.existsSync.mockImplementation(
|
|
151
|
+
(path: string) => path === "/sys/devices/virtual/dmi/id/product_name",
|
|
152
|
+
);
|
|
153
|
+
fsMock.readFileSync.mockReturnValue("OptiPlex 7090\n");
|
|
154
|
+
|
|
155
|
+
expect(device(context())).toBe("OptiPlex 7090");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it.runIf(IS_LINUX)("prefers Android's getprop when it is available", () => {
|
|
159
|
+
commandExists.mockImplementation((cmd: string) => cmd === "getprop");
|
|
160
|
+
execCommand.mockReturnValue("Pixel 8");
|
|
161
|
+
|
|
162
|
+
expect(device(context())).toBe("Pixel 8");
|
|
163
|
+
expect(execCommand).toHaveBeenCalledWith("getprop ro.product.model");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it.runIf(IS_LINUX)("falls through to DMI when getprop answers nothing", () => {
|
|
167
|
+
commandExists.mockImplementation((cmd: string) => cmd === "getprop");
|
|
168
|
+
execCommand.mockReturnValue("");
|
|
169
|
+
fsMock.existsSync.mockReturnValue(true);
|
|
170
|
+
fsMock.readFileSync.mockReturnValue("Steam Deck\n");
|
|
171
|
+
|
|
172
|
+
expect(device(context())).toBe("Steam Deck");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it.runIf(IS_LINUX)("ignores a blank DMI product name", () => {
|
|
176
|
+
fsMock.existsSync.mockReturnValue(true);
|
|
177
|
+
fsMock.readFileSync.mockReturnValue(" \n");
|
|
178
|
+
|
|
179
|
+
expect(device(context())).toBe("");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it.runIf(IS_LINUX)("survives an unreadable DMI file", () => {
|
|
183
|
+
fsMock.existsSync.mockReturnValue(true);
|
|
184
|
+
fsMock.readFileSync.mockImplementation(() => {
|
|
185
|
+
throw new Error("EACCES");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(device(context())).toBe("");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Covers the shell and network helpers every info module is
|
|
3
|
+
* built on. Both are written to never throw — `about-system` prints a partial
|
|
4
|
+
* readout rather than crashing when a tool is missing or the network is down —
|
|
5
|
+
* so the failure paths are the point of these tests.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from "events";
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
// `command.ts` binds `execSync` as a named ESM import, so spying on the
|
|
12
|
+
// child_process namespace object would not reach it — the module has to be
|
|
13
|
+
// mocked outright.
|
|
14
|
+
const execSync = vi.hoisted(() => vi.fn());
|
|
15
|
+
vi.mock("child_process", () => ({
|
|
16
|
+
execSync,
|
|
17
|
+
default: { execSync },
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const httpsGet = vi.hoisted(() => vi.fn());
|
|
21
|
+
vi.mock("https", () => ({
|
|
22
|
+
get: httpsGet,
|
|
23
|
+
default: { get: httpsGet },
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { commandExists, execCommand } from "./command";
|
|
27
|
+
import { IS_LINUX, IS_MAC, IS_WINDOWS } from "./platform";
|
|
28
|
+
import { fetchIPInfo } from "./network";
|
|
29
|
+
import { DEFAULT_IPINFO_TOKEN } from "../cache/cache-config";
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
execSync.mockReset();
|
|
33
|
+
httpsGet.mockReset();
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("platform flags", () => {
|
|
38
|
+
it("identifies exactly one platform", () => {
|
|
39
|
+
expect([IS_WINDOWS, IS_MAC, IS_LINUX].filter(Boolean).length).toBeLessThanOrEqual(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("agrees with the running platform", () => {
|
|
43
|
+
expect(IS_LINUX).toBe(process.platform === "linux");
|
|
44
|
+
expect(IS_MAC).toBe(process.platform === "darwin");
|
|
45
|
+
expect(IS_WINDOWS).toBe(process.platform === "win32");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("execCommand", () => {
|
|
50
|
+
it("returns the command's trimmed output", () => {
|
|
51
|
+
execSync.mockReturnValue(" hello \n");
|
|
52
|
+
expect(execCommand("echo hello")).toBe("hello");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns an empty string instead of throwing when the command fails", () => {
|
|
56
|
+
execSync.mockImplementation(() => {
|
|
57
|
+
throw new Error("command not found");
|
|
58
|
+
});
|
|
59
|
+
expect(execCommand("nope")).toBe("");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("never waits forever on a hung command", () => {
|
|
63
|
+
execSync.mockReturnValue("");
|
|
64
|
+
execCommand("sleep 999");
|
|
65
|
+
|
|
66
|
+
expect(execSync.mock.calls[0][1]).toMatchObject({ timeout: 10_000 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("discards the command's stderr so it cannot corrupt the readout", () => {
|
|
70
|
+
execSync.mockReturnValue("");
|
|
71
|
+
execCommand("noisy");
|
|
72
|
+
|
|
73
|
+
expect((execSync.mock.calls[0][1] as { stdio: string[] }).stdio).toEqual([
|
|
74
|
+
"pipe",
|
|
75
|
+
"pipe",
|
|
76
|
+
"ignore",
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("lets the caller override the default options", () => {
|
|
81
|
+
execSync.mockReturnValue("");
|
|
82
|
+
execCommand("slow", { timeout: 100 });
|
|
83
|
+
|
|
84
|
+
expect(execSync.mock.calls[0][1]).toMatchObject({ timeout: 100 });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("handles a Buffer result from execSync", () => {
|
|
88
|
+
execSync.mockReturnValue(Buffer.from(" buffered \n"));
|
|
89
|
+
expect(execCommand("x")).toBe("buffered");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("commandExists", () => {
|
|
94
|
+
it("reports a command that resolves", () => {
|
|
95
|
+
execSync.mockReturnValue("");
|
|
96
|
+
expect(commandExists("node")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reports a command that does not resolve", () => {
|
|
100
|
+
execSync.mockImplementation(() => {
|
|
101
|
+
throw new Error("not found");
|
|
102
|
+
});
|
|
103
|
+
expect(commandExists("definitely-not-a-real-command")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("looks the command up with the platform's own resolver", () => {
|
|
107
|
+
execSync.mockReturnValue("");
|
|
108
|
+
commandExists("git");
|
|
109
|
+
|
|
110
|
+
expect(execSync.mock.calls[0][0]).toBe(IS_WINDOWS ? "where git" : "which git");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("silences the resolver's own output", () => {
|
|
114
|
+
execSync.mockReturnValue("");
|
|
115
|
+
commandExists("git");
|
|
116
|
+
|
|
117
|
+
expect(execSync.mock.calls[0][1]).toMatchObject({ stdio: "ignore" });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/** A fake `https.get` whose response body and failure mode a test controls. */
|
|
122
|
+
function stubHttpsGet(behavior: (req: EventEmitter & { setTimeout: (ms: number, fn: () => void) => void; destroy: () => void }, onResponse: (res: EventEmitter) => void) => void) {
|
|
123
|
+
httpsGet.mockImplementation(
|
|
124
|
+
(_url: string, callback: (res: EventEmitter) => void) => {
|
|
125
|
+
const req = Object.assign(new EventEmitter(), {
|
|
126
|
+
setTimeout: vi.fn(),
|
|
127
|
+
destroy: vi.fn(),
|
|
128
|
+
});
|
|
129
|
+
queueMicrotask(() => behavior(req, callback));
|
|
130
|
+
return req;
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
return httpsGet;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Emits a complete response body on the next tick. */
|
|
137
|
+
const respondWith = (body: string) =>
|
|
138
|
+
stubHttpsGet((_req, onResponse) => {
|
|
139
|
+
const res = new EventEmitter();
|
|
140
|
+
onResponse(res);
|
|
141
|
+
res.emit("data", body);
|
|
142
|
+
res.emit("end");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("fetchIPInfo", () => {
|
|
146
|
+
it("parses the ipinfo.io payload", async () => {
|
|
147
|
+
respondWith(JSON.stringify({ ip: "1.2.3.4", city: "San Francisco" }));
|
|
148
|
+
|
|
149
|
+
await expect(fetchIPInfo()).resolves.toEqual({
|
|
150
|
+
ip: "1.2.3.4",
|
|
151
|
+
city: "San Francisco",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("sends the default token when none is given", async () => {
|
|
156
|
+
const get = respondWith("{}");
|
|
157
|
+
await fetchIPInfo();
|
|
158
|
+
|
|
159
|
+
expect(get.mock.calls[0][0]).toBe(
|
|
160
|
+
`https://ipinfo.io/json?token=${DEFAULT_IPINFO_TOKEN}`,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("sends a caller-supplied token", async () => {
|
|
165
|
+
const get = respondWith("{}");
|
|
166
|
+
await fetchIPInfo("my-token");
|
|
167
|
+
|
|
168
|
+
expect(get.mock.calls[0][0]).toContain("token=my-token");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("omits the query string entirely for an empty token", async () => {
|
|
172
|
+
const get = respondWith("{}");
|
|
173
|
+
await fetchIPInfo("");
|
|
174
|
+
|
|
175
|
+
expect(get.mock.calls[0][0]).toBe("https://ipinfo.io/json");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("reassembles a body that arrives in chunks", async () => {
|
|
179
|
+
stubHttpsGet((_req, onResponse) => {
|
|
180
|
+
const res = new EventEmitter();
|
|
181
|
+
onResponse(res);
|
|
182
|
+
res.emit("data", '{"ip":"1.2');
|
|
183
|
+
res.emit("data", '.3.4"}');
|
|
184
|
+
res.emit("end");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await expect(fetchIPInfo()).resolves.toEqual({ ip: "1.2.3.4" });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("resolves empty rather than rejecting on a malformed body", async () => {
|
|
191
|
+
respondWith("<html>gateway error</html>");
|
|
192
|
+
await expect(fetchIPInfo()).resolves.toEqual({});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("resolves empty rather than rejecting when the request errors", async () => {
|
|
196
|
+
stubHttpsGet((req) => req.emit("error", new Error("ENOTFOUND")));
|
|
197
|
+
await expect(fetchIPInfo()).resolves.toEqual({});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("arms a timeout that abandons the request", async () => {
|
|
201
|
+
let armed: { ms: number; fire: () => void } | undefined;
|
|
202
|
+
httpsGet.mockImplementation(() => {
|
|
203
|
+
const req = Object.assign(new EventEmitter(), {
|
|
204
|
+
setTimeout: (ms: number, fire: () => void) => {
|
|
205
|
+
armed = { ms, fire };
|
|
206
|
+
},
|
|
207
|
+
destroy: vi.fn(),
|
|
208
|
+
});
|
|
209
|
+
return req;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const pending = fetchIPInfo("t", 1234);
|
|
213
|
+
expect(armed?.ms).toBe(1234);
|
|
214
|
+
|
|
215
|
+
armed?.fire();
|
|
216
|
+
await expect(pending).resolves.toEqual({});
|
|
217
|
+
});
|
|
218
|
+
});
|