diff --git a/BUILD.bazel b/BUILD.bazel index 257e70f71f0..fe80c1d4d5c 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -22,6 +22,14 @@ npm_link_package( package = "@workerd/jsg", ) +# The @workerd/test package provides a small library to manage workerd subprocesses in js_test() +# targets. +npm_link_package( + name = "node_modules/@workerd/test", + src = "//src/workerd/server/tests:test_js", + package = "@workerd/test", +) + capnpc_ts_bin.capnpc_ts_binary( name = "capnpc_ts", visibility = ["//visibility:public"], diff --git a/package.json b/package.json index b578afa28a8..0a000e9ae49 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "capnpc-ts": "^0.7.0", + "chrome-remote-interface": "^0.33.2", "esbuild": "^0.15.18", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88cf7eca38d..e906f083e57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: capnpc-ts: specifier: ^0.7.0 version: 0.7.0 + chrome-remote-interface: + specifier: ^0.33.2 + version: 0.33.2 esbuild: specifier: ^0.15.18 version: 0.15.18 @@ -304,6 +307,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chrome-remote-interface@0.33.2: + resolution: {integrity: sha512-wvm9cOeBTrb218EC+6DteGt92iXr2iY0+XJP30f15JVDhqvWvJEVACh9GvUm8b9Yd8bxQivaLSb8k7mgrbyomQ==} + hasBin: true + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -311,6 +318,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@2.11.0: + resolution: {integrity: sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1200,6 +1210,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -1510,12 +1532,22 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chrome-remote-interface@0.33.2: + dependencies: + commander: 2.11.0 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} + commander@2.11.0: {} + concat-map@0.0.1: {} cross-spawn@7.0.3: @@ -2509,6 +2541,8 @@ snapshots: wrappy@1.0.2: {} + ws@7.5.10: {} + yallist@4.0.0: {} yocto-queue@0.1.0: {} diff --git a/src/workerd/server/BUILD.bazel b/src/workerd/server/BUILD.bazel index 636d0e3ad3a..2684c59e312 100644 --- a/src/workerd/server/BUILD.bazel +++ b/src/workerd/server/BUILD.bazel @@ -145,24 +145,6 @@ wd_cc_capnp_library( ], ) -sh_test( - name = "helloworld_compile_test", - size = "small", - srcs = ["tests/compile-tests/compile-test.sh"], - args = [ - "$(location :workerd)", - "$(location //samples:helloworld/config.capnp)", - "$(location tests/compile-tests/compile-helloworld-test.ok)", - ], - data = [ - "tests/compile-tests/compile-helloworld-test.ok", - ":workerd", - "//samples:helloworld/config.capnp", - "//samples:helloworld/worker.js", - ], - tags = ["no-qemu"], -) - kj_test( src = "server-test.c++", deps = [ diff --git a/src/workerd/server/tests/BUILD.bazel b/src/workerd/server/tests/BUILD.bazel new file mode 100644 index 00000000000..de8f8075e51 --- /dev/null +++ b/src/workerd/server/tests/BUILD.bazel @@ -0,0 +1,14 @@ +load("@aspect_rules_js//js:defs.bzl", "js_library") +load("@aspect_rules_js//npm:defs.bzl", "npm_package") + +js_library( + name = "server-harness_js_lib", + srcs = [ "server-harness.mjs" ], +) + +npm_package( + name = "test_js", + srcs = [":server-harness_js_lib"], + publishable = False, + visibility = ["//visibility:public"], +) diff --git a/src/workerd/server/tests/compile-tests/BUILD.bazel b/src/workerd/server/tests/compile-tests/BUILD.bazel new file mode 100644 index 00000000000..16d0f0a2114 --- /dev/null +++ b/src/workerd/server/tests/compile-tests/BUILD.bazel @@ -0,0 +1,18 @@ +sh_test( + name = "helloworld_compile_test", + size = "small", + srcs = ["compile-test.sh"], + args = [ + "$(location //src/workerd/server:workerd)", + "$(location //samples:helloworld/config.capnp)", + "$(location compile-helloworld-test.ok)", + ], + data = [ + "compile-helloworld-test.ok", + "//src/workerd/server:workerd", + "//samples:helloworld/config.capnp", + "//samples:helloworld/worker.js", + ], + tags = ["no-qemu"], +) + diff --git a/src/workerd/server/tests/inspector/BUILD.bazel b/src/workerd/server/tests/inspector/BUILD.bazel new file mode 100644 index 00000000000..7b36bc8a121 --- /dev/null +++ b/src/workerd/server/tests/inspector/BUILD.bazel @@ -0,0 +1,19 @@ +load("@aspect_rules_js//js:defs.bzl", "js_test") + +js_test( + name = "inspector-test", + entry_point = "driver.mjs", + env = { + "WORKERD_BINARY": "$(rootpath //src/workerd/server:workerd)", + "WORKERD_CONFIG": "$(rootpath :config.capnp)", + }, + data = [ + "//:node_modules/chrome-remote-interface", + "//:node_modules/@workerd/test", + "//src/workerd/server:workerd", + ":config.capnp", + ":index.mjs", + ], + tags = ["js-test"], +) + diff --git a/src/workerd/server/tests/inspector/config.capnp b/src/workerd/server/tests/inspector/config.capnp new file mode 100644 index 00000000000..4e2ab94f985 --- /dev/null +++ b/src/workerd/server/tests/inspector/config.capnp @@ -0,0 +1,19 @@ +# config.capnp +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + ( name = "main", worker = .worker ), + ], + sockets = [ + ( name = "http", address = "*:0", http = (), service = "main" ), + ] +); + +const worker :Workerd.Worker = ( + modules = [ + ( name = "./index.mjs", esModule = embed "index.mjs" ) + ], + compatibilityDate = "2024-01-01", + compatibilityFlags = ["nodejs_compat"], +); diff --git a/src/workerd/server/tests/inspector/driver.mjs b/src/workerd/server/tests/inspector/driver.mjs new file mode 100644 index 00000000000..cfe35eb809b --- /dev/null +++ b/src/workerd/server/tests/inspector/driver.mjs @@ -0,0 +1,89 @@ +import { env } from "node:process"; +import { beforeEach, afterEach, test } from "node:test"; +import assert from "node:assert"; +import CDP from "chrome-remote-interface"; +import { WorkerdServerHarness } from "@workerd/test/server-harness.mjs"; + +// Globals that are reset for each test. +let workerd; +let inspectorClient; + +assert(env.WORKERD_BINARY !== undefined, "You must set the WORKERD_BINARY environment variable."); +assert(env.WORKERD_CONFIG !== undefined, "You must set the WORKERD_CONFIG environment variable."); + +// Start workerd and connect to its inspector port with our CDP library. +beforeEach(async () => { + workerd = new WorkerdServerHarness({ + workerdBinary: env.WORKERD_BINARY, + workerdConfig: env.WORKERD_CONFIG, + + // Hard-coded to match a socket name expected in the `workerdConfig` file. + listenPortNames: [ "http" ], + }); + + await workerd.start(); + + inspectorClient = await CDP({ + port: await workerd.getListenInspectorPort(), + + // Hard-coded to match a service name expected in the `workerdConfig` file. + target: "/main", + + // Required to avoid trying to load the Protocol (schema, I guess?) from workerd, which doesn't + // implement the inspector protocol message in question. + local: true, + }); +}); + +// Stop both our CDP client and workerd. +afterEach(async () => { + await inspectorClient.close(); + inspectorClient = null; + + const [code, signal] = await workerd.stop(); + assert(code === 0 || signal === "SIGTERM"); + workerd = null; +}); + +test("Profiler mostly sees deriveBits() frames", async () => { + // Enable and start profiling. + await inspectorClient.Profiler.enable(); + await inspectorClient.Profiler.start(); + + // Drive the worker with a test request. A single one is sufficient. + let httpPort = await workerd.getListenPort("http"); + const response = await fetch(`http://localhost:${httpPort}`); + await response.arrayBuffer(); + + // Stop and disable profiling. + const profile = await inspectorClient.Profiler.stop(); + await inspectorClient.Profiler.disable(); + + // Figure out which function name was most frequently sampled. + let hitCountMap = new Map(); + + for (let node of profile.profile.nodes) { + if (hitCountMap.get(node.callFrame.functionName) === undefined) { + hitCountMap.set(node.callFrame.functionName, 0); + } + hitCountMap.set(node.callFrame.functionName, + hitCountMap.get(node.callFrame.functionName) + node.hitCount); + } + + let max = { + name: null, + count: 0, + }; + + for (let [name, count] of hitCountMap) { + if (count > max.count) { + max.name = name; + max.count = count; + } + } + + // The most CPU-intensive function our test script runs is `deriveBits()`, so we expect that to be + // the most frequently sampled function. + assert.equal(max.name, "deriveBits"); + assert.notEqual(max.count, 0); +}); diff --git a/src/workerd/server/tests/inspector/index.mjs b/src/workerd/server/tests/inspector/index.mjs new file mode 100644 index 00000000000..7a8d837bc5e --- /dev/null +++ b/src/workerd/server/tests/inspector/index.mjs @@ -0,0 +1,23 @@ +// index.mjs +import { Buffer } from "node:buffer"; + +const encoder = new TextEncoder(); + +async function pbkdf2Derive(password) { + const passwordArray = encoder.encode(password); + const passwordKey = await crypto.subtle.importKey( + "raw", passwordArray, "PBKDF2", false, ["deriveBits"] + ); + const saltArray = crypto.getRandomValues(new Uint8Array(16)); + const keyBuffer = await crypto.subtle.deriveBits( + { name: "PBKDF2", hash: "SHA-256", salt: saltArray, iterations: 1_000_000 }, + passwordKey, 256 + ); + return Buffer.from(keyBuffer).toString("base64"); +} + +export default { + async fetch(request, env, ctx) { + return new Response(await pbkdf2Derive("hello!")); + } +} diff --git a/src/workerd/server/tests/server-harness.mjs b/src/workerd/server/tests/server-harness.mjs new file mode 100644 index 00000000000..925ad78848e --- /dev/null +++ b/src/workerd/server/tests/server-harness.mjs @@ -0,0 +1,128 @@ +import { spawn } from "node:child_process"; +import assert from "node:assert"; + +// A convenience class to: +// - start workerd +// - wait for its listen ports to be opened and reported +// - stop workerd +export class WorkerdServerHarness { + // Properties set by our constructor and never changed. + #workerdBinary = null; + #workerdConfig = null; + #listenPortNames = null; + + // Properties set by `start()` and cleared by `stop()`. + #child = null; + #listenPorts = null; + #listenInspectorPort = null; + #closed = null; + + constructor({ + workerdBinary, + workerdConfig, + listenPortNames, + }) { + this.#workerdBinary = workerdBinary; + this.#workerdConfig = workerdConfig; + this.#listenPortNames = listenPortNames; + } + + // Spawn our workerd process and wait for it to start. + async start() { + assert.equal(this.#child, null); + + // One after STDIN, STDOUT, and STDERR. + const CONTROL_FD = 3; + + const args = [ + "serve", + this.#workerdConfig, + "--verbose", + "--inspector-addr=127.0.0.1:0", + `--control-fd=${CONTROL_FD}`, + ]; + + const options = { + stdio: [ + "inherit", + "inherit", + "inherit", + // One more for our control FD. + "pipe", + ], + }; + + // Start the subprocess. + this.#child = spawn(this.#workerdBinary, args, options); + + // Create a promise for every named listen port we were told in our constructor to expect. Parse + // messages from our control FD and resolve the promises as we see ports come online. + // + // TODO(perf): Registering a separate callback for every named port isn't very efficient -- + // we'll parse JSON N times -- but we typically don't have many named ports, and I don't want to + // spend forever on this code. + this.#listenPorts = new Map(); + for (const listenPort of this.#listenPortNames) { + this.#listenPorts.set(listenPort, new Promise((resolve, reject) => { + this.#child.stdio[CONTROL_FD].on("data", data => { + const parsed = JSON.parse(data); + if (parsed.event === "listen" && parsed.socket === listenPort) { + resolve(parsed.port); + } + }); + this.#child.once("error", reject); + })); + } + + // Do the same as the above for the inspector port. + this.#listenInspectorPort = new Promise((resolve, reject) => { + this.#child.stdio[CONTROL_FD].on("data", data => { + const parsed = JSON.parse(data); + if (parsed.event === "listen-inspector") { + resolve(parsed.port); + } + }); + this.#child.once("error", reject); + }); + + // Set up a closed promise, too. + this.#closed = new Promise((resolve, reject) => { + this.#child.once("close", (code, signal) => resolve([code, signal])) + .once("error", reject); + }); + + // Wait for the subprocess to complete spawning before we return. + await new Promise((resolve, reject) => { + this.#child.once("spawn", resolve) + .once("error", reject); + }); + } + + // Return a promise for the inspector port. + async getListenInspectorPort() { + assert.notEqual(this.#listenInspectorPort, null); + return await this.#listenInspectorPort; + } + + // Return a promise for the named listen port. + async getListenPort(name) { + assert.notEqual(this.#listenPorts, null); + assert.notEqual(this.#listenPorts.get(name), undefined); + return await this.#listenPorts.get(name); + } + + // Send SIGTERM to workerd and wait for it to completely finish. + async stop() { + assert.notEqual(this.#child, null); + + await this.#child.kill(); + let result = await this.#closed; + + this.#child = null; + this.#listenPorts = null; + this.#listenInspectorPort = null; + this.#closed = null; + + return result; + } +}