heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files e53b98b72d84ce5feeddbe32d2586ca5df139720
| 1 | import { expect, test } from "bun:test"; |
| 2 | import { mkdtempSync } from "node:fs"; |
| 3 | import { tmpdir } from "node:os"; |
| 4 | import { join } from "node:path"; |
| 5 | |
| 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "lanternfall-web-")); |
| 7 | process.env.PORT = "0"; |
| 8 | const { server } = await import("./server.ts"); |
| 9 | const base = `http://${server.hostname}:${server.port}`; |
| 10 | |
| 11 | class Player { |
| 12 | cookie = ""; |
| 13 | private keep(response: Response): void { |
| 14 | const set = response.headers.get("set-cookie"); |
| 15 | if (set) this.cookie = set.split(";")[0] ?? ""; |
| 16 | } |
| 17 | async look(path = "/"): Promise<string> { |
| 18 | const response = await fetch(base + path, { |
| 19 | headers: this.cookie === "" ? {} : { cookie: this.cookie }, |
| 20 | }); |
| 21 | this.keep(response); |
| 22 | return response.text(); |
| 23 | } |
| 24 | async act(action: string): Promise<Response> { |
| 25 | const response = await fetch(`${base}/act`, { |
| 26 | method: "POST", |
| 27 | body: new URLSearchParams({ action }), |
| 28 | redirect: "manual", |
| 29 | headers: { |
| 30 | "content-type": "application/x-www-form-urlencoded", |
| 31 | ...(this.cookie === "" ? {} : { cookie: this.cookie }), |
| 32 | }, |
| 33 | }); |
| 34 | this.keep(response); |
| 35 | return response; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | test("the dungeon opens with a delver, a map and a lantern", async () => { |
| 40 | const page = await new Player().look(); |
| 41 | expect(page).toContain("lanternfall"); |
| 42 | expect(page).toContain("health"); |
| 43 | expect(page).toContain("12 / 12"); |
| 44 | expect(page).toContain('aria-label="the dungeon floor, drawn in ascii"'); |
| 45 | // 48 x 24 cells, all rendered. |
| 46 | expect((page.match(/<span class="/g) ?? []).length).toBeGreaterThanOrEqual(48 * 24); |
| 47 | }); |
| 48 | |
| 49 | test("the first screen says what you are here for", async () => { |
| 50 | const page = await new Player().look(); |
| 51 | expect(page).toContain("standing on the way out"); |
| 52 | expect(page).toContain("yours only once you climb back out"); |
| 53 | expect(page).toContain("Walk into a thing to fight it"); |
| 54 | }); |
| 55 | |
| 56 | test("the log tells this turn apart from what it remembers", async () => { |
| 57 | const player = new Player(); |
| 58 | await player.look(); |
| 59 | await player.act("wait"); |
| 60 | let page = await player.look(); |
| 61 | // The only line so far belongs to this turn. |
| 62 | expect(page).toMatch(/<p class="now">You wait/); |
| 63 | await player.act("descend"); // off a stair: refused |
| 64 | page = await player.look(); |
| 65 | expect(page).toMatch(/<p>You wait/); |
| 66 | expect(page).toMatch(/<p class="now">There is no stair here\./); |
| 67 | // The same refusal again is still answered, not swallowed. |
| 68 | await player.act("descend"); |
| 69 | page = await player.look(); |
| 70 | expect(page).toMatch(/<p class="now">There is no stair here\./); |
| 71 | expect((page.match(/There is no stair here/g) ?? []).length).toBe(1); |
| 72 | }); |
| 73 | |
| 74 | test("a visitor gets one delver, kept in an HttpOnly cookie", async () => { |
| 75 | const response = await fetch(`${base}/`); |
| 76 | const set = response.headers.get("set-cookie") ?? ""; |
| 77 | expect(set).toMatch(/delver=[a-f0-9]{32}/); |
| 78 | expect(set).toContain("HttpOnly"); |
| 79 | expect(set).toContain("SameSite=Lax"); |
| 80 | }); |
| 81 | |
| 82 | test("moving takes a turn and is remembered", async () => { |
| 83 | const player = new Player(); |
| 84 | await player.look(); |
| 85 | const moved = await player.act("west"); |
| 86 | expect(moved.status).toBe(303); |
| 87 | expect(moved.headers.get("location")).toBe("/"); |
| 88 | const page = await player.look(); |
| 89 | expect(page).toMatch(/turns<\/span><b>[1-9]/); |
| 90 | }); |
| 91 | |
| 92 | test("an action the game does not have is refused, not guessed at", async () => { |
| 93 | const player = new Player(); |
| 94 | await player.look(); |
| 95 | const response = await player.act("teleport"); |
| 96 | expect(response.status).toBe(303); |
| 97 | expect(response.headers.get("location")).toBe("/"); |
| 98 | expect(await player.look()).toContain("12 / 12"); |
| 99 | }); |
| 100 | |
| 101 | test("two players share one dungeon and one map", async () => { |
| 102 | const first = new Player(); |
| 103 | await first.look(); |
| 104 | for (const move of ["west", "west", "west", "north"]) await first.act(move); |
| 105 | const second = new Player(); |
| 106 | const page = await second.look(); |
| 107 | // The tiles the first one lit are remembered on the second one's screen. |
| 108 | expect((page.match(/class="m"/g) ?? []).length).toBeGreaterThan(0); |
| 109 | expect(page).toContain("tiles lit, ever"); |
| 110 | }); |
| 111 | |
| 112 | test("the map hides what has never been lit", async () => { |
| 113 | const page = await new Player().look(); |
| 114 | expect((page.match(/class="u"/g) ?? []).length).toBeGreaterThan(400); |
| 115 | }); |
| 116 | |
| 117 | test("the keyboard layer is a file, not an inline script", async () => { |
| 118 | const page = await new Player().look(); |
| 119 | expect(page).toContain('<script src="/static/keys.v1.js" defer></script>'); |
| 120 | expect(page).not.toMatch(/<script>[^<]/); |
| 121 | const script = await fetch(`${base}/static/keys.v1.js`); |
| 122 | expect(script.status).toBe(200); |
| 123 | expect(script.headers.get("cache-control")).toContain("immutable"); |
| 124 | }); |
| 125 | |
| 126 | test("stands on the security floor", async () => { |
| 127 | const response = await fetch(`${base}/`); |
| 128 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 129 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 130 | }); |
| 131 | |
| 132 | test("refuses a cross-site move", async () => { |
| 133 | const response = await fetch(`${base}/act`, { |
| 134 | method: "POST", |
| 135 | body: new URLSearchParams({ action: "west" }), |
| 136 | redirect: "manual", |
| 137 | headers: { |
| 138 | "content-type": "application/x-www-form-urlencoded", |
| 139 | origin: "https://evil.example", |
| 140 | "sec-fetch-site": "cross-site", |
| 141 | }, |
| 142 | }); |
| 143 | expect(response.status).toBe(403); |
| 144 | }); |
| 145 | |
| 146 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 147 | const response = await fetch(`${base}/act`, { |
| 148 | method: "POST", |
| 149 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 150 | body: "action=" + "x".repeat(1024 * 1024), |
| 151 | }); |
| 152 | expect(response.status).toBe(413); |
| 153 | }); |
| 154 | |
| 155 | test("HEAD sees the app up", async () => { |
| 156 | const response = await server.fetch(new Request(`${base}/`, { method: "HEAD" })); |
| 157 | expect(response.status).toBe(200); |
| 158 | expect(await response.text()).toBe(""); |
| 159 | }); |
| 160 | |
| 161 | test("the end pages belong to a run that ended", async () => { |
| 162 | const player = new Player(); |
| 163 | await player.look(); |
| 164 | const response = await fetch(`${base}/died`, { |
| 165 | headers: { cookie: player.cookie }, |
| 166 | redirect: "manual", |
| 167 | }); |
| 168 | expect(response.status).toBe(303); |
| 169 | }); |