@serverkgg/bridge 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/bot/botCauses.test.ts +81 -0
  3. package/src/bot/botCauses.ts +5 -4
  4. package/src/bot/detect/botInspectWarning.ts +1 -0
  5. package/src/bot/detect/corpus/anonymousx1025-anonxmusic.json +3 -2
  6. package/src/bot/detect/corpus/bot-base-telegram-bot-template.json +3 -1
  7. package/src/bot/detect/corpus/nezdemkovski-telegram-bot-boilerplate.json +3 -1
  8. package/src/bot/detect/corpus/serverk-discord-node.json +3 -2
  9. package/src/bot/detect/corpus/serverk-discord-python.json +3 -2
  10. package/src/bot/detect/corpus/serverk-telegram-node.json +3 -2
  11. package/src/bot/detect/corpus/serverk-telegram-python.json +3 -2
  12. package/src/bot/detect/detect.test.ts +285 -0
  13. package/src/bot/detect/detect.ts +39 -14
  14. package/src/bot/detect/detectTable.ts +4 -0
  15. package/src/bot/detect/detectedVersion.test.ts +160 -0
  16. package/src/bot/detect/detectedVersion.ts +55 -0
  17. package/src/bot/detect/envExample.test.ts +4 -0
  18. package/src/bot/detect/envExample.ts +1 -0
  19. package/src/bot/detect/envScan.test.ts +110 -0
  20. package/src/bot/detect/envScan.ts +7 -3
  21. package/src/protocol/bridgeCrash.ts +5 -1
  22. package/src/protocol/bridgeEvents.ts +1 -0
  23. package/src/runtime/bridgeEvents.test.ts +52 -1
  24. package/src/runtime/bridgeLineSpacing.test.ts +132 -0
  25. package/src/runtime/bridgeLineSpacing.ts +49 -0
  26. package/src/runtime/bridgeSupervisor.ts +5 -3
  27. package/src/steam/steamcmdNarrator.test.ts +18 -5
  28. package/src/steam/steamcmdNarrator.ts +26 -2
  29. package/src/wine/protonCommand.test.ts +17 -1
  30. package/src/wine/protonCommand.ts +7 -1
@@ -2,15 +2,16 @@ import type { BotFramework } from "./botFramework";
2
2
  import { BOT_FRAMEWORKS, type BotFrameworkSpec, botFrameworkSpec } from "./botFrameworks";
3
3
  import { BotInspectWarning } from "./botInspectWarning";
4
4
  import { BotPackageManager } from "./botPackageManager";
5
- import type { BotPlatform } from "./botPlatform";
5
+ import { BotPlatform } from "./botPlatform";
6
6
  import { BOT_RUNTIME_PACKAGES, BotRuntime } from "./botRuntime";
7
7
  import { BOT_TOKEN_KEYS, platformOfKey } from "./botToken";
8
+ import { nodeLtsMajorOf } from "./detectedVersion";
8
9
  import type { DetectEntry, DetectedCommand, DetectedEnvKey, DetectInput, DetectResult } from "./detectResult";
9
10
  import { DETECT_TABLE, type DetectVersionFile } from "./detectTable";
10
11
  import { ENV_EXAMPLE_FILES, ENV_FILE, isSecretEnvFile, parseEnvExample } from "./envExample";
11
12
  import { isEnvKey } from "./envKey";
12
13
  import { isNodeSource, isPythonSource, scanEnvKeys } from "./envScan";
13
- import { BOT_PROTECTED_KEYS, BOT_PROVIDED_KEYS, isReservedBotKey } from "./keys";
14
+ import { BOT_PROTECTED_KEYS, BOT_PROVIDED_KEYS, isReservedBotKey, PORT } from "./keys";
14
15
  import { isSensitiveKey } from "./sensitiveKey";
15
16
  import { parseServerkJson, SERVERK_JSON, type ServerkJson } from "./serverkJson";
16
17
 
@@ -79,6 +80,8 @@ const TOKEN_KEY = /TOKEN/i;
79
80
 
80
81
  const BOT_KEY = /BOT/i;
81
82
 
83
+ const WEBHOOK_KEY = /WEBHOOK/i;
84
+
82
85
  const NODE_IMPORT = /(?:\brequire\s*\(\s*|\bfrom\s+|\bimport\s*\(\s*|\bimport\s+)(["'])([^"'\n]+)\1/g;
83
86
 
84
87
  const PYTHON_IMPORT = /^[ \t]*(?:from[ \t]+([A-Za-z_]\w*)|import[ \t]+([A-Za-z_]\w*))/gm;
@@ -192,14 +195,16 @@ const START_FILES: readonly string[] = [
192
195
  ];
193
196
 
194
197
  const FIXED_FILES: readonly string[] = [
195
- DETECT_TABLE.node.manifest,
196
- REQUIREMENTS_FILE,
197
- PYPROJECT_FILE,
198
- DETECT_TABLE.procfile.file,
199
- SERVERK_JSON,
200
- ...ENV_EXAMPLE_FILES,
201
- ...DETECT_TABLE.node.versionFiles.map((entry) => entry.file),
202
- ...DETECT_TABLE.python.versionFiles.map((entry) => entry.file),
198
+ ...new Set([
199
+ DETECT_TABLE.node.manifest,
200
+ REQUIREMENTS_FILE,
201
+ PYPROJECT_FILE,
202
+ DETECT_TABLE.procfile.file,
203
+ SERVERK_JSON,
204
+ ...ENV_EXAMPLE_FILES,
205
+ ...DETECT_TABLE.node.versionFiles.map((entry) => entry.file),
206
+ ...DETECT_TABLE.python.versionFiles.map((entry) => entry.file),
207
+ ]),
203
208
  ];
204
209
 
205
210
  const sourceRank = (path: string) => {
@@ -587,7 +592,11 @@ const nodeBuildOf = (packageJson: PackageJson | null, manager: string): Detected
587
592
  };
588
593
  };
589
594
 
590
- const versionFromFile = (text: string, spec: DetectVersionFile): string | null => {
595
+ const versionFromFile = (
596
+ text: string,
597
+ spec: DetectVersionFile,
598
+ aliasOf: (value: string) => string | null,
599
+ ): string | null => {
591
600
  for (const raw of text.split("\n")) {
592
601
  let line = raw.trim();
593
602
 
@@ -623,7 +632,7 @@ const versionFromFile = (text: string, spec: DetectVersionFile): string | null =
623
632
  line = line.slice(spec.prefix.length);
624
633
  }
625
634
 
626
- return VERSION_NUMBER.exec(line)?.[1] ?? null;
635
+ return VERSION_NUMBER.exec(line)?.[1] ?? aliasOf(line);
627
636
  }
628
637
 
629
638
  return null;
@@ -633,6 +642,7 @@ const versionFromFiles = (
633
642
  specs: readonly DetectVersionFile[],
634
643
  read: (path: string) => string | null,
635
644
  parts: number,
645
+ aliasOf: (value: string) => string | null = () => null,
636
646
  ): string | null => {
637
647
  for (const spec of specs) {
638
648
  const text = read(spec.file);
@@ -641,7 +651,7 @@ const versionFromFiles = (
641
651
  continue;
642
652
  }
643
653
 
644
- const version = versionFromFile(text, spec);
654
+ const version = versionFromFile(text, spec, aliasOf);
645
655
 
646
656
  if (version !== null) {
647
657
  return version.split(".").slice(0, parts).join(".");
@@ -731,7 +741,7 @@ const nodeRequirementOf = (
731
741
  }
732
742
  }
733
743
 
734
- const version = versionFromFiles(DETECT_TABLE.node.versionFiles, read, 1);
744
+ const version = versionFromFiles(DETECT_TABLE.node.versionFiles, read, 1, nodeLtsMajorOf);
735
745
 
736
746
  return version === null
737
747
  ? null
@@ -845,6 +855,19 @@ const committedWarnings = (paths: ReadonlySet<string>, warnings: Set<BotInspectW
845
855
  }
846
856
  };
847
857
 
858
+ const webPortWarning = (
859
+ platform: BotPlatform | null,
860
+ env: readonly DetectedEnvKey[],
861
+ warnings: Set<BotInspectWarning>,
862
+ ) => {
863
+ const keys = env.map((entry) => entry.key);
864
+ const webhook = platform !== BotPlatform.Discord && keys.some((key) => WEBHOOK_KEY.test(key));
865
+
866
+ if (keys.includes(PORT) || webhook) {
867
+ warnings.add(BotInspectWarning.WebPort);
868
+ }
869
+ };
870
+
848
871
  interface EnvCollector {
849
872
  add(key: string, source: string, sensitive: boolean | null, example: string | null): void;
850
873
  entries: DetectedEnvKey[];
@@ -1165,6 +1188,8 @@ export const detect = (input: DetectInput): DetectResult => {
1165
1188
  );
1166
1189
  const platform = platformOf(serverk, framework, candidates, keyPlatforms, warnings);
1167
1190
 
1191
+ webPortWarning(platform, env.entries, warnings);
1192
+
1168
1193
  return {
1169
1194
  runtime,
1170
1195
  package: runtime ? BOT_RUNTIME_PACKAGES[runtime] : null,
@@ -230,6 +230,10 @@ export const DETECT_TABLE: DetectTable = {
230
230
  file: ".tool-versions",
231
231
  tool: "nodejs",
232
232
  },
233
+ {
234
+ file: ".tool-versions",
235
+ tool: "node",
236
+ },
233
237
  ],
234
238
  defaultVersion: "24",
235
239
  },
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { BotInspectWarning } from "./botInspectWarning";
3
+ import { BotRuntime } from "./botRuntime";
4
+ import { detect } from "./detect";
5
+ import { nodeLtsMajorOf } from "./detectedVersion";
6
+ import { DETECT_TABLE } from "./detectTable";
7
+
8
+ const DISCORD_NODE = {
9
+ "package.json": JSON.stringify({
10
+ name: "discord-bot",
11
+ main: "index.js",
12
+ dependencies: {
13
+ "discord.js": "^14.27.0",
14
+ },
15
+ }),
16
+ "index.js": 'import { Client } from "discord.js";\nawait client.login(process.env.DISCORD_TOKEN);\n',
17
+ };
18
+
19
+ const resultOf = (files: Record<string, string>) => {
20
+ const contents = {
21
+ ...DISCORD_NODE,
22
+ ...files,
23
+ };
24
+
25
+ return detect({
26
+ files: Object.keys(contents).map((path) => ({
27
+ path,
28
+ })),
29
+ contents,
30
+ });
31
+ };
32
+
33
+ const LTS_CASES: readonly {
34
+ alias: string;
35
+ major: string | null;
36
+ }[] = [
37
+ {
38
+ alias: "lts/iron",
39
+ major: "20",
40
+ },
41
+ {
42
+ alias: "LTS/Hydrogen",
43
+ major: "18",
44
+ },
45
+ {
46
+ alias: "lts/jod",
47
+ major: "22",
48
+ },
49
+ {
50
+ alias: "lts-krypton",
51
+ major: "24",
52
+ },
53
+ {
54
+ alias: " lts/argon ",
55
+ major: "4",
56
+ },
57
+ {
58
+ alias: "lts/*",
59
+ major: null,
60
+ },
61
+ {
62
+ alias: "lts/-1",
63
+ major: null,
64
+ },
65
+ {
66
+ alias: "lts",
67
+ major: null,
68
+ },
69
+ {
70
+ alias: "lts/unknown",
71
+ major: null,
72
+ },
73
+ {
74
+ alias: "lts/constructor",
75
+ major: null,
76
+ },
77
+ {
78
+ alias: "node",
79
+ major: null,
80
+ },
81
+ ];
82
+
83
+ describe("nodeLtsMajorOf", () => {
84
+ for (const item of LTS_CASES) {
85
+ it(`reads ${item.alias} as ${item.major ?? "no fixed major"}`, () => {
86
+ expect(nodeLtsMajorOf(item.alias)).toBe(item.major);
87
+ });
88
+ }
89
+ });
90
+
91
+ describe("detect", () => {
92
+ it("reads the Node major an .nvmrc names by its LTS codename", () => {
93
+ expect(
94
+ resultOf({
95
+ ".nvmrc": "lts/iron\n",
96
+ }).nodeVersion,
97
+ ).toBe("20");
98
+ expect(
99
+ resultOf({
100
+ ".nvmrc": "LTS/Hydrogen\n",
101
+ }).nodeVersion,
102
+ ).toBe("18");
103
+ expect(
104
+ resultOf({
105
+ ".nvmrc": "lts/*\n",
106
+ }).nodeVersion,
107
+ ).toBeNull();
108
+ });
109
+
110
+ it("warns when an LTS codename names a major the image does not run", () => {
111
+ const result = resultOf({
112
+ ".nvmrc": "lts/hydrogen\n",
113
+ });
114
+
115
+ expect(DETECT_TABLE.node.defaultVersion).not.toBe("18");
116
+ expect(result.warnings).toContain(BotInspectWarning.NodeVersionUnmet);
117
+ });
118
+
119
+ it("reads the Node version from .tool-versions under mise's node and asdf's nodejs", () => {
120
+ expect(
121
+ resultOf({
122
+ ".tool-versions": "node 20.11.0\n",
123
+ }).nodeVersion,
124
+ ).toBe("20");
125
+ expect(
126
+ resultOf({
127
+ ".tool-versions": "nodejs 20.11.0\n",
128
+ }).nodeVersion,
129
+ ).toBe("20");
130
+ expect(
131
+ resultOf({
132
+ ".tool-versions": "python 3.12.1\nnode lts/jod\n",
133
+ }).nodeVersion,
134
+ ).toBe("22");
135
+ });
136
+
137
+ it("leaves the Python version untouched by a Node LTS codename", () => {
138
+ const result = detect({
139
+ files: [
140
+ {
141
+ path: "requirements.txt",
142
+ },
143
+ {
144
+ path: "main.py",
145
+ },
146
+ {
147
+ path: ".python-version",
148
+ },
149
+ ],
150
+ contents: {
151
+ "requirements.txt": "python-telegram-bot==21.11.1\n",
152
+ "main.py": "from telegram.ext import Application\n",
153
+ ".python-version": "lts/iron\n",
154
+ },
155
+ });
156
+
157
+ expect(result.runtime).toBe(BotRuntime.Python);
158
+ expect(result.pythonVersion).toBeNull();
159
+ });
160
+ });
@@ -1,5 +1,60 @@
1
1
  export const DETECTED_VERSION_PATTERN = /^\d+(?:\.\d+)?$/;
2
2
 
3
+ const NODE_LTS_MAJORS: ReadonlyMap<string, string> = new Map([
4
+ [
5
+ "argon",
6
+ "4",
7
+ ],
8
+ [
9
+ "boron",
10
+ "6",
11
+ ],
12
+ [
13
+ "carbon",
14
+ "8",
15
+ ],
16
+ [
17
+ "dubnium",
18
+ "10",
19
+ ],
20
+ [
21
+ "erbium",
22
+ "12",
23
+ ],
24
+ [
25
+ "fermium",
26
+ "14",
27
+ ],
28
+ [
29
+ "gallium",
30
+ "16",
31
+ ],
32
+ [
33
+ "hydrogen",
34
+ "18",
35
+ ],
36
+ [
37
+ "iron",
38
+ "20",
39
+ ],
40
+ [
41
+ "jod",
42
+ "22",
43
+ ],
44
+ [
45
+ "krypton",
46
+ "24",
47
+ ],
48
+ ]);
49
+
50
+ const NODE_LTS_ALIAS = /^lts[/-]([a-z]+)$/i;
51
+
3
52
  export const isDetectedVersion = (value: string | null): value is string => {
4
53
  return value !== null && DETECTED_VERSION_PATTERN.test(value);
5
54
  };
55
+
56
+ export const nodeLtsMajorOf = (alias: string): string | null => {
57
+ const codename = NODE_LTS_ALIAS.exec(alias.trim())?.[1];
58
+
59
+ return codename === undefined ? null : (NODE_LTS_MAJORS.get(codename.toLowerCase()) ?? null);
60
+ };
@@ -69,6 +69,10 @@ describe("isSecretEnvFile", () => {
69
69
  name: ".env.dist",
70
70
  secret: false,
71
71
  },
72
+ {
73
+ name: "sample.env",
74
+ secret: false,
75
+ },
72
76
  {
73
77
  name: ".env.defaults",
74
78
  secret: false,
@@ -7,6 +7,7 @@ export const ENV_EXAMPLE_FILES: readonly string[] = [
7
7
  ".env-example",
8
8
  ".env-sample",
9
9
  ".env.dist",
10
+ "sample.env",
10
11
  ];
11
12
 
12
13
  const ENV_TEMPLATE_FILES: ReadonlySet<string> = new Set([
@@ -33,6 +33,116 @@ describe("scanEnvKeys", () => {
33
33
  ]);
34
34
  });
35
35
 
36
+ it("finds a Bun.env property read", () => {
37
+ expect(scanEnvKeys("src/index.ts", "const token = Bun.env.DISCORD_TOKEN;")).toEqual([
38
+ "DISCORD_TOKEN",
39
+ ]);
40
+ });
41
+
42
+ it("finds a Bun.env bracket read", () => {
43
+ expect(scanEnvKeys("src/index.ts", 'const guildId = Bun.env["GUILD_ID"] ?? "";')).toEqual([
44
+ "GUILD_ID",
45
+ ]);
46
+ });
47
+
48
+ it("finds names destructured from Bun.env", () => {
49
+ expect(scanEnvKeys("src/config.ts", "const { DISCORD_TOKEN, CLIENT_ID } = Bun.env;")).toEqual([
50
+ "DISCORD_TOKEN",
51
+ "CLIENT_ID",
52
+ ]);
53
+ });
54
+
55
+ it("finds environs reads, bare and typed", () => {
56
+ expect(
57
+ scanEnvKeys(
58
+ "tgbot/config.py",
59
+ [
60
+ "from environs import Env",
61
+ "",
62
+ "env = Env()",
63
+ "env.read_env(path)",
64
+ 'BOT_TOKEN = env.str("BOT_TOKEN")',
65
+ 'ADMIN_ID = env.int("ADMIN_ID")',
66
+ 'admin_ids = env.list("ADMINS", subcast=int)',
67
+ 'use_redis = env.bool("USE_REDIS")',
68
+ 'rate = env.float("RATE_LIMIT", 1.5)',
69
+ 'webhook = env.url("WEBHOOK_URL")',
70
+ 'extra = env.json("EXTRA_OPTIONS")',
71
+ "SECRET_KEY = env('SECRET_KEY')",
72
+ ].join("\n"),
73
+ ),
74
+ ).toEqual([
75
+ "BOT_TOKEN",
76
+ "ADMIN_ID",
77
+ "ADMINS",
78
+ "USE_REDIS",
79
+ "RATE_LIMIT",
80
+ "WEBHOOK_URL",
81
+ "EXTRA_OPTIONS",
82
+ "SECRET_KEY",
83
+ ]);
84
+ });
85
+
86
+ it("finds python-decouple reads", () => {
87
+ expect(
88
+ scanEnvKeys(
89
+ "bot/settings.py",
90
+ [
91
+ "import decouple",
92
+ "from decouple import config",
93
+ "",
94
+ 'TOKEN = config("TOKEN")',
95
+ 'ADMIN_ID = config("ADMIN_ID", cast=int)',
96
+ 'DEBUG = decouple.config("DEBUG", default=False, cast=bool)',
97
+ ].join("\n"),
98
+ ),
99
+ ).toEqual([
100
+ "TOKEN",
101
+ "ADMIN_ID",
102
+ "DEBUG",
103
+ ]);
104
+ });
105
+
106
+ it("orders names by where the file reads them across os and environs", () => {
107
+ expect(
108
+ scanEnvKeys(
109
+ "bot/config.py",
110
+ [
111
+ "import os",
112
+ "from environs import Env",
113
+ "",
114
+ "env = Env()",
115
+ 'BOT_TOKEN = env.str("BOT_TOKEN")',
116
+ 'REDIS_URL = os.getenv("REDIS_URL")',
117
+ 'ADMIN_ID = env.int("ADMIN_ID")',
118
+ ].join("\n"),
119
+ ),
120
+ ).toEqual([
121
+ "BOT_TOKEN",
122
+ "REDIS_URL",
123
+ "ADMIN_ID",
124
+ ]);
125
+ });
126
+
127
+ it("reads no key from calls that only look like environs or decouple", () => {
128
+ expect(
129
+ scanEnvKeys(
130
+ "bot/main.py",
131
+ [
132
+ "env = {}",
133
+ 'env["x"] = 1',
134
+ 'value = env["x"]',
135
+ 'logger.config("level")',
136
+ 'logger.config("LEVEL")',
137
+ 'config("level")',
138
+ 'self.env.str("NOPE")',
139
+ 'client.getenv("NOPE")',
140
+ 'cfg.environ.get("NOPE")',
141
+ ].join("\n"),
142
+ ),
143
+ ).toEqual([]);
144
+ });
145
+
36
146
  it("reads nothing from files that are not code", () => {
37
147
  expect(scanEnvKeys("README.md", "process.env.A")).toEqual([]);
38
148
  });
@@ -13,12 +13,14 @@ export const PYTHON_SOURCE_EXTENSIONS: readonly string[] = [
13
13
 
14
14
  const NAME = "([A-Za-z_][A-Za-z0-9_]*)";
15
15
 
16
+ const UPPER_NAME = "([A-Z_][A-Z0-9_]*)";
17
+
16
18
  const NODE_PATTERNS: readonly RegExp[] = [
17
- new RegExp(`\\bprocess\\.env\\.${NAME}`, "g"),
18
- new RegExp(`\\bprocess\\.env\\[\\s*["'\`]${NAME}["'\`]\\s*\\]`, "g"),
19
+ new RegExp(`\\b(?:process|Bun)\\.env\\.${NAME}`, "g"),
20
+ new RegExp(`\\b(?:process|Bun)\\.env\\[\\s*["'\`]${NAME}["'\`]\\s*\\]`, "g"),
19
21
  ];
20
22
 
21
- const NODE_DESTRUCTURE = /\{([^{}]{1,2000})\}\s*=\s*process\.env\b/g;
23
+ const NODE_DESTRUCTURE = /\{([^{}]{1,2000})\}\s*=\s*(?:process|Bun)\.env\b/g;
22
24
 
23
25
  const DESTRUCTURED_NAME = /^\s*([A-Za-z_][A-Za-z0-9_]*)/;
24
26
 
@@ -26,6 +28,8 @@ const PYTHON_PATTERNS: readonly RegExp[] = [
26
28
  new RegExp(`(?<![\\w.])(?:os\\.)?environ\\[\\s*["']${NAME}["']\\s*\\]`, "g"),
27
29
  new RegExp(`(?<![\\w.])(?:os\\.)?environ\\.get\\(\\s*["']${NAME}["']`, "g"),
28
30
  new RegExp(`(?<![\\w.])(?:os\\.)?getenv\\(\\s*["']${NAME}["']`, "g"),
31
+ new RegExp(`(?<![\\w.])env(?:\\.\\w+)?\\(\\s*["']${UPPER_NAME}["']`, "g"),
32
+ new RegExp(`(?<![\\w.])(?:decouple\\.)?config\\(\\s*["']${UPPER_NAME}["']`, "g"),
29
33
  ];
30
34
 
31
35
  interface Found {
@@ -34,7 +34,7 @@ export interface BridgeCrashEvidence {
34
34
 
35
35
  export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
36
36
  {
37
- name: BridgeEventName.ServerCrashed,
37
+ name: BridgeEventName.OutOfMemory,
38
38
  cause: BridgeCrashCause.OutOfMemory,
39
39
  },
40
40
  {
@@ -101,6 +101,10 @@ export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
101
101
  name: BridgeEventName.RateLimited,
102
102
  cause: BridgeCrashCause.RateLimited,
103
103
  },
104
+ {
105
+ name: BridgeEventName.ServerCrashed,
106
+ cause: BridgeCrashCause.Unknown,
107
+ },
104
108
  ];
105
109
 
106
110
  const BRIDGE_CRASH_EVIDENCE_NAMES: ReadonlySet<string> = new Set(BRIDGE_CRASH_EVIDENCE.map((entry) => entry.name));
@@ -23,6 +23,7 @@ export enum BridgeEventName {
23
23
  ModpackInstalled = "ModpackInstalled",
24
24
  ModpackRemoved = "ModpackRemoved",
25
25
  ModpackSelected = "ModpackSelected",
26
+ OutOfMemory = "OutOfMemory",
26
27
  PlayerAdvanced = "PlayerAdvanced",
27
28
  PlayerBanned = "PlayerBanned",
28
29
  PlayerChanged = "PlayerChanged",
@@ -291,7 +291,7 @@ describe("the crash record the ring builds", () => {
291
291
  detail: "class file version 65",
292
292
  });
293
293
 
294
- ring.emit(BridgeEventName.ServerCrashed, {
294
+ ring.emit(BridgeEventName.OutOfMemory, {
295
295
  error: "java.lang.OutOfMemoryError",
296
296
  });
297
297
 
@@ -303,6 +303,57 @@ describe("the crash record the ring builds", () => {
303
303
  });
304
304
  });
305
305
 
306
+ it("ends a lone fatal crash line as unknown, in one record that carries the last line", () => {
307
+ const ring = idleRing();
308
+
309
+ ring.emit(BridgeEventName.ServerCrashed);
310
+
311
+ ring.crash(
312
+ crashRecord({
313
+ exitCode: 139,
314
+ lastLine: "Segmentation fault (core dumped)",
315
+ }),
316
+ );
317
+
318
+ expect(ring.drain(0).events).toEqual([
319
+ {
320
+ seq: 1,
321
+ event: BridgeEventName.ServerCrashed,
322
+ payload: {
323
+ cause: "unknown",
324
+ phase: "running",
325
+ exitCode: "139",
326
+ restarting: "no",
327
+ looping: "no",
328
+ restarts: "0",
329
+ uptimeSeconds: "12",
330
+ lastLine: "Segmentation fault (core dumped)",
331
+ },
332
+ },
333
+ ]);
334
+ });
335
+
336
+ it("names a corrupt world over the fatal crash line of the same run", () => {
337
+ const ring = idleRing();
338
+
339
+ ring.emit(BridgeEventName.WorldCorrupt);
340
+ ring.emit(BridgeEventName.ServerCrashed);
341
+
342
+ ring.crash(
343
+ crashRecord({
344
+ lastLine: "Aborted (core dumped)",
345
+ }),
346
+ );
347
+
348
+ const crashed = ring.drain(0).events;
349
+
350
+ expect(crashed).toHaveLength(1);
351
+ expect(crashed.at(0)?.payload).toMatchObject({
352
+ cause: "world-corrupt",
353
+ });
354
+ expect(crashed.at(0)?.payload).not.toHaveProperty("lastLine");
355
+ });
356
+
306
357
  it("never lets the evidence overwrite the record keys", () => {
307
358
  const ring = idleRing();
308
359