AtlatestRepositorysigil-irc

sigil-irc / tree / test / integrationtest-wasm-irc-websocket.mjs

1#!/usr/bin/env node
2
3import crypto from "node:crypto";
4import { spawnSync } from "node:child_process";
5import fs from "node:fs";
6import http from "node:http";
7import path from "node:path";
8import { fileURLToPath } from "node:url";
9import vm from "node:vm";
10import { WASI } from "node:wasi";
12const scriptDir = path.dirname(fileURLToPath(import.meta.url));
13const repoRoot = path.resolve(scriptDir, "../..");
14const workspaceRoot = path.resolve(repoRoot, "..");
15const sigilRoot = path.join(workspaceRoot, "sigil");
16const appDir = path.join(repoRoot, "test/integration/apps/wasm-irc-websocket-smoke");
17const buildDir = path.join(appDir, "build/web");
18const wasmPath = path.join(buildDir, "wasm-irc-websocket-smoke.wasm");
19const bridgePath = path.join(buildDir, "assets/sigil-wasm-net.js");
20const manifestPath = path.join(buildDir, "sigil-wasm-bridges.json");
21const libPath = path.join(buildDir, "lib");
22const sigilBin = path.join(sigilRoot, "build/dev/bin/sigil");
23const withZig = path.join(sigilRoot, "scripts/with-zig");
25function 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]);
38function 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 };
61function 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;
72function 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 }
85function requireFile(file) {
86 if (!fs.existsSync(file)) throw new Error(`Expected file: ${file}`);
89async 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 = crypto
98 .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 };
127function 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}`);
136async 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 session
185 (irc-ws-connect
186 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 }
240try {
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 }