summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-14 14:28:15 -0300
committerGabriel Schneider <[email protected]>2026-09-16 11:18:48 -0300
commitae310a207534b33b7321dd2b9f423a73b1969159 (patch)
treeb4c2e85091a4ff6657e0a04ba1b2ef030d588ac8 /test
parent5f24c4a2284a0af84fb9d116f7a39f6a58e42ae9 (diff)
downloadcloud9-ae310a207534b33b7321dd2b9f423a73b1969159.tar.gz
cloud9-ae310a207534b33b7321dd2b9f423a73b1969159.zip
Add reusable HTTP transport, serial gateway, and WASM file browser
Diffstat (limited to 'test')
-rw-r--r--test/differential/webfixture/main.go83
-rw-r--r--test/http.zig92
-rw-r--r--test/web/e2e.mjs268
-rw-r--r--test/web/native.zig66
-rw-r--r--test/web/serial.py25
5 files changed, 534 insertions, 0 deletions
diff --git a/test/differential/webfixture/main.go b/test/differential/webfixture/main.go
new file mode 100644
index 0000000..9d23399
--- /dev/null
+++ b/test/differential/webfixture/main.go
@@ -0,0 +1,83 @@
+// Local reference filesystem for HTTP/browser tests. No external target.
+package main
+
+import (
+ "fmt"
+ "github.com/knusbaum/go9p"
+ "github.com/knusbaum/go9p/fs"
+ "net"
+ "os"
+)
+
+type fragmented struct{ net.Conn }
+
+func (f fragmented) Read(p []byte) (int, error) {
+ if len(p) > 17 {
+ p = p[:17]
+ }
+ return f.Conn.Read(p)
+}
+func (f fragmented) Write(p []byte) (int, error) {
+ total := 0
+ for len(p) > 0 {
+ n := len(p)
+ if n > 31 {
+ n = 31
+ }
+ written, err := f.Conn.Write(p[:n])
+ total += written
+ p = p[written:]
+ if err != nil {
+ return total, err
+ }
+ }
+ return total, nil
+}
+func check(err error) {
+ if err != nil {
+ panic(err)
+ }
+}
+func main() {
+ tree, root := fs.NewFS("user", "user", 0755)
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat("hello.txt", "user", "user", 0644), []byte("Hello from 9P.\n"))))
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat("edit.txt", "user", "user", 0644), []byte("Edit me.\n"))))
+ large := make([]byte, 180000)
+ for i := range large {
+ large[i] = byte(i % 251)
+ }
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat("large.bin", "user", "user", 0644), large)))
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat("<literal>.txt", "user", "user", 0644), []byte("Names are text.\n"))))
+ for _, name := range []string{"app.mjs", "style.css", "client.mjs", "config.json", "cloud9.wasm", "9p", "space #?% ü.txt", "literal%2F.txt", "back\\slash.txt"} {
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat(name, "user", "user", 0644), []byte("Path: "+name+"\n"))))
+ }
+ internal := fs.NewStaticDir(tree.NewStat("_cloud9", "user", "user", 0755))
+ check(root.AddChild(internal))
+ check(internal.AddChild(fs.NewStaticFile(tree.NewStat("app.mjs", "user", "user", 0644), []byte("Upstream private-looking path.\n"))))
+ dir := fs.NewStaticDir(tree.NewStat("notes", "user", "user", 0755))
+ check(root.AddChild(dir))
+ check(dir.AddChild(fs.NewStaticFile(tree.NewStat("café.txt", "user", "user", 0644), []byte("Olá, 世界.\n"))))
+ for i := 0; i < 150; i++ {
+ name := fmt.Sprintf("item-%03d.txt", i)
+ check(dir.AddChild(fs.NewStaticFile(tree.NewStat(name, "user", "user", 0644), nil)))
+ }
+ parent := root
+ for i := 0; i < 18; i++ {
+ d := fs.NewStaticDir(tree.NewStat("deep", "user", "user", 0755))
+ check(parent.AddChild(d))
+ parent = d
+ }
+ check(parent.AddChild(fs.NewStaticFile(tree.NewStat("end.txt", "user", "user", 0644), []byte("Deep walk.\n"))))
+ network, address := "tcp", "127.0.0.1:0"
+ if len(os.Args) == 2 {
+ network, address = "unix", os.Args[1]
+ }
+ listener, err := net.Listen(network, address)
+ check(err)
+ fmt.Println(listener.Addr().String())
+ for {
+ conn, err := listener.Accept()
+ check(err)
+ go func() { defer conn.Close(); f := fragmented{conn}; _ = go9p.ServeReadWriter(f, f, tree.Server()) }()
+ }
+}
diff --git a/test/http.zig b/test/http.zig
new file mode 100644
index 0000000..d5064e4
--- /dev/null
+++ b/test/http.zig
@@ -0,0 +1,92 @@
+const std = @import("std");
+const c9 = @import("cloud9");
+const testing = std.testing;
+const http = c9.http;
+
+test "client and server preserve 9P frames at WebSocket length boundaries" {
+ var bytes: [70000]u8 = undefined;
+ var data: [66000]u8 = @splat(0xa5);
+ var receive_buffer: [70000]u8 = undefined;
+ for ([_]usize{ 0, 114, 115, 65524, 65525 }) |length| {
+ const frame = try c9.encode(.{ .rread = .{ .data = data[0..length] } }, 7, &bytes);
+ for ([_]http.Role{ .client, .server }) |role| {
+ var writer: std.Io.Writer.Allocating = .init(testing.allocator);
+ defer writer.deinit();
+ var empty: std.Io.Reader = .fixed("");
+ var sender: http.WebSocket = .{ .input = &empty, .output = &writer.writer, .role = role };
+ try sender.send(frame, .binary, if (role == .client) .{ 1, 2, 3, 4 } else null);
+ var reader: testing.Reader = .init(&.{}, &.{.{ .buffer = writer.written() }});
+ reader.artificial_limit = .limited(1);
+ var receiver: http.WebSocket = .{ .input = &reader.interface, .output = &writer.writer, .role = if (role == .client) .server else .client };
+ const message = try receiver.receive(&receive_buffer);
+ try testing.expectEqual(.binary, message.opcode);
+ try testing.expectEqualSlices(u8, frame, message.data);
+ }
+ }
+}
+
+test "fragmented message survives an interleaved ping" {
+ // Rflush tag 1 split after its size field. Ping between the fragments.
+ var reader: std.Io.Reader = .fixed(&.{ 0x02, 4, 7, 0, 0, 0, 0x89, 1, 'x', 0x80, 3, 109, 1, 0 });
+ var writer: std.Io.Writer = .fixed(&.{});
+ var socket: http.WebSocket = .{ .input = &reader, .output = &writer, .role = .client };
+ var buffer: [64]u8 = undefined;
+ const ping = try socket.receive(&buffer);
+ try testing.expectEqual(.ping, ping.opcode);
+ try testing.expectEqualStrings("x", ping.data);
+ const frame = try socket.receive(&buffer);
+ const decoded = try c9.decode(frame.data);
+ try testing.expectEqual(c9.Type.rflush, decoded.msg.msgType());
+ try testing.expectEqual(@as(u16, 1), decoded.tag);
+}
+
+test "framing rejects invalid masks, reserved bits, lengths and controls" {
+ const cases = .{
+ .{ &[_]u8{ 0x82, 0x80 }, error.InvalidMask },
+ .{ &[_]u8{ 0xc2, 0 }, error.ReservedBits },
+ .{ &[_]u8{ 0x82, 126, 0, 7 }, error.NonCanonicalLength },
+ .{ &[_]u8{ 0x89, 126, 0, 126 }, error.InvalidControl },
+ .{ &[_]u8{ 0x09, 0 }, error.InvalidControl },
+ .{ &[_]u8{ 0x80, 0 }, error.UnexpectedContinuation },
+ .{ &[_]u8{ 0x81, 0 }, error.ExpectedBinary },
+ .{ &[_]u8{ 0x88, 1, 0 }, error.InvalidClose },
+ .{ &[_]u8{ 0x88, 2, 3, 237 }, error.InvalidClose },
+ .{ &[_]u8{ 0x88, 3, 3, 232, 255 }, error.InvalidClose },
+ .{ &[_]u8{ 0x82, 100 }, error.MessageTooLarge },
+ };
+ inline for (cases) |case| {
+ var reader: std.Io.Reader = .fixed(case[0]);
+ var writer: std.Io.Writer = .fixed(&.{});
+ var socket: http.WebSocket = .{ .input = &reader, .output = &writer, .role = .client };
+ var buffer: [64]u8 = undefined;
+ try testing.expectError(case[1], socket.receive(&buffer));
+ }
+}
+
+test "binary messages must contain exactly one complete 9P frame" {
+ var reader: std.Io.Reader = .fixed(&.{ 0x82, 7, 8, 0, 0, 0, 109, 1, 0 });
+ var writer: std.Io.Writer = .fixed(&.{});
+ var socket: http.WebSocket = .{ .input = &reader, .output = &writer, .role = .client };
+ var buffer: [64]u8 = undefined;
+ try testing.expectError(error.Truncated, socket.receive(&buffer));
+}
+
+test "upgrade validates nonce and uses RFC 6455 accept value" {
+ var reader: std.Io.Reader = .fixed("GET /9p HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive, Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n");
+ var writer: std.Io.Writer.Allocating = .init(testing.allocator);
+ defer writer.deinit();
+ var server: std.http.Server = .init(&reader, &writer.writer);
+ var request = try server.receiveHead();
+ _ = try http.accept(&request);
+ try testing.expect(std.mem.find(u8, writer.written(), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") != null);
+}
+
+test "upgrade rejects invalid nonce before responding" {
+ inline for (.{ "not-a-nonce", "AAAAAAAAAAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAA=", "!!!!!!!!!!!!!!!!!!!!!!==" }) |nonce| {
+ var reader: std.Io.Reader = .fixed("GET /9p HTTP/1.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: " ++ nonce ++ "\r\n\r\n");
+ var writer: std.Io.Writer = .fixed(&.{});
+ var server: std.http.Server = .init(&reader, &writer);
+ var request = try server.receiveHead();
+ try testing.expectError(error.InvalidUpgrade, http.accept(&request));
+ }
+}
diff --git a/test/web/e2e.mjs b/test/web/e2e.mjs
new file mode 100644
index 0000000..2ff83ae
--- /dev/null
+++ b/test/web/e2e.mjs
@@ -0,0 +1,268 @@
+// Real Chromium + native cloud9 HTTPS client + pinned go9p. No npm packages.
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import net from 'node:net';
+import tls from 'node:tls';
+import http from 'node:http';
+
+const [bridgeBinary, nativeBinary] = process.argv.slice(2).map(p => path.resolve(p));
+if (!nativeBinary) throw new Error('usage: node test/web/e2e.mjs BRIDGE NATIVE_TEST');
+const root = path.resolve('.');
+await fs.mkdir('.zig-cache/tmp', { recursive: true });
+const work = await fs.mkdtemp(path.join(root, '.zig-cache/e2e-'));
+const children = [], logs = [], sockets = new Set();
+let browserSocket, tlsServer;
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+function start(command, args, options = {}) {
+ const child = spawn(command, args, { ...options, env: { ...process.env, TMPDIR: path.join(root, '.zig-cache/tmp'), ...options.env }, stdio: ['ignore', 'pipe', 'pipe'] });
+ child.output = ''; child.errors = '';
+ child.stdout.on('data', b => { child.output += b; });
+ child.stderr.on('data', b => { child.errors = (child.errors + b).slice(-20000); });
+ child.on('error', error => { child.errors += error.message; });
+ children.push(child); return child;
+}
+async function run(command, args, options = {}, success = true) {
+ const child = start(command, args, options);
+ const timer = setTimeout(() => child.kill('SIGKILL'), 60000);
+ const [code] = await once(child, 'exit'); clearTimeout(timer);
+ if (success) assert.equal(code, 0, `${command}: ${child.errors}`);
+ else assert.notEqual(code, 0, 'untrusted TLS certificate must fail');
+ return child;
+}
+async function wait(check, message, timeout = 15000) {
+ const end = Date.now() + timeout;
+ while (Date.now() < end) { try { const result = await check(); if (result) return result; } catch {} await delay(40); }
+ throw new Error(`Timed out: ${message}`);
+}
+async function launchBridge(upstream, origin, extra = []) {
+ const child = start(bridgeBinary, ['--listen', '127.0.0.1:0', '--upstream', upstream, ...(origin ? ['--origin', origin] : []), ...extra]);
+ // With a public origin the log does not include the bound address. Tests
+ // requiring a proxy instead reserve a port and pass it explicitly below.
+ const match = await wait(() => child.errors.match(/cloud9-http (http:\/\/127\.0\.0\.1:\d+)/), 'bridge ready');
+ return { child, url: match[1] };
+}
+async function port() { const s = net.createServer(); s.listen(0, '127.0.0.1'); await once(s, 'listening'); const p = s.address().port; await new Promise(r => s.close(r)); return p; }
+let nextId = 0; const pending = new Map();
+function cdp(method, params = {}) {
+ const id = ++nextId;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 30000);
+ pending.set(id, { resolve, reject, timer }); browserSocket.send(JSON.stringify({ id, method, params }));
+ });
+}
+async function evaluate(expression) {
+ const result = await cdp('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
+ return result.result.value;
+}
+async function click(selector) { await evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`); }
+async function statusIncludes(text) { await wait(() => evaluate(`document.getElementById('status').textContent.includes(${JSON.stringify(text)})`), `status: ${text}`); }
+async function openPath(value) {
+ await evaluate(`document.getElementById('path').value=${JSON.stringify(value)};document.getElementById('path-form').requestSubmit()`);
+}
+try {
+ const fixture = path.join(work, 'fixture');
+ await run('go', ['build', '-o', fixture, './webfixture'], { cwd: path.join(root, 'test/differential') });
+ const server = start(fixture, []);
+ const upstream = await wait(() => server.output.match(/127\.0\.0\.1:\d+/)?.[0], 'go9p ready');
+ const bridge = await launchBridge(`tcp:${upstream}`);
+ const url = bridge.url;
+ const wasmResponse = await fetch(`${url}/_cloud9/cloud9.wasm`);
+ assert.equal(wasmResponse.headers.get('content-type'), 'application/wasm');
+ const module = await WebAssembly.compile(await wasmResponse.arrayBuffer());
+ assert.deepEqual(WebAssembly.Module.imports(module), [], 'WASM uses cloud9 without host imports');
+ assert.equal((await fetch(`${url}/_cloud9/absent`)).status, 404);
+ assert.equal((await fetch(`${url}/`, { method: 'POST' })).status, 405);
+ assert.equal((await fetch(`${url}/_cloud9/9p`)).status, 403);
+ assert.equal(await new Promise((resolve,reject)=>{const r=http.get(url,{headers:{Host:'unrelated.example'}},response=>{response.resume();resolve(response.statusCode)});r.on('error',reject)}), 403);
+ await run(nativeBinary, [`${url}/_cloud9/9p`, url]);
+
+ const debugPort = await port();
+ const chrome = start(process.env.CLOUD9_CHROME || 'google-chrome-stable', ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', `--user-data-dir=${work}/chrome`, `--remote-debugging-port=${debugPort}`, 'about:blank']);
+ const target = await wait(async () => {
+ const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: 'PUT' }); return response.ok && response.json();
+ }, 'Chromium ready');
+ browserSocket = new WebSocket(target.webSocketDebuggerUrl);
+ await once(browserSocket, 'open');
+ browserSocket.addEventListener('message', ({ data }) => {
+ const message = JSON.parse(data);
+ if (message.id) {
+ const request = pending.get(message.id); if (!request) return;
+ clearTimeout(request.timer); pending.delete(message.id);
+ if (message.error) request.reject(new Error(JSON.stringify(message.error))); else request.resolve(message.result);
+ } else if (message.method === 'Runtime.exceptionThrown') logs.push(message.params.exceptionDetails);
+ });
+ await cdp('Runtime.enable'); await cdp('Page.enable');
+ await cdp('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: path.join(work, 'downloads') });
+ await cdp('Emulation.setDeviceMetricsOverride', { width: 1120, height: 900, deviceScaleFactor: 1, mobile: false });
+ await cdp('Page.navigate', { url });
+ await wait(() => evaluate(`typeof document.getElementById('path-form')?.onsubmit === 'function'`), 'UI loaded');
+ await statusIncludes('entries');
+ assert.equal(await evaluate(`document.querySelectorAll('#connect,#disconnect,#user,#tree').length`), 0);
+ assert.deepEqual(await (await fetch(`${url}/_cloud9/config.json`)).json(), {user:'user',tree:''});
+ assert.ok(await evaluate(`[...document.querySelectorAll('.entry')].some(e=>e.textContent==='hello.txt')`));
+ assert.ok(await evaluate(`[...document.querySelectorAll('.entry')].some(e=>e.textContent==='<literal>.txt')`));
+ assert.equal(await evaluate(`document.querySelectorAll('literal').length`), 0);
+ await openPath('/hello.txt'); await statusIncludes('File loaded');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Hello from 9P.\n');
+ assert.equal(await evaluate(`location.pathname`), '/hello.txt');
+ await evaluate('history.back()'); await statusIncludes('entries');
+ await evaluate('history.forward()'); await statusIncludes('File loaded');
+ assert.ok(await evaluate(`document.querySelector('#breadcrumbs a[aria-current]').textContent==='hello.txt'`));
+ await openPath('/notes'); await statusIncludes('151 entries');
+ await openPath('/notes/café.txt'); await statusIncludes('File loaded');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Olá, 世界.\n');
+ await openPath('/edit.txt'); await statusIncludes('File loaded');
+ await evaluate(`document.getElementById('editor').value='Saved from Chromium.\\n';document.getElementById('editor').dispatchEvent(new Event('input'))`);
+ await click('#save'); await statusIncludes('Saved 21 bytes');
+ await evaluate('window.beforeReload=true');
+ await cdp('Page.reload');
+ await wait(() => evaluate(`!window.beforeReload && document.getElementById('status')?.textContent.includes('File loaded')`), 'automatic attach after page reload');
+ assert.equal(await evaluate(`location.pathname`), '/edit.txt');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Saved from Chromium.\n');
+ await openPath('/missing/child'); await statusIncludes('');
+ await wait(() => evaluate(`document.getElementById('status').classList.contains('error') && !document.getElementById('go').disabled`), 'missing path error');
+ await openPath('/hello.txt'); await statusIncludes('File loaded');
+ await openPath('/large.bin'); await statusIncludes('File loaded');
+ assert.equal(await evaluate(`document.getElementById('binary').hidden`), false);
+ assert.equal(await evaluate(`document.getElementById('save').disabled`), true);
+ await click('#download');
+ const download = await wait(async () => {
+ const bytes = await fs.readFile(path.join(work, 'downloads/large.bin'));
+ return bytes.length === 180000 && bytes;
+ }, 'binary download');
+ assert.ok(download.every((value, index) => value === index % 251));
+
+ // Invoke the shipped JS/WASM API in the browser for byte-exact, multi-frame
+ // transfers, queued operations, independent fid spaces, and long walks.
+ const checks = await evaluate(`(async()=>{
+ const {Client}=await import('/_cloud9/client.mjs');
+ const a=await Client.connect(), b=await Client.connect();
+ try {
+ const [large,other]=await Promise.all([a.readPath('/large.bin'),b.readPath('/hello.txt')]);
+ if(large.bytes.length!==180000||large.bytes.some((v,i)=>v!==i%251))throw Error('binary mismatch');
+ const bytes=Uint8Array.from({length:150000},(_,i)=>(i*17)%251);
+ await a.writePath('/edit.txt',bytes);
+ const read=await b.readPath('/edit.txt');
+ if(read.bytes.length!==bytes.length||read.bytes.some((v,i)=>v!==bytes[i]))throw Error('write mismatch');
+ await a.writePath('/edit.txt',new Uint8Array());
+ if((await b.readPath('/edit.txt')).bytes.length!==0)throw Error('truncate mismatch');
+ const deep=await a.readPath('/'+Array(18).fill('deep').join('/')+'/end.txt');
+ if(new TextDecoder().decode(deep.bytes)!=='Deep walk.\\n')throw Error('deep walk');
+ const queued=await Promise.all([a.readPath('/hello.txt'),a.readPath('/notes/café.txt')]);
+ return {binary:large.bytes.length,written:bytes.length,queued:queued.length,independent:new TextDecoder().decode(other.bytes)};
+ }finally{a.close();b.close()}
+ })()`);
+ assert.equal(checks.binary, 180000); assert.equal(checks.written, 150000); assert.equal(checks.queued, 2);
+ await openPath('/'); await statusIncludes('entries');
+ await fs.writeFile(path.join(work, 'browser.png'), Buffer.from((await cdp('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
+ await cdp('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
+ assert.ok(await evaluate('document.documentElement.scrollWidth <= innerWidth'), 'mobile layout fits viewport');
+ await fs.writeFile(path.join(work, 'mobile.png'), Buffer.from((await cdp('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
+ assert.deepEqual(logs, [], 'no uncaught browser exceptions');
+
+ // File paths share no routes with gateway assets. Read them through actual
+ // generated links, then reload those URLs in a fresh browser document.
+ for (const name of ['app.mjs', 'style.css', 'client.mjs', 'config.json', 'cloud9.wasm', '9p', 'space #?% ü.txt', 'literal%2F.txt', 'back\\slash.txt']) {
+ await openPath('/'); await statusIncludes('entries');
+ const expectedPath = '/' + encodeURIComponent(name);
+ const href = await evaluate(`document.querySelector('a[data-path='+CSS.escape(${JSON.stringify('/'+name)})+']').getAttribute('href')`);
+ assert.equal(href, expectedPath);
+ await evaluate(`document.querySelector('a[data-path='+CSS.escape(${JSON.stringify('/'+name)})+']').click()`);
+ await statusIncludes('File loaded');
+ assert.equal(await evaluate('location.pathname'), expectedPath);
+ assert.equal(await evaluate('location.search + location.hash'), '');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Path: '+name+'\n');
+ await evaluate('window.beforeReload=true');
+ await cdp('Page.reload');
+ await wait(() => evaluate(`!window.beforeReload && document.getElementById('status')?.textContent.includes('File loaded')`), 'encoded file reload');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Path: '+name+'\n');
+ }
+ await openPath('/_cloud9/app.mjs'); await statusIncludes('File loaded');
+ assert.equal(await evaluate('location.pathname'), '/%5Fcloud9/app.mjs');
+ await evaluate('window.beforeReload=true'); await cdp('Page.reload');
+ await wait(() => evaluate(`!window.beforeReload && document.getElementById('editor')?.value==='Upstream private-looking path.\\n'`), 'escaped gateway-prefix file');
+ for (const path of ['/bad%escape', '/notes%2Fcaf%C3%A9.txt', '/bad%00name']) {
+ await cdp('Page.navigate', {url:url+path});
+ await wait(() => evaluate(`location.pathname===${JSON.stringify(path)} && document.getElementById('status')?.textContent.includes('The URL contains')`), 'invalid path rejected');
+ await click('.brand'); await statusIncludes('entries');
+ assert.equal(await evaluate('location.pathname'), '/');
+ }
+
+ // Expiry is transport bookkeeping: edits survive it, and Save establishes
+ // a fresh session without a user-facing connection flow.
+ const shortBridge = await launchBridge(`tcp:${upstream}`, null, ['--timeout-ms', '1000', '--user', 'user', '--tree', '']);
+ await cdp('Page.navigate', {url:shortBridge.url+'/edit.txt'});
+ await wait(() => evaluate(`location.port===${JSON.stringify(new URL(shortBridge.url).port)} && document.getElementById('status')?.textContent.includes('File loaded')`), 'direct file URL');
+ await evaluate(`document.getElementById('editor').value='After expiry.\\n';document.getElementById('editor').dispatchEvent(new Event('input'))`);
+ await delay(1300);
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'After expiry.\n');
+ await click('#save'); await statusIncludes('Saved 14 bytes');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'After expiry.\n');
+ const configured = await launchBridge(`tcp:${upstream}`, null, ['--user', 'reader', '--tree', 'named-export']);
+ assert.deepEqual(await (await fetch(configured.url+'/_cloud9/config.json')).json(), {user:'reader',tree:'named-export'});
+ await cdp('Page.navigate', {url:'about:blank'});
+
+ // Same public native API over certificate-verified TLS. The TLS terminator
+ // forwards bytes; the 9P server still runs behind the HTTP bridge over TCP.
+ const key = path.join(work, 'key.pem'), cert = path.join(work, 'cert.pem');
+ await run('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-noenc', '-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost']);
+ const httpsPort = await port(), backendPort = await port();
+ const origin = `https://localhost:${httpsPort}`;
+ const secureBridge = start(bridgeBinary, ['--listen', `127.0.0.1:${backendPort}`, '--upstream', `tcp:${upstream}`, '--origin', origin]);
+ await wait(() => secureBridge.errors.includes('cloud9-http'), 'TLS backend ready');
+ tlsServer = tls.createServer({ key: await fs.readFile(key), cert: await fs.readFile(cert), ALPNProtocols: ['http/1.1'] }, socket => {
+ const upstreamSocket = net.connect(backendPort, '127.0.0.1');
+ sockets.add(socket); sockets.add(upstreamSocket);
+ socket.on('error', () => upstreamSocket.destroy()); upstreamSocket.on('error', () => socket.destroy());
+ socket.on('close', () => { sockets.delete(socket); upstreamSocket.destroy(); });
+ upstreamSocket.on('close', () => { sockets.delete(upstreamSocket); socket.destroy(); });
+ socket.pipe(upstreamSocket).pipe(socket);
+ });
+ tlsServer.on('tlsClientError', () => {});
+ tlsServer.listen(httpsPort); await once(tlsServer, 'listening');
+ await run(nativeBinary, [`${origin}/_cloud9/9p`, origin, cert]);
+ await run(nativeBinary, [`${origin}/_cloud9/9p`, origin], {}, false);
+ await run(nativeBinary, [`https://127.0.0.1:${httpsPort}/_cloud9/9p`, origin, cert], {}, false);
+
+ const unixPath = path.join(work, '9p.sock');
+ const unixServer = start(fixture, [unixPath]);
+ await wait(() => unixServer.output.includes(unixPath), 'Unix go9p ready');
+ const unixBridge = await launchBridge(`unix:${unixPath}`);
+ await run(nativeBinary, [`${unixBridge.url}/_cloud9/9p`, unixBridge.url]);
+ const serial = start('python3', ['test/web/serial.py', upstream]);
+ const serialPath = await wait(() => serial.output.match(/\/dev\/pts\/\d+/)?.[0], 'serial fixture ready');
+ const serialBridge = await launchBridge(`file:${serialPath}`);
+ await run(nativeBinary, [`${serialBridge.url}/_cloud9/9p`, serialBridge.url]);
+ // Closing a browser connection must cancel a blocked serial read and release
+ // the exclusive device lease before another session attaches.
+ await delay(100);
+ await run(nativeBinary, [`${serialBridge.url}/_cloud9/9p`, serialBridge.url]);
+
+ const deadlineBridge = start(bridgeBinary, ['--listen', '127.0.0.1:0', '--upstream', `tcp:${upstream}`, '--timeout-ms', '100']);
+ const deadlineURL = await wait(() => deadlineBridge.errors.match(/http:\/\/127\.0\.0\.1:\d+/)?.[0], 'deadline bridge');
+ const stalled = net.connect(Number(new URL(deadlineURL).port), '127.0.0.1');
+ await once(stalled, 'connect');
+ stalled.write('GET / HTTP/1.1\r\n');
+ await Promise.race([once(stalled, 'close'), delay(2000).then(()=>{stalled.destroy();throw Error('header deadline did not close connection')})]);
+ const report = { browser: 'Chromium', reference: 'go9p v1.18.0', wasmHostImports: 0, checks, passed: ['clean path links and reloads', 'encoded filename round trips', 'gateway asset name collisions', 'escaped gateway-prefix file', 'invalid path encoding rejected', 'automatic connection', 'bookmarkable file URLs', 'browser back and forward', 'automatic reconnect preserves edits', 'configured attach defaults', 'directory paging', 'UTF-8 paths and contents', 'text save and reconnect', 'binary read/write', 'browser download', 'mobile layout', 'truncate to empty', '18-component walk', 'concurrent sessions', 'queued operations', 'Rerror recovery', 'HTTP routing and origin policy', 'native HTTP', 'native verified HTTPS', 'untrusted certificate rejection', 'hostname mismatch rejection', 'Unix upstream', 'PTY serial upstream', 'serial lease released after disconnect', 'connection deadline', 'overlapping native requests', 'fragmented upstream I/O'] };
+ await fs.writeFile(path.join(work, 'result.json'), JSON.stringify(report, null, 2)+'\n');
+ console.log(`E2E passed: ${report.passed.length} scenarios. Artifacts: ${path.relative(root, work)}`);
+} catch (error) {
+ console.error(error.stack);
+ if (browserSocket?.readyState === WebSocket.OPEN) console.error('Browser state:', await evaluate(`({status:document.getElementById('status')?.textContent,body:document.body?.innerText})`).catch(String));
+ console.error('Browser exceptions:', logs);
+ for (const child of children) if (child.errors) console.error(child.spawnargs.join(' '), '\n', child.errors);
+ process.exitCode = 1;
+} finally {
+ for (const request of pending.values()) clearTimeout(request.timer);
+ browserSocket?.close();
+ for (const socket of sockets) socket.destroy();
+ tlsServer?.close();
+ for (const child of children.reverse()) if (child.exitCode === null) child.kill('SIGTERM');
+ await delay(300);
+ for (const child of children) if (child.exitCode === null) child.kill('SIGKILL');
+}
diff --git a/test/web/native.zig b/test/web/native.zig
new file mode 100644
index 0000000..ad11386
--- /dev/null
+++ b/test/web/native.zig
@@ -0,0 +1,66 @@
+//! Native use of the public HTTPS transport, exercised by the browser E2E runner.
+const std = @import("std");
+const c9 = @import("cloud9");
+const Session = struct {
+ tunnel: *c9.http.Client,
+ client: c9.Client,
+ fn ask(s: *Session, request: c9.Client.Request) !c9.Client.Result {
+ const tag = try s.client.submit(request);
+ try s.tunnel.send(s.client.output());
+ s.client.wrote(s.client.output().len);
+ var buffer: [65536]u8 = undefined;
+ const frame = try s.tunnel.receive(&buffer);
+ if (s.client.push(frame) != frame.len) return error.InputFull;
+ const done = s.client.take() orelse return error.MissingReply;
+ if (done.tag != tag or done.result == .fail) return error.UnexpectedReply;
+ return done.result;
+ }
+};
+pub fn main(init: std.process.Init) !void {
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ if (args.len < 3 or args.len > 4) return error.Arguments;
+ var http: std.http.Client = .{ .allocator = init.gpa, .io = init.io };
+ defer http.deinit();
+ if (args.len == 4) {
+ http.now = std.Io.Clock.real.now(init.io);
+ try http.ca_bundle.addCertsFromFilePathAbsolute(init.gpa, init.io, http.now.?, args[3]);
+ }
+ var tunnel = try c9.http.Client.connect(&http, args[1], args[2]);
+ defer tunnel.deinit();
+ var input: [65536]u8 = undefined;
+ var output: [65536]u8 = undefined;
+ var session: Session = .{ .tunnel = &tunnel, .client = .init(.{ .in = &input, .out = &output }) };
+ _ = try session.ask(.{ .version = .{} });
+ _ = try session.ask(.{ .attach = .{ .fid = 0, .uname = "user" } });
+ _ = try session.ask(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"hello.txt"} } });
+ _ = try session.ask(.{ .open = .{ .fid = 1, .mode = c9.oread } });
+ const result = try session.ask(.{ .read = .{ .fid = 1, .offset = 0, .count = 1024 } });
+ if (!std.mem.eql(u8, result.read, "Hello from 9P.\n")) return error.WrongContents;
+ // Two outstanding tags exercise the full-duplex bridge independently of
+ // the browser UI's serialized file-operation queue.
+ const read_tag = try session.client.submit(.{ .read = .{ .fid = 1, .offset = 0, .count = 1024 } });
+ try tunnel.send(session.client.output());
+ session.client.wrote(session.client.output().len);
+ const stat_tag = try session.client.submit(.{ .stat = .{ .fid = 0 } });
+ try tunnel.send(session.client.output());
+ session.client.wrote(session.client.output().len);
+ var seen_read = false;
+ var seen_stat = false;
+ var reply_buffer: [65536]u8 = undefined;
+ for (0..2) |_| {
+ const frame = try tunnel.receive(&reply_buffer);
+ if (session.client.push(frame) != frame.len) return error.InputFull;
+ const done = session.client.take() orelse return error.MissingReply;
+ if (done.tag == read_tag and !seen_read and done.result == .read) {
+ if (!std.mem.eql(u8, done.result.read, "Hello from 9P.\n")) return error.WrongContents;
+ seen_read = true;
+ } else if (done.tag == stat_tag and !seen_stat and done.result == .stat) {
+ if (done.result.stat.qid.type & c9.qtdir == 0) return error.WrongStat;
+ seen_stat = true;
+ } else return error.UnexpectedReply;
+ }
+ if (!seen_read or !seen_stat) return error.MissingReply;
+ _ = try session.ask(.{ .clunk = .{ .fid = 1 } });
+ _ = try session.ask(.{ .clunk = .{ .fid = 0 } });
+ std.debug.print("native HTTP transport passed\n", .{});
+}
diff --git a/test/web/serial.py b/test/web/serial.py
new file mode 100644
index 0000000..83833a4
--- /dev/null
+++ b/test/web/serial.py
@@ -0,0 +1,25 @@
+"""Raw PTY to local reference server: exercise a UART-like byte stream."""
+import os
+import pty
+import select
+import socket
+import sys
+import tty
+
+master, slave = pty.openpty()
+tty.setraw(slave)
+host, port = sys.argv[1].split(":")
+peer = socket.create_connection((host, int(port)))
+print(os.ttyname(slave), flush=True)
+while True:
+ ready, _, _ = select.select([master, peer], [], [])
+ for source in ready:
+ data = os.read(master, 19) if source == master else peer.recv(23)
+ if not data:
+ sys.exit(0)
+ if source == master:
+ peer.sendall(data)
+ else:
+ while data:
+ written = os.write(master, data)
+ data = data[written:]