AtlatestRepositorysigil-irc
sigil-irc / tree / test / integrationtest-wasm-irc-websocket.mjs
1
#!/usr/bin/env node3
import crypto from "node:crypto";4
import { spawnSync } from "node:child_process";5
import fs from "node:fs";6
import http from "node:http";7
import path from "node:path";8
import { fileURLToPath } from "node:url";9
import vm from "node:vm";10
import { WASI } from "node:wasi";12
const scriptDir = path.dirname(fileURLToPath(import.meta.url));13
const repoRoot = path.resolve(scriptDir, "../..");14
const workspaceRoot = path.resolve(repoRoot, "..");15
const sigilRoot = path.join(workspaceRoot, "sigil");16
const appDir = path.join(repoRoot, "test/integration/apps/wasm-irc-websocket-smoke");17
const buildDir = path.join(appDir, "build/web");18
const wasmPath = path.join(buildDir, "wasm-irc-websocket-smoke.wasm");19
const bridgePath = path.join(buildDir, "assets/sigil-wasm-net.js");20
const manifestPath = path.join(buildDir, "sigil-wasm-bridges.json");21
const libPath = path.join(buildDir, "lib");22
const sigilBin = path.join(sigilRoot, "build/dev/bin/sigil");23
const withZig = path.join(sigilRoot, "scripts/with-zig");25
function encodeServerFrame(data, opcode) {26
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);27
const header = [0x80 | opcode];28
if (payload.length < 126) {29
header.push(payload.length);30
} else if (payload.length < 65536) {31
header.push(126, (payload.length >> 8) & 0xff, payload.length & 0xff);32
} else {33
throw new Error("large frames are not needed for this smoke test");34
}35
return Buffer.concat([Buffer.from(header), payload]);36
}38
function decodeClientFrame(buffer, start = 0) {39
const opcode = buffer[start] & 0x0f;40
let length = buffer[start + 1] & 0x7f;41
let offset = start + 2;42
if (length === 126) {43
length = buffer.readUInt16BE(offset);44
offset += 2;45
} else if (length === 127) {46
throw new Error("large frames are not needed for this smoke test");47
}48
if ((buffer[1] & 0x80) === 0) {49
throw new Error("client frame was not masked");50
}51
const mask = buffer.subarray(offset, offset + 4);52
offset += 4;53
const payload = buffer.subarray(offset, offset + length);54
const decoded = Buffer.alloc(length);55
for (let i = 0; i < length; i += 1) {56
decoded[i] = payload[i] ^ mask[i % 4];57
}58
return { opcode, text: decoded.toString("utf8"), bytesConsumed: offset + length - start };59
}61
function decodeClientFrames(buffer) {62
const frames = [];63
let offset = 0;64
while (offset < buffer.length) {65
const frame = decodeClientFrame(buffer, offset);66
frames.push(frame);67
offset += frame.bytesConsumed;68
}69
return frames;70
}72
function run(command, args, options = {}) {73
const result = spawnSync(command, args, { encoding: "utf8", ...options });74
if (result.error) throw result.error;75
if (result.stdout) process.stdout.write(result.stdout);76
if (result.stderr) process.stderr.write(result.stderr);77
if (result.status !== 0) {78
console.error("Command failed:");79
console.error(` cwd: ${options.cwd || process.cwd()}`);80
console.error(` command: ${command} ${args.join(" ")}`);81
throw new Error(`${command} ${args.join(" ")} exited with ${result.status}`);82
}83
}85
function requireFile(file) {86
if (!fs.existsSync(file)) throw new Error(`Expected file: ${file}`);87
}89
async function startEchoServer() {90
const messages = [];91
const sockets = new Set();92
const server = http.createServer();93
server.on("upgrade", (req, socket) => {94
sockets.add(socket);95
socket.on("close", () => sockets.delete(socket));96
const key = req.headers["sec-websocket-key"];97
const accept = crypto98
.createHash("sha1")99
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)100
.digest("base64");101
socket.write([102
"HTTP/1.1 101 Switching Protocols",103
"Upgrade: websocket",104
"Connection: Upgrade",105
`Sec-WebSocket-Accept: ${accept}`,106
"",107
"",108
].join("\r\n"));109
socket.on("data", (buffer) => {110
for (const frame of decodeClientFrames(buffer)) {111
if (frame.opcode === 1) {112
messages.push(frame.text);113
if (frame.text.startsWith("PRIVMSG ")) {114
socket.write(encodeServerFrame(frame.text, 1));115
}116
}117
}118
});119
});120
await new Promise((resolve, reject) => {121
server.once("error", reject);122
server.listen(0, "127.0.0.1", resolve);123
});124
return { server, sockets, messages, port: server.address().port };125
}127
function callWithString(instance, fn, code) {128
const bytes = new TextEncoder().encode(`${code}\0`);129
const ptr = instance.exports.malloc(bytes.length);130
new Uint8Array(instance.exports.memory.buffer, ptr, bytes.length).set(bytes);131
const result = fn(ptr);132
instance.exports.free(ptr);133
if (result !== 0) throw new Error(`Sigil eval failed with status ${result}`);134
}136
async function main() {137
requireFile(sigilBin);138
requireFile(withZig);140
run(withZig, [141
sigilBin,142
"build",143
"--config",144
"web",145
"--force",146
"--no-bundle",147
], {148
cwd: appDir,149
env: { ...process.env, SIGIL_FEATURES: "web,wasm" },150
});152
requireFile(wasmPath);153
requireFile(bridgePath);154
requireFile(manifestPath);156
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));157
if (!manifest.bridges?.some((bridge) => bridge.name === "net")) {158
throw new Error("Bridge manifest does not include net bridge");159
}161
const { server, sockets, messages, port } = await startEchoServer();162
try {163
vm.runInThisContext(fs.readFileSync(bridgePath, "utf8"), { filename: bridgePath });165
const wasi = new WASI({166
version: "preview1",167
args: ["wasm-irc-websocket-smoke"],168
env: { SIGIL_FEATURES: "web,wasm" },169
preopens: { "/lib": libPath },170
});171
const bridgeImports = globalThis.SigilWasmNet.createImports();172
const wasm = await WebAssembly.compile(fs.readFileSync(wasmPath));173
const instance = await WebAssembly.instantiate(wasm, {174
wasi_snapshot_preview1: wasi.wasiImport,175
...bridgeImports,176
});178
globalThis.SigilWasmNet.setInstance(instance);179
wasi.start(instance);181
callWithString(instance, instance.exports.sigil_wasm_eval, `182
(import (sigil irc)183
(sigil irc websocket))184
(define session185
(irc-ws-connect186
url: "ws://127.0.0.1:${port}"187
nick: "wasm-irc"))188
(if (not session) (error "irc-ws-connect failed"))189
(if (not (irc-ws-connected? session)) (error "irc-ws session not connected"))190
(display "irc-ws-helper-connect-ok") (newline)191
`);193
await new Promise((resolve) => setTimeout(resolve, 300));195
callWithString(instance, instance.exports.sigil_wasm_eval, `196
(irc-ws-receive session)197
(irc-ws-register session)198
(display "irc-ws-helper-register-ok") (newline)199
`);201
await new Promise((resolve) => setTimeout(resolve, 300));203
if (!messages.includes("NICK wasm-irc")) {204
throw new Error(`echo server did not receive NICK registration; saw ${JSON.stringify(messages)}`);205
}206
if (!messages.includes("USER wasm-irc 0 * wasm-irc")) {207
throw new Error(`echo server did not receive USER registration; saw ${JSON.stringify(messages)}`);208
}210
callWithString(instance, instance.exports.sigil_wasm_eval, `211
(irc-ws-receive session)212
(irc-ws-privmsg session "#sigil" "hello from wasm irc")213
(display "irc-ws-helper-send-ok") (newline)214
`);216
await new Promise((resolve) => setTimeout(resolve, 300));218
callWithString(instance, instance.exports.sigil_wasm_eval, `219
(define parsed (irc-ws-receive session))220
(if (and (irc-message? parsed)221
(eq? (irc-message-command parsed) 'PRIVMSG)222
(string=? (irc-message-target parsed) "#sigil")223
(string=? (irc-message-text parsed) "hello from wasm irc"))224
(display "irc-parse-roundtrip-ok")225
(error "bad irc websocket helper roundtrip"))226
(newline)227
(irc-ws-close session)228
`);230
if (!messages.includes("PRIVMSG #sigil :hello from wasm irc")) {231
throw new Error(`echo server did not receive IRC PRIVMSG; saw ${JSON.stringify(messages)}`);232
}233
console.log("irc-websocket-wasm-ok");234
} finally {235
for (const socket of sockets) socket.destroy();236
await new Promise((resolve) => server.close(resolve));237
}238
}240
try {241
await main();242
} finally {243
if (process.env.SIGIL_KEEP_WASM_IRC_WEBSOCKET_SMOKE_BUILD !== "1") {244
fs.rmSync(path.join(appDir, "build"), { force: true, recursive: true });245
}246
}