heron/lanternfall Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files 0a44464472bc362ca2dc0b6f6620148b4636b1d6
169 lines · 6.0 KB · ~1.7k tok Raw History
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
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts