import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node" import { ServerProcess } from "@opencode/server/process" import { afterAll, describe, expect, test } from "bun:test" import { Effect } from "effect" import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { createServer } from "node:http" import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { WebUi } from "../src/services/web-ui" import { it } from "../../core/test/lib/effect" const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-")) afterAll(() => rm(root, { recursive: true, force: true })) describe("web UI", () => { it.live("serves the web shell and assets before server authentication", () => Effect.gen(function* () { const transform = yield* WebUi.handler({ assets: { "index.html": "connect", "_assets/app.js": "console.log('connect')", "_assets/app.css": "body { color: black; }", "icons/icon.svg": "", "font.woff2": new Uint8Array([0, 1, 2, 255]), "sw.js": "service worker", }, }) const server = yield* ServerProcess.start( { hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } }, undefined, transform, ) const origin = HttpServer.formatAddress(server.address) yield* Effect.forEach( [ "/", "/settings", "/workspace/example", "/_assets/app.js", "/_assets/app.css", "/icons/icon.svg", "/font.woff2", "/sw.js", ], (pathname) => Effect.gen(function* () { yield* Effect.forEach(["GET", "HEAD"], (method) => Effect.gen(function* () { const response = yield* Effect.promise(() => fetch(new URL(pathname, origin), { method })) expect(response.status).toBe(200) expect(response.headers.get("www-authenticate")).toBeNull() yield* Effect.promise(() => response.arrayBuffer()) }), ) }), ) yield* Effect.forEach(["/api", "/api/info", "/api/event", "/api/missing", "/openapi.json"], (pathname) => Effect.gen(function* () { const response = yield* Effect.promise(() => fetch(new URL(pathname, origin))) expect(response.status).toBe(401) expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"') yield* Effect.promise(() => response.arrayBuffer()) }), ) const response = yield* Effect.promise(() => fetch(new URL("/api/info", origin), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }), ) expect(response.status).toBe(200) expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid") const pairing = yield* Effect.promise(() => fetch(new URL("/api/pair", origin), { method: "POST", headers: { authorization: `Basic ${btoa("opencode:secret")}` }, }).then((response) => response.json() as Promise<{ code: string }>), ) const redirect = yield* Effect.promise(() => fetch(new URL(`/auth/connect/${pairing.code}`, origin), { redirect: "manual", headers: { accept: "text/html" }, }), ) expect(redirect.status).toBe(302) const cookie = (redirect.headers.get("set-cookie") ?? "").split(";")[0] const authorized = yield* Effect.promise(() => fetch(new URL("/api/info", origin), { headers: { cookie } })) expect(authorized.status).toBe(200) yield* Effect.promise(() => authorized.arrayBuffer()) }).pipe(Effect.provide(NodeFileSystem.layer)), ) test("falls back from API routes to assets and the SPA index", async () => { const index = path.join(root, "index.html") const asset = path.join(root, "app.js") await writeFile(index, "embedded") await writeFile(asset, "console.log('embedded')") const assets = { "index.html": await Bun.file(index).text(), "_assets/app.js": await Bun.file(asset).text(), "sw.js": "service worker", "registerSW.js": "registration", "font.woff2": new Uint8Array([0, 1, 2, 255]), } await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const transform = yield* WebUi.handler({ assets }) const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 }) yield* http.serve( transform( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest const pathname = new URL(request.url, "http://localhost").pathname if (pathname === "/api/info") return HttpServerResponse.jsonUnsafe({ version: "test", pid: 1, urls: [origin], paths: { tmp: "/tmp/opencode" }, }) return yield* Effect.fail( new HttpServerError.HttpServerError({ reason: new HttpServerError.RouteNotFound({ request }), }), ) }), ), ) const origin = HttpServer.formatAddress(http.address) const status = yield* Effect.promise(() => fetch(`${origin}/api/info`)) expect(yield* Effect.promise(() => status.json())).toEqual({ version: "test", pid: 1, urls: [origin], paths: { tmp: "/tmp/opencode" }, }) const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`)) expect(missing.status).toBe(404) expect(yield* Effect.promise(() => missing.text())).toBe("") yield* Effect.forEach(["/_assets/old.js", "/_assets/old.css", "/_assets/missing"], (pathname) => Effect.gen(function* () { const missing = yield* Effect.promise(() => fetch(`${origin}${pathname}`)) expect(missing.status).toBe(404) expect(missing.headers.get("cache-control")).toBe("no-store") expect(yield* Effect.promise(() => missing.text())).toBe("") }), ) const script = yield* Effect.promise(() => fetch(`${origin}/_assets/app.js`)) expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')") expect(script.headers.get("content-type")).toContain("javascript") expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable") const worker = yield* Effect.promise(() => fetch(`${origin}/sw.js`)) expect(worker.headers.get("cache-control")).toBe("no-cache") const registration = yield* Effect.promise(() => fetch(`${origin}/registerSW.js`)) expect(registration.headers.get("cache-control")).toBe("no-cache") const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`)) expect(font.headers.get("content-type")).toBe("font/woff2") expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual( new Uint8Array([0, 1, 2, 255]), ) const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`)) expect(yield* Effect.promise(() => fallback.text())).toContain("embedded") expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'") expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:") const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`)) expect(dotted.status).toBe(200) expect(yield* Effect.promise(() => dotted.text())).toContain("embedded") const legacy = yield* Effect.promise(() => fetch(`${origin}/assets/missing.js`)) expect(legacy.status).toBe(200) expect(yield* Effect.promise(() => legacy.text())).toContain("embedded") }), ).pipe(Effect.provide(NodeFileSystem.layer)), ) }) })