| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
import { expect, test } from "bun:test"; |
| 12 |
import { mkdtempSync } from "node:fs"; |
| 13 |
import { tmpdir } from "node:os"; |
| 14 |
import { join } from "node:path"; |
| 15 |
|
| 16 |
process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); |
| 17 |
process.env.PORT ??= "0"; |
| 18 |
const { server } = await import("./server.ts"); |
| 19 |
const base = `http://${server.hostname}:${server.port}`; |
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
const drawn: string[] = []; |
| 24 |
const listeners = new Map<string, ((event: unknown) => void)[]>(); |
| 25 |
const frames: ((now: number) => void)[] = []; |
| 26 |
|
| 27 |
const noop = () => {}; |
| 28 |
const context = () => |
| 29 |
new Proxy( |
| 30 |
{ |
| 31 |
measureText: (text: string) => ({ width: text.length * 6 }), |
| 32 |
createLinearGradient: () => ({ addColorStop: noop }), |
| 33 |
} as Record<string, unknown>, |
| 34 |
{ |
| 35 |
get(target, prop: string) { |
| 36 |
if (prop in target) return target[prop]; |
| 37 |
if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return ""; |
| 38 |
if (prop === "globalAlpha" || prop === "lineWidth") return 1; |
| 39 |
return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`); |
| 40 |
}, |
| 41 |
set: () => true, |
| 42 |
}, |
| 43 |
); |
| 44 |
|
| 45 |
function element(id: string, attrs: Record<string, string> = {}) { |
| 46 |
const node: Record<string, unknown> = { |
| 47 |
id, |
| 48 |
dataset: { ...attrs }, |
| 49 |
style: {}, |
| 50 |
textContent: "", |
| 51 |
title: "", |
| 52 |
disabled: false, |
| 53 |
type: "", |
| 54 |
children: [] as unknown[], |
| 55 |
classList: { |
| 56 |
on: new Set<string>(), |
| 57 |
add(name: string) { |
| 58 |
(this.on as Set<string>).add(name); |
| 59 |
}, |
| 60 |
remove(name: string) { |
| 61 |
(this.on as Set<string>).delete(name); |
| 62 |
}, |
| 63 |
toggle(name: string, force?: boolean) { |
| 64 |
const on = force ?? !(this.on as Set<string>).has(name); |
| 65 |
if (on) (this.on as Set<string>).add(name); |
| 66 |
else (this.on as Set<string>).delete(name); |
| 67 |
return on; |
| 68 |
}, |
| 69 |
contains(name: string) { |
| 70 |
return (this.on as Set<string>).has(name); |
| 71 |
}, |
| 72 |
}, |
| 73 |
events: new Map<string, ((event: unknown) => void)[]>(), |
| 74 |
addEventListener(kind: string, fn: (event: unknown) => void) { |
| 75 |
const map = node.events as Map<string, ((event: unknown) => void)[]>; |
| 76 |
map.set(kind, [...(map.get(kind) ?? []), fn]); |
| 77 |
}, |
| 78 |
append(...kids: unknown[]) { |
| 79 |
(node.children as unknown[]).push(...kids); |
| 80 |
}, |
| 81 |
replaceChildren(...kids: unknown[]) { |
| 82 |
node.children = kids; |
| 83 |
}, |
| 84 |
getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }), |
| 85 |
getContext: () => context(), |
| 86 |
requestPointerLock: noop, |
| 87 |
setPointerCapture: noop, |
| 88 |
width: 900, |
| 89 |
height: 570, |
| 90 |
}; |
| 91 |
return node; |
| 92 |
} |
| 93 |
|
| 94 |
const click = (node: Record<string, unknown>) => { |
| 95 |
for (const fn of (node.events as Map<string, ((e: unknown) => void)[]>).get("click") ?? []) fn({}); |
| 96 |
}; |
| 97 |
const press = (code: string, key = "") => { |
| 98 |
for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop }); |
| 99 |
}; |
| 100 |
|
| 101 |
const nodes = new Map<string, Record<string, unknown>>(); |
| 102 |
for (const id of [ |
| 103 |
"view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle", |
| 104 |
"viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage", |
| 105 |
"log", "atkboard", "defboard", "helpagents", "roster", "feed", |
| 106 |
]) |
| 107 |
nodes.set(id, element(id)); |
| 108 |
|
| 109 |
const buys = [ |
| 110 |
element("b1", { buy: "vandal", cost: "2900" }), |
| 111 |
element("b2", { buy: "c", cost: "0" }), |
| 112 |
element("b3", { buy: "q", cost: "0" }), |
| 113 |
]; |
| 114 |
const agentButtons = [element("a1", { agent: "veil" })]; |
| 115 |
const scoreRed = element("red"); |
| 116 |
const scoreBlue = element("blue"); |
| 117 |
|
| 118 |
Object.assign(globalThis, { |
| 119 |
document: { |
| 120 |
getElementById: (id: string) => nodes.get(id) ?? null, |
| 121 |
querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue), |
| 122 |
querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons), |
| 123 |
createElement: (tag: string) => element(tag), |
| 124 |
addEventListener: noop, |
| 125 |
pointerLockElement: null, |
| 126 |
exitPointerLock: noop, |
| 127 |
}, |
| 128 |
HTMLInputElement: class {}, |
| 129 |
matchMedia: () => ({ matches: false }), |
| 130 |
devicePixelRatio: 1, |
| 131 |
localStorage: { |
| 132 |
store: new Map<string, string>(), |
| 133 |
getItem(key: string) { |
| 134 |
return this.store.get(key) ?? null; |
| 135 |
}, |
| 136 |
setItem(key: string, value: string) { |
| 137 |
this.store.set(key, value); |
| 138 |
}, |
| 139 |
}, |
| 140 |
requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn), |
| 141 |
addEventListener: (kind: string, fn: (event: unknown) => void) => { |
| 142 |
listeners.set(kind, [...(listeners.get(kind) ?? []), fn]); |
| 143 |
}, |
| 144 |
location: new URL(base), |
| 145 |
}); |
| 146 |
|
| 147 |
|
| 148 |
const realFetch = globalThis.fetch; |
| 149 |
globalThis.fetch = ((input: string, init?: RequestInit) => |
| 150 |
realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch; |
| 151 |
|
| 152 |
const runFrame = () => { |
| 153 |
const pending = frames.splice(0, frames.length); |
| 154 |
for (const fn of pending) fn(performance.now()); |
| 155 |
}; |
| 156 |
const until = async (what: () => boolean, ms = 4000) => { |
| 157 |
const stop = Date.now() + ms; |
| 158 |
while (Date.now() < stop) { |
| 159 |
runFrame(); |
| 160 |
if (what()) return true; |
| 161 |
await Bun.sleep(20); |
| 162 |
} |
| 163 |
return false; |
| 164 |
}; |
| 165 |
|
| 166 |
|
| 167 |
|
| 168 |
test("the bundled client connects, paints the HUD and draws a frame", async () => { |
| 169 |
const page = await (await fetch(base + "/")).text(); |
| 170 |
const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!; |
| 171 |
const script = await (await fetch(base + src)).text(); |
| 172 |
const path = join(process.env.FORGE_APP_DATA!, "client.mjs"); |
| 173 |
await Bun.write(path, script); |
| 174 |
|
| 175 |
await import(path); |
| 176 |
expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true); |
| 177 |
expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true); |
| 178 |
|
| 179 |
|
| 180 |
expect(String(nodes.get("phase")!.textContent)).toContain("round"); |
| 181 |
expect(String(nodes.get("creds")!.textContent)).toContain("cr"); |
| 182 |
|
| 183 |
expect((nodes.get("kit")!.children as unknown[]).length).toBe(4); |
| 184 |
|
| 185 |
expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8); |
| 186 |
|
| 187 |
expect(String(buys[1]!.title)).toContain("charge"); |
| 188 |
|
| 189 |
|
| 190 |
const slots = nodes.get("roster")!.children as { className: string }[]; |
| 191 |
expect(slots.length).toBe(5); |
| 192 |
expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1); |
| 193 |
|
| 194 |
|
| 195 |
|
| 196 |
drawn.length = 0; |
| 197 |
runFrame(); |
| 198 |
expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); |
| 199 |
|
| 200 |
|
| 201 |
for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop }); |
| 202 |
drawn.length = 0; |
| 203 |
runFrame(); |
| 204 |
expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(800); |
| 205 |
for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" }); |
| 206 |
drawn.length = 0; |
| 207 |
runFrame(); |
| 208 |
expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800); |
| 209 |
|
| 210 |
|
| 211 |
const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 212 |
press("KeyW"); |
| 213 |
await Bun.sleep(160); |
| 214 |
expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9); |
| 215 |
expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4); |
| 216 |
|
| 217 |
|
| 218 |
press("KeyV"); |
| 219 |
drawn.length = 0; |
| 220 |
runFrame(); |
| 221 |
expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true); |
| 222 |
expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" }); |
| 223 |
press("KeyV"); |
| 224 |
expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" }); |
| 225 |
|
| 226 |
|
| 227 |
const help = nodes.get("help")!.style as Record<string, string>; |
| 228 |
const before = help.display; |
| 229 |
press("KeyH"); |
| 230 |
expect(help.display).not.toBe(before); |
| 231 |
if (help.display !== "grid") press("KeyH"); |
| 232 |
expect(help.display).toBe("grid"); |
| 233 |
click(nodes.get("helpclose")!); |
| 234 |
expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none"); |
| 235 |
expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1"); |
| 236 |
|
| 237 |
|
| 238 |
const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean }; |
| 239 |
const wasOpen = panel.contains("on"); |
| 240 |
press("KeyB"); |
| 241 |
expect(panel.contains("on")).toBe(!wasOpen); |
| 242 |
|
| 243 |
|
| 244 |
expect(seat.bot).toBe(false); |
| 245 |
}, 15000); |