Skip to content

Commit 6ab53b6

Browse files
test(crypto-worker): increase unit test coverage (#1318)
* Tests * Test defaults * Test service-provider * Use real data * Extract worker implementation * Rename class * Double check * style: resolve style guide violations [ci-lint-fix]
1 parent c046793 commit 6ab53b6

11 files changed

Lines changed: 870 additions & 104 deletions

packages/crypto-worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@types/chance": "1.1.8",
3232
"@types/fs-extra": "11.0.4",
3333
"@types/tmp": "0.2.6",
34+
"esmock": "2.7.5",
3435
"uvu": "0.5.6"
3536
},
3637
"engines": {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { EnvironmentVariables } from "@mainsail/constants";
2+
import esmock from "esmock";
3+
4+
import { describe } from "@mainsail/test-runner";
5+
6+
let bust = 0;
7+
const load = async (): Promise<{ workerCount: number | string; workerLoggingEnabled: boolean }> =>
8+
(await import(`./defaults.js?bust=${bust++}`)).defaults;
9+
10+
// Re-import defaults with os.cpus() mocked to report a specific core count.
11+
const loadWithCpus = async (cores: number): Promise<{ workerCount: number | string }> => {
12+
delete process.env[EnvironmentVariables.MAINSAIL_CRYPTO_WORKER_COUNT];
13+
return (await esmock("./defaults", { os: { cpus: () => Array.from({ length: cores }, () => ({})) } })).defaults;
14+
};
15+
16+
describe("Defaults", ({ assert, it }) => {
17+
it("falls back to a CPU-derived worker count and disabled logging", async () => {
18+
const defaults = await load();
19+
20+
assert.number(defaults.workerCount);
21+
assert.gte(defaults.workerCount as number, 1);
22+
assert.lte(defaults.workerCount as number, 4);
23+
assert.false(defaults.workerLoggingEnabled);
24+
});
25+
26+
it("caps the worker count at 4 on machines with more cores", async () => {
27+
const defaults = await loadWithCpus(16);
28+
29+
assert.equal(defaults.workerCount, 4);
30+
});
31+
32+
it("uses the cpu count when fewer than 4 cores are available", async () => {
33+
const defaults = await loadWithCpus(2);
34+
35+
assert.equal(defaults.workerCount, 2);
36+
});
37+
38+
it("reads the worker count and logging flag from the environment", async () => {
39+
process.env[EnvironmentVariables.MAINSAIL_CRYPTO_WORKER_COUNT] = "7";
40+
process.env[EnvironmentVariables.MAINSAIL_CRYPTO_WORKER_LOGGING_ENABLED] = "true";
41+
42+
const defaults = await load();
43+
44+
assert.equal(defaults.workerCount, "7");
45+
assert.true(defaults.workerLoggingEnabled);
46+
});
47+
});
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { Identifiers } from "@mainsail/constants";
2+
import { Application, Ipc } from "@mainsail/kernel";
3+
import { EventEmitter } from "events";
4+
import esmock from "esmock";
5+
import { PassThrough } from "stream";
6+
7+
import { describe } from "@mainsail/test-runner";
8+
import { Worker as WorkerInstance } from "./worker";
9+
10+
// Records every `new Worker(...)` so the factory test can assert how the thread is spawned.
11+
const constructions: any[][] = [];
12+
13+
// Stand-in for worker_threads.Worker: an EventEmitter exposing the stdout/stderr streams and
14+
// threadId that Ipc.Subprocess reads, so the real Subprocess wraps it without a real thread.
15+
class FakeWorker extends EventEmitter {
16+
public threadId = 1;
17+
public readonly stdout = new PassThrough();
18+
public readonly stderr = new PassThrough();
19+
20+
public constructor(...arguments_: any[]) {
21+
super();
22+
constructions.push(arguments_);
23+
}
24+
25+
public postMessage(): void {}
26+
public async terminate(): Promise<number> {
27+
return 0;
28+
}
29+
}
30+
31+
// Load the provider with worker_threads.Worker swapped for the fake; the real Ipc.Subprocess
32+
// and ./worker.js stay in place.
33+
const { ServiceProvider } = await esmock("./service-provider", {
34+
worker_threads: { Worker: FakeWorker },
35+
});
36+
37+
describe<{
38+
app: Application;
39+
serviceProvider: any;
40+
}>("ServiceProvider", ({ assert, beforeEach, it, spy }) => {
41+
beforeEach((context) => {
42+
constructions.length = 0;
43+
44+
context.app = new Application();
45+
context.app.bind(Identifiers.Config.Flags).toConstantValue({ network: "testnet" });
46+
// Ipc.Subprocess resolves the logger from the container when the factory runs.
47+
context.app.bind(Identifiers.Services.Log.Service).toConstantValue({ debug: () => {}, error: () => {} });
48+
49+
context.serviceProvider = context.app.resolve(ServiceProvider);
50+
});
51+
52+
it("register binds the worker instance, worker factory, worker pool and subprocess factory", async (context) => {
53+
await context.serviceProvider.register();
54+
55+
assert.true(context.app.isBound(Identifiers.CryptoWorker.Worker.Instance));
56+
assert.true(context.app.isBound(Identifiers.CryptoWorker.Worker.Factory));
57+
assert.true(context.app.isBound(Identifiers.CryptoWorker.WorkerPool));
58+
assert.true(context.app.isBound(Identifiers.CryptoWorker.WorkerSubprocess.Factory));
59+
assert.function(context.app.get(Identifiers.CryptoWorker.WorkerSubprocess.Factory));
60+
});
61+
62+
it("the subprocess factory spawns the worker script with piped stdio and wraps it in an Ipc.Subprocess", async (context) => {
63+
await context.serviceProvider.register();
64+
65+
const factory = context.app.get(Identifiers.CryptoWorker.WorkerSubprocess.Factory) as () => Ipc.Subprocess;
66+
const subprocess = factory();
67+
68+
assert.length(constructions, 1);
69+
const [scriptPath, options] = constructions[0];
70+
assert.true(scriptPath.endsWith("worker-script.js"));
71+
assert.equal(options, { stderr: true, stdout: true });
72+
assert.instance(subprocess, Ipc.Subprocess);
73+
});
74+
75+
it("the worker factory resolves a Worker instance", async (context) => {
76+
await context.serviceProvider.register();
77+
78+
const factory = context.app.get<() => WorkerInstance>(Identifiers.CryptoWorker.Worker.Factory);
79+
80+
assert.instance(factory(), WorkerInstance);
81+
});
82+
83+
it("boot boots the worker pool", async (context) => {
84+
await context.serviceProvider.register();
85+
86+
const pool = { boot: async () => {}, dispose: async () => {} };
87+
context.app.rebind(Identifiers.CryptoWorker.WorkerPool).toConstantValue(pool);
88+
const boot = spy(pool, "boot");
89+
90+
await context.serviceProvider.boot();
91+
92+
boot.calledOnce();
93+
});
94+
95+
it("dispose disposes the worker pool", async (context) => {
96+
await context.serviceProvider.register();
97+
98+
const pool = { boot: async () => {}, dispose: async () => {} };
99+
context.app.rebind(Identifiers.CryptoWorker.WorkerPool).toConstantValue(pool);
100+
const dispose = spy(pool, "dispose");
101+
102+
await context.serviceProvider.dispose();
103+
104+
dispose.calledOnce();
105+
});
106+
107+
it("is required", async (context) => {
108+
assert.true(await context.serviceProvider.required());
109+
});
110+
});
111+
112+
const importFresh = (moduleName: string) => import(`${moduleName}?${Date.now()}`);
113+
114+
describe<{
115+
app: Application;
116+
serviceProvider: any;
117+
}>("ServiceProvider.configSchema", ({ assert, beforeEach, it }) => {
118+
const importDefaults = async () => (await importFresh("../distribution/defaults.js")).defaults;
119+
120+
beforeEach((context) => {
121+
context.app = new Application();
122+
context.serviceProvider = context.app.resolve(ServiceProvider);
123+
124+
for (const key of Object.keys(process.env)) {
125+
if (key.includes("MAINSAIL_CRYPTO_WORKER")) {
126+
delete process.env[key];
127+
}
128+
}
129+
});
130+
131+
it("should validate schema using defaults", async ({ serviceProvider }) => {
132+
const result = serviceProvider.configSchema().validate(await importDefaults());
133+
134+
assert.undefined(result.error);
135+
assert.number(result.value.workerCount);
136+
assert.boolean(result.value.workerLoggingEnabled);
137+
});
138+
139+
it("should allow configuration extension", async ({ serviceProvider }) => {
140+
const defaults = await importDefaults();
141+
defaults.customField = "dummy";
142+
143+
const result = serviceProvider.configSchema().validate(defaults);
144+
145+
assert.undefined(result.error);
146+
assert.equal(result.value.customField, "dummy");
147+
});
148+
149+
it("should parse process.env.MAINSAIL_CRYPTO_WORKER_COUNT", async ({ serviceProvider }) => {
150+
process.env.MAINSAIL_CRYPTO_WORKER_COUNT = "1";
151+
152+
const result = serviceProvider.configSchema().validate(await importDefaults());
153+
154+
assert.undefined(result.error);
155+
assert.equal(result.value.workerCount, 1);
156+
});
157+
158+
it("should throw if process.env.MAINSAIL_CRYPTO_WORKER_COUNT is below the minimum", async ({ serviceProvider }) => {
159+
process.env.MAINSAIL_CRYPTO_WORKER_COUNT = "0";
160+
161+
const result = serviceProvider.configSchema().validate(await importDefaults());
162+
163+
assert.defined(result.error);
164+
});
165+
166+
it("should throw if process.env.MAINSAIL_CRYPTO_WORKER_COUNT is not a number", async ({ serviceProvider }) => {
167+
process.env.MAINSAIL_CRYPTO_WORKER_COUNT = "not-a-number";
168+
169+
const result = serviceProvider.configSchema().validate(await importDefaults());
170+
171+
assert.defined(result.error);
172+
});
173+
174+
it("should throw if process.env.MAINSAIL_CRYPTO_WORKER_COUNT is not an integer", async ({ serviceProvider }) => {
175+
process.env.MAINSAIL_CRYPTO_WORKER_COUNT = "1.5";
176+
177+
const result = serviceProvider.configSchema().validate(await importDefaults());
178+
179+
assert.defined(result.error);
180+
});
181+
182+
it("should throw if process.env.MAINSAIL_CRYPTO_WORKER_COUNT exceeds the available cpus", async ({
183+
serviceProvider,
184+
}) => {
185+
process.env.MAINSAIL_CRYPTO_WORKER_COUNT = "9999";
186+
187+
const result = serviceProvider.configSchema().validate(await importDefaults());
188+
189+
assert.defined(result.error);
190+
});
191+
192+
it("should parse process.env.MAINSAIL_CRYPTO_WORKER_LOGGING_ENABLED", async ({ serviceProvider }) => {
193+
process.env.MAINSAIL_CRYPTO_WORKER_LOGGING_ENABLED = "true";
194+
195+
const result = serviceProvider.configSchema().validate(await importDefaults());
196+
197+
assert.undefined(result.error);
198+
assert.true(result.value.workerLoggingEnabled);
199+
});
200+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { Identifiers } from "@mainsail/constants";
2+
import { Application, Services } from "@mainsail/kernel";
3+
4+
import { describe } from "@mainsail/test-runner";
5+
import { WorkerScriptHandler } from "./worker-handler";
6+
7+
describe<{
8+
subject: WorkerScriptHandler;
9+
impl: any;
10+
toCalls: unknown[];
11+
resolve: any;
12+
bootstrap: any;
13+
boot: any;
14+
terminate: any;
15+
rebind: any;
16+
}>("WorkerScriptHandler", ({ assert, beforeEach, it, spy, stub }) => {
17+
beforeEach((context) => {
18+
context.impl = {
19+
callBlockFactory: async () => {},
20+
callConsensusSignature: async () => {},
21+
callPublicKeyFactory: async () => {},
22+
callTransactionFactory: async () => {},
23+
callWalletSignature: async () => {},
24+
};
25+
context.toCalls = [];
26+
27+
// WorkerScriptHandler owns a private `new Application()`; stub the prototype so bootstrap,
28+
// boot, the WorkerImplementation resolution and the logger rebind all stay in-process.
29+
context.resolve = stub(Application.prototype, "resolve").returnValue(context.impl);
30+
context.bootstrap = stub(Application.prototype, "bootstrap").resolvedValue(undefined);
31+
context.boot = stub(Application.prototype, "boot").resolvedValue(undefined);
32+
context.terminate = stub(Application.prototype, "terminate").resolvedValue(undefined);
33+
context.rebind = stub(Application.prototype, "rebind").returnValue({
34+
to: (value: unknown) => context.toCalls.push(value),
35+
});
36+
37+
context.subject = new WorkerScriptHandler();
38+
});
39+
40+
it("boot bootstraps with the flags, boots and resolves the worker impl", async ({
41+
subject,
42+
bootstrap,
43+
boot,
44+
resolve,
45+
rebind,
46+
}) => {
47+
await subject.boot({ workerLoggingEnabled: true } as any);
48+
49+
bootstrap.calledWith({ flags: { workerLoggingEnabled: true } });
50+
boot.calledOnce();
51+
resolve.calledOnce();
52+
// Logging enabled → the logger is left in place.
53+
rebind.neverCalled();
54+
});
55+
56+
it("boot rebinds the logger to the null logger when worker logging is disabled", async ({
57+
subject,
58+
rebind,
59+
toCalls,
60+
}) => {
61+
await subject.boot({ workerLoggingEnabled: false } as any);
62+
63+
rebind.calledWith(Identifiers.Services.Log.Service);
64+
assert.equal(toCalls, [Services.Log.NullLogger]);
65+
});
66+
67+
it("dispose terminates the app", async ({ subject, terminate }) => {
68+
await subject.dispose();
69+
70+
terminate.calledOnce();
71+
});
72+
73+
it("consensusSignature delegates to the worker impl", async ({ subject, impl }) => {
74+
await subject.boot({ workerLoggingEnabled: true } as any);
75+
const call = spy(impl, "callConsensusSignature");
76+
const message = Buffer.from("message-to-sign");
77+
const privateKey = Buffer.from("consensus-private-key");
78+
79+
await subject.consensusSignature("sign", [message, privateKey]);
80+
81+
call.calledWith("sign", [message, privateKey]);
82+
});
83+
84+
it("walletSignature delegates to the worker impl", async ({ subject, impl }) => {
85+
await subject.boot({ workerLoggingEnabled: true } as any);
86+
const call = spy(impl, "callWalletSignature");
87+
const message = Buffer.from("message-to-sign");
88+
const privateKey = Buffer.from("wallet-private-key");
89+
90+
await subject.walletSignature("signRecoverable", [message, privateKey]);
91+
92+
call.calledWith("signRecoverable", [message, privateKey]);
93+
});
94+
95+
it("blockFactory delegates to the worker impl", async ({ subject, impl }) => {
96+
await subject.boot({ workerLoggingEnabled: true } as any);
97+
const call = spy(impl, "callBlockFactory");
98+
99+
await subject.blockFactory("fromHex", ["0a1b2c3d"]);
100+
101+
call.calledWith("fromHex", ["0a1b2c3d"]);
102+
});
103+
104+
it("transactionFactory delegates to the worker impl", async ({ subject, impl }) => {
105+
await subject.boot({ workerLoggingEnabled: true } as any);
106+
const call = spy(impl, "callTransactionFactory");
107+
const bytes = Buffer.from("deadbeef", "hex");
108+
109+
await subject.transactionFactory("fromBytes", [bytes]);
110+
111+
call.calledWith("fromBytes", [bytes]);
112+
});
113+
114+
it("publicKeyFactory delegates to the worker impl", async ({ subject, impl }) => {
115+
await subject.boot({ workerLoggingEnabled: true } as any);
116+
const call = spy(impl, "callPublicKeyFactory");
117+
118+
await subject.publicKeyFactory("fromMnemonic", ["clay harbor essay analyst"]);
119+
120+
call.calledWith("fromMnemonic", ["clay harbor essay analyst"]);
121+
});
122+
});

0 commit comments

Comments
 (0)