@serverkgg/bridge 0.0.12 → 0.0.13

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 (70) hide show
  1. package/package.json +9 -1
  2. package/src/catalogs/curseforge.test.ts +580 -0
  3. package/src/catalogs/curseforge.ts +461 -0
  4. package/src/catalogs/index.ts +2 -0
  5. package/src/catalogs/thunderstore.test.ts +94 -0
  6. package/src/catalogs/thunderstore.ts +239 -0
  7. package/src/codecs/index.ts +3 -0
  8. package/src/codecs/ini.test.ts +152 -0
  9. package/src/codecs/ini.ts +158 -0
  10. package/src/codecs/sourceCfg.test.ts +100 -0
  11. package/src/codecs/sourceCfg.ts +111 -0
  12. package/src/codecs/xmlProperties.test.ts +104 -0
  13. package/src/codecs/xmlProperties.ts +133 -0
  14. package/src/context/bridgeContext.ts +20 -0
  15. package/src/core/bridgeRconError.ts +19 -0
  16. package/src/core/index.ts +1 -0
  17. package/src/install/index.ts +1 -0
  18. package/src/install/installStamp.test.ts +125 -0
  19. package/src/install/installStamp.ts +40 -0
  20. package/src/main.ts +1 -0
  21. package/src/manifest/index.ts +1 -0
  22. package/src/manifest/manifest.test.ts +427 -2
  23. package/src/manifest/manifest.ts +141 -13
  24. package/src/manifest/primitives.ts +6 -0
  25. package/src/manifest/runtime.ts +89 -0
  26. package/src/net/index.ts +2 -0
  27. package/src/net/loopback.test.ts +152 -0
  28. package/src/net/loopback.ts +109 -0
  29. package/src/net/loopbackMethod.ts +7 -0
  30. package/src/presence/index.ts +1 -0
  31. package/src/presence/rosterSync.test.ts +179 -0
  32. package/src/presence/rosterSync.ts +57 -0
  33. package/src/protocol/bridgeEvents.ts +4 -0
  34. package/src/rcon/index.ts +3 -0
  35. package/src/rcon/sourceRcon.test.ts +308 -0
  36. package/src/rcon/sourceRcon.ts +276 -0
  37. package/src/rcon/sourceRconFrame.test.ts +152 -0
  38. package/src/rcon/sourceRconFrame.ts +99 -0
  39. package/src/runtime/bridgeCodec.ts +45 -2
  40. package/src/runtime/bridgeContext.ts +13 -6
  41. package/src/runtime/bridgeDriver.test.ts +1 -0
  42. package/src/runtime/bridgeLiveConfig.test.ts +149 -0
  43. package/src/runtime/bridgeLiveConfig.ts +63 -0
  44. package/src/runtime/bridgeRcon.ts +15 -0
  45. package/src/runtime/bridgeRegistry.ts +1 -0
  46. package/src/steam/index.ts +7 -0
  47. package/src/steam/steamApp.test.ts +78 -0
  48. package/src/steam/steamApp.ts +35 -0
  49. package/src/steam/steamcmd.test.ts +157 -0
  50. package/src/steam/steamcmd.ts +273 -0
  51. package/src/steam/steamcmdLogin.ts +34 -0
  52. package/src/steam/steamcmdNarrator.test.ts +90 -0
  53. package/src/steam/steamcmdNarrator.ts +43 -0
  54. package/src/steam/steamcmdPlatform.ts +4 -0
  55. package/src/steam/webApi.ts +70 -0
  56. package/src/steam/workshop.test.ts +45 -0
  57. package/src/steam/workshop.ts +148 -0
  58. package/src/utils/execDetail.ts +16 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/utils/token.test.ts +50 -0
  61. package/src/utils/token.ts +35 -0
  62. package/src/wine/index.ts +3 -0
  63. package/src/wine/protonCommand.test.ts +67 -0
  64. package/src/wine/protonCommand.ts +49 -0
  65. package/src/wine/wineCommand.test.ts +92 -0
  66. package/src/wine/wineCommand.ts +54 -0
  67. package/src/wine/winePrefix.test.ts +111 -0
  68. package/src/wine/winePrefix.ts +88 -0
  69. package/src/runtime/bridgeSecrets.test.ts +0 -121
  70. package/src/runtime/bridgeSecrets.ts +0 -43
@@ -0,0 +1,148 @@
1
+ import type { BridgeContext } from "../context";
2
+ import { BridgeHttpMethod } from "../protocol";
3
+ import type { Steamcmd } from "./steamcmd";
4
+
5
+ export const STEAM_REMOTE_STORAGE = "https://api.steampowered.com/ISteamRemoteStorage";
6
+
7
+ export const WORKSHOP_CONTENT_DIRECTORY = "steamapps/workshop/content";
8
+
9
+ const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
10
+
11
+ const DETAILS_CACHE_SECONDS = 600;
12
+
13
+ const DOWNLOAD_BATCH = 20;
14
+
15
+ export interface SteamWorkshopTag {
16
+ tag: string;
17
+ }
18
+
19
+ export interface SteamPublishedFile {
20
+ publishedfileid: string;
21
+ result: number;
22
+ creator?: string;
23
+ consumer_app_id?: number;
24
+ title?: string;
25
+ description?: string;
26
+ preview_url?: string;
27
+ file_size?: string;
28
+ time_created?: number;
29
+ time_updated?: number;
30
+ banned?: number;
31
+ tags?: SteamWorkshopTag[];
32
+ }
33
+
34
+ export interface SteamCollectionChild {
35
+ publishedfileid: string;
36
+ sortorder: number;
37
+ filetype: number;
38
+ }
39
+
40
+ export interface SteamCollection {
41
+ publishedfileid: string;
42
+ result: number;
43
+ children?: SteamCollectionChild[];
44
+ }
45
+
46
+ interface PublishedFileResponse {
47
+ response?: {
48
+ publishedfiledetails?: SteamPublishedFile[];
49
+ };
50
+ }
51
+
52
+ interface CollectionResponse {
53
+ response?: {
54
+ collectiondetails?: SteamCollection[];
55
+ };
56
+ }
57
+
58
+ export const workshopContentPath = (
59
+ workshopAppId: string,
60
+ id: string,
61
+ directory: string = WORKSHOP_CONTENT_DIRECTORY,
62
+ ) => {
63
+ return `${directory}/${workshopAppId}/${id}`;
64
+ };
65
+
66
+ export const workshopFormBody = (countKey: string, ids: string[]) => {
67
+ const form = new URLSearchParams();
68
+
69
+ form.set(countKey, String(ids.length));
70
+
71
+ for (const [index, id] of ids.entries()) {
72
+ form.set(`publishedfileids[${index}]`, id);
73
+ }
74
+
75
+ return form.toString();
76
+ };
77
+
78
+ const uniqueIds = (ids: string[]) => {
79
+ return [
80
+ ...new Set(ids.filter((id) => id.length > 0)),
81
+ ];
82
+ };
83
+
84
+ const post = async <Result>(context: BridgeContext, url: string, body: string) => {
85
+ return await context.net.json<Result>(url, {
86
+ httpMethod: BridgeHttpMethod.Post,
87
+ headers: {
88
+ "content-type": FORM_CONTENT_TYPE,
89
+ },
90
+ body,
91
+ cacheSeconds: DETAILS_CACHE_SECONDS,
92
+ });
93
+ };
94
+
95
+ export const publishedFileDetails = async (context: BridgeContext, ids: string[]): Promise<SteamPublishedFile[]> => {
96
+ const wanted = uniqueIds(ids);
97
+
98
+ if (wanted.length === 0) {
99
+ return [];
100
+ }
101
+
102
+ const page = await post<PublishedFileResponse>(
103
+ context,
104
+ `${STEAM_REMOTE_STORAGE}/GetPublishedFileDetails/v1/`,
105
+ workshopFormBody("itemcount", wanted),
106
+ );
107
+
108
+ return page.response?.publishedfiledetails ?? [];
109
+ };
110
+
111
+ export const collectionDetails = async (context: BridgeContext, ids: string[]): Promise<SteamCollection[]> => {
112
+ const wanted = uniqueIds(ids);
113
+
114
+ if (wanted.length === 0) {
115
+ return [];
116
+ }
117
+
118
+ const page = await post<CollectionResponse>(
119
+ context,
120
+ `${STEAM_REMOTE_STORAGE}/GetCollectionDetails/v1/`,
121
+ workshopFormBody("collectioncount", wanted),
122
+ );
123
+
124
+ return page.response?.collectiondetails ?? [];
125
+ };
126
+
127
+ export interface WorkshopDownloadOptions {
128
+ workshopAppId: string;
129
+ ids: string[];
130
+ batch?: number;
131
+ }
132
+
133
+ export const workshopDownloadArgs = (workshopAppId: string, ids: string[]) => {
134
+ return ids.flatMap((id) => [
135
+ "+workshop_download_item",
136
+ workshopAppId,
137
+ id,
138
+ ]);
139
+ };
140
+
141
+ export const workshopDownload = async (steamcmd: Steamcmd, options: WorkshopDownloadOptions) => {
142
+ const wanted = uniqueIds(options.ids);
143
+ const batch = options.batch ?? DOWNLOAD_BATCH;
144
+
145
+ for (let cursor = 0; cursor < wanted.length; cursor += batch) {
146
+ await steamcmd.run(workshopDownloadArgs(options.workshopAppId, wanted.slice(cursor, cursor + batch)));
147
+ }
148
+ };
@@ -0,0 +1,16 @@
1
+ import type { BridgeExecResult } from "../context";
2
+
3
+ const FAILURE_DETAIL = 800;
4
+
5
+ const tail = (text: string) => {
6
+ return text.trim().slice(-FAILURE_DETAIL);
7
+ };
8
+
9
+ export const execDetail = (result: BridgeExecResult) => {
10
+ return [
11
+ tail(result.stdout),
12
+ tail(result.stderr),
13
+ ]
14
+ .filter((part) => part.length > 0)
15
+ .join(" | ");
16
+ };
@@ -1,2 +1,4 @@
1
+ export * from "./execDetail";
1
2
  export * from "./glob";
2
3
  export * from "./stripAnsi";
4
+ export * from "./token";
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { generateToken, TOKEN_ALPHABET } from "./token";
3
+
4
+ describe("generateToken", () => {
5
+ test("returns a token of the requested length from the default alphabet", () => {
6
+ const token = generateToken(40);
7
+
8
+ expect(token).toHaveLength(40);
9
+ expect(
10
+ [
11
+ ...token,
12
+ ].every((character) => TOKEN_ALPHABET.includes(character)),
13
+ ).toBe(true);
14
+ });
15
+
16
+ test("uses the alphabet it is given", () => {
17
+ expect(generateToken(32, "ab")).toMatch(/^[ab]{32}$/);
18
+ });
19
+
20
+ test("does not repeat itself", () => {
21
+ expect(generateToken(24)).not.toBe(generateToken(24));
22
+ });
23
+
24
+ test("draws every character of a small alphabet", () => {
25
+ const seen = new Set([
26
+ ...generateToken(2000, "abc"),
27
+ ]);
28
+
29
+ expect(
30
+ [
31
+ ...seen,
32
+ ].sort(),
33
+ ).toEqual([
34
+ "a",
35
+ "b",
36
+ "c",
37
+ ]);
38
+ });
39
+
40
+ test("refuses a length that is not a positive integer", () => {
41
+ expect(() => generateToken(0)).toThrow();
42
+ expect(() => generateToken(-4)).toThrow();
43
+ expect(() => generateToken(1.5)).toThrow();
44
+ });
45
+
46
+ test("refuses an unusable alphabet", () => {
47
+ expect(() => generateToken(8, "")).toThrow();
48
+ expect(() => generateToken(8, "x".repeat(257))).toThrow();
49
+ });
50
+ });
@@ -0,0 +1,35 @@
1
+ export const TOKEN_ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
2
+
3
+ const BYTE_RANGE = 256;
4
+
5
+ export const generateToken = (length: number, alphabet: string = TOKEN_ALPHABET) => {
6
+ if (!Number.isSafeInteger(length) || length <= 0) {
7
+ throw new Error("a token needs a positive length");
8
+ }
9
+
10
+ if (alphabet.length === 0 || alphabet.length > BYTE_RANGE) {
11
+ throw new Error(`a token alphabet needs between 1 and ${BYTE_RANGE} characters`);
12
+ }
13
+
14
+ const ceiling = Math.floor(BYTE_RANGE / alphabet.length) * alphabet.length;
15
+ const bytes = new Uint8Array(length);
16
+ const picked: string[] = [];
17
+
18
+ while (picked.length < length) {
19
+ crypto.getRandomValues(bytes);
20
+
21
+ for (const byte of bytes) {
22
+ if (picked.length === length) {
23
+ break;
24
+ }
25
+
26
+ if (byte >= ceiling) {
27
+ continue;
28
+ }
29
+
30
+ picked.push(alphabet[byte % alphabet.length] ?? "");
31
+ }
32
+ }
33
+
34
+ return picked.join("");
35
+ };
@@ -0,0 +1,3 @@
1
+ export * from "./protonCommand";
2
+ export * from "./wineCommand";
3
+ export * from "./winePrefix";
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BridgeContext } from "../context";
3
+ import { protonCommand, protonCompatPath } from "./protonCommand";
4
+
5
+ const ensured: string[] = [];
6
+
7
+ const context = {
8
+ files: {
9
+ async ensure(...paths: string[]) {
10
+ ensured.push(...paths);
11
+ },
12
+ },
13
+ } as unknown as BridgeContext;
14
+
15
+ describe("protonCompatPath", () => {
16
+ test("gives every app its own compat data directory", () => {
17
+ expect(protonCompatPath("2430930")).toBe(".proton/2430930");
18
+ });
19
+ });
20
+
21
+ describe("protonCommand", () => {
22
+ test("builds an env prefix pointing inside the container home", async () => {
23
+ expect(
24
+ await protonCommand(context, {
25
+ appId: "2430930",
26
+ exe: "ArkAscendedServer.exe",
27
+ args: [
28
+ "TheIsland_WP?listen",
29
+ ],
30
+ }),
31
+ ).toEqual([
32
+ "env",
33
+ "HOME=/home/container",
34
+ "STEAM_COMPAT_DATA_PATH=/home/container/.proton/2430930",
35
+ "STEAM_COMPAT_CLIENT_INSTALL_PATH=/home/container/.steam",
36
+ "WINEDEBUG=-all",
37
+ "/opt/proton/proton",
38
+ "run",
39
+ "ArkAscendedServer.exe",
40
+ "TheIsland_WP?listen",
41
+ ]);
42
+ });
43
+
44
+ test("ensures the compat and steam directories", async () => {
45
+ ensured.length = 0;
46
+
47
+ await protonCommand(context, {
48
+ appId: "2278520",
49
+ exe: "enshrouded_server.exe",
50
+ });
51
+
52
+ expect(ensured).toEqual([
53
+ ".proton/2278520",
54
+ ".steam",
55
+ ]);
56
+ });
57
+
58
+ test("honours another proton build", async () => {
59
+ expect(
60
+ await protonCommand(context, {
61
+ appId: "1",
62
+ exe: "a.exe",
63
+ protonDir: "/opt/proton-ge",
64
+ }),
65
+ ).toContain("/opt/proton-ge/proton");
66
+ });
67
+ });
@@ -0,0 +1,49 @@
1
+ import type { BridgeContext } from "../context";
2
+ import { CONTAINER_HOME } from "./wineCommand";
3
+
4
+ export const PROTON_DIRECTORY = "/opt/proton";
5
+
6
+ export const PROTON_COMPAT_DIRECTORY = ".proton";
7
+
8
+ export const STEAM_COMPAT_CLIENT_DIRECTORY = ".steam";
9
+
10
+ export interface ProtonCommandOptions {
11
+ appId: string;
12
+ exe: string;
13
+ args?: string[];
14
+ protonDir?: string;
15
+ compatDir?: string;
16
+ steamDir?: string;
17
+ home?: string;
18
+ environment?: Record<string, string>;
19
+ }
20
+
21
+ export const protonCompatPath = (appId: string, compatDir: string = PROTON_COMPAT_DIRECTORY) => {
22
+ return `${compatDir}/${appId}`;
23
+ };
24
+
25
+ export const protonCommand = async (context: BridgeContext, options: ProtonCommandOptions): Promise<string[]> => {
26
+ const home = options.home ?? CONTAINER_HOME;
27
+ const protonDir = options.protonDir ?? PROTON_DIRECTORY;
28
+ const steamDir = options.steamDir ?? STEAM_COMPAT_CLIENT_DIRECTORY;
29
+ const compat = protonCompatPath(options.appId, options.compatDir);
30
+
31
+ await context.files.ensure(compat, steamDir);
32
+
33
+ const environment: Record<string, string> = {
34
+ HOME: home,
35
+ STEAM_COMPAT_DATA_PATH: `${home}/${compat}`,
36
+ STEAM_COMPAT_CLIENT_INSTALL_PATH: `${home}/${steamDir}`,
37
+ WINEDEBUG: "-all",
38
+ ...options.environment,
39
+ };
40
+
41
+ return [
42
+ "env",
43
+ ...Object.entries(environment).map(([key, value]) => `${key}=${value}`),
44
+ `${protonDir}/proton`,
45
+ "run",
46
+ options.exe,
47
+ ...(options.args ?? []),
48
+ ];
49
+ };
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BridgeContext } from "../context";
3
+ import { wineCommand, wineDllOverrides } from "./wineCommand";
4
+
5
+ const ensured: string[] = [];
6
+
7
+ const context = {
8
+ files: {
9
+ async ensure(...paths: string[]) {
10
+ ensured.push(...paths);
11
+ },
12
+ },
13
+ } as unknown as BridgeContext;
14
+
15
+ describe("wineDllOverrides", () => {
16
+ test("joins the overrides the way wine reads them", () => {
17
+ expect(
18
+ wineDllOverrides({
19
+ winhttp: "n,b",
20
+ mscoree: "",
21
+ }),
22
+ ).toBe("winhttp=n,b;mscoree=");
23
+ });
24
+ });
25
+
26
+ describe("wineCommand", () => {
27
+ test("builds an env prefix pointing inside the container home", async () => {
28
+ expect(
29
+ await wineCommand(context, {
30
+ exe: "VRisingServer.exe",
31
+ args: [
32
+ "-persistentDataPath",
33
+ "./save-data",
34
+ ],
35
+ }),
36
+ ).toEqual([
37
+ "env",
38
+ "HOME=/home/container",
39
+ "WINEPREFIX=/home/container/.wine",
40
+ "WINEDEBUG=-all",
41
+ "wine",
42
+ "VRisingServer.exe",
43
+ "-persistentDataPath",
44
+ "./save-data",
45
+ ]);
46
+ });
47
+
48
+ test("ensures the prefix directory it points at", async () => {
49
+ ensured.length = 0;
50
+
51
+ await wineCommand(context, {
52
+ exe: "a.exe",
53
+ prefix: ".prefix",
54
+ });
55
+
56
+ expect(ensured).toEqual([
57
+ ".prefix",
58
+ ]);
59
+ });
60
+
61
+ test("wraps the game in xvfb-run when asked", async () => {
62
+ const argv = await wineCommand(context, {
63
+ exe: "a.exe",
64
+ xvfb: true,
65
+ });
66
+
67
+ expect(argv.slice(-4)).toEqual([
68
+ "xvfb-run",
69
+ "-a",
70
+ "wine",
71
+ "a.exe",
72
+ ]);
73
+ });
74
+
75
+ test("adds the dll overrides and extra environment", async () => {
76
+ const argv = await wineCommand(context, {
77
+ exe: "a.exe",
78
+ dllOverrides: {
79
+ winhttp: "n,b",
80
+ },
81
+ environment: {
82
+ WINEDEBUG: "warn+all",
83
+ DXVK_HUD: "0",
84
+ },
85
+ });
86
+
87
+ expect(argv).toContain("WINEDLLOVERRIDES=winhttp=n,b");
88
+ expect(argv).toContain("WINEDEBUG=warn+all");
89
+ expect(argv).toContain("DXVK_HUD=0");
90
+ expect(argv).not.toContain("WINEDEBUG=-all");
91
+ });
92
+ });
@@ -0,0 +1,54 @@
1
+ import type { BridgeContext } from "../context";
2
+
3
+ export const CONTAINER_HOME = "/home/container";
4
+
5
+ export const WINE_PREFIX_DIRECTORY = ".wine";
6
+
7
+ export interface WineCommandOptions {
8
+ exe: string;
9
+ args?: string[];
10
+ xvfb?: boolean;
11
+ dllOverrides?: Record<string, string>;
12
+ prefix?: string;
13
+ home?: string;
14
+ environment?: Record<string, string>;
15
+ }
16
+
17
+ export const wineDllOverrides = (overrides: Record<string, string>) => {
18
+ return Object.entries(overrides)
19
+ .map(([library, mode]) => `${library}=${mode}`)
20
+ .join(";");
21
+ };
22
+
23
+ export const wineCommand = async (context: BridgeContext, options: WineCommandOptions): Promise<string[]> => {
24
+ const home = options.home ?? CONTAINER_HOME;
25
+ const prefix = options.prefix ?? WINE_PREFIX_DIRECTORY;
26
+
27
+ await context.files.ensure(prefix);
28
+
29
+ const environment: Record<string, string> = {
30
+ HOME: home,
31
+ WINEPREFIX: `${home}/${prefix}`,
32
+ WINEDEBUG: "-all",
33
+ ...(options.dllOverrides === undefined
34
+ ? {}
35
+ : {
36
+ WINEDLLOVERRIDES: wineDllOverrides(options.dllOverrides),
37
+ }),
38
+ ...options.environment,
39
+ };
40
+
41
+ return [
42
+ "env",
43
+ ...Object.entries(environment).map(([key, value]) => `${key}=${value}`),
44
+ ...(options.xvfb === true
45
+ ? [
46
+ "xvfb-run",
47
+ "-a",
48
+ ]
49
+ : []),
50
+ "wine",
51
+ options.exe,
52
+ ...(options.args ?? []),
53
+ ];
54
+ };
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BridgeContext, BridgeExecOptions } from "../context";
3
+ import { prepareWinePrefix, WINE_PREFIX_STAMP } from "./winePrefix";
4
+
5
+ const testContext = (code = 0) => {
6
+ const stored = new Map<string, string>();
7
+ const runs: string[][] = [];
8
+ const logs: string[] = [];
9
+
10
+ const context = {
11
+ files: {
12
+ async exists(path: string) {
13
+ return stored.has(path);
14
+ },
15
+ async read(path: string) {
16
+ return stored.get(path) ?? "";
17
+ },
18
+ async write(path: string, content: string) {
19
+ stored.set(path, content);
20
+ },
21
+ async ensure() {
22
+ return;
23
+ },
24
+ },
25
+ async exec(command: string[], _options?: BridgeExecOptions) {
26
+ runs.push(command);
27
+
28
+ return {
29
+ stdout: "",
30
+ stderr: code === 0 ? "" : "wineboot exploded",
31
+ code,
32
+ };
33
+ },
34
+ log(message: string) {
35
+ logs.push(message);
36
+ },
37
+ } as unknown as BridgeContext;
38
+
39
+ return {
40
+ context,
41
+ runs,
42
+ logs,
43
+ stored,
44
+ };
45
+ };
46
+
47
+ describe("prepareWinePrefix", () => {
48
+ test("boots a wine prefix once and stamps it", async () => {
49
+ const { context, runs, stored } = testContext();
50
+
51
+ await prepareWinePrefix(context);
52
+
53
+ expect(runs).toHaveLength(1);
54
+ expect(runs.at(0)?.slice(-3)).toEqual([
55
+ "wine",
56
+ "wineboot",
57
+ "-i",
58
+ ]);
59
+ expect(stored.get(WINE_PREFIX_STAMP)).toContain('"ready": true');
60
+
61
+ await prepareWinePrefix(context);
62
+
63
+ expect(runs).toHaveLength(1);
64
+ });
65
+
66
+ test("boots the prefix through proton when asked", async () => {
67
+ const { context, runs } = testContext();
68
+
69
+ await prepareWinePrefix(context, {
70
+ proton: true,
71
+ appId: "2430930",
72
+ });
73
+
74
+ expect(runs.at(0)?.slice(-4)).toEqual([
75
+ "/opt/proton/proton",
76
+ "run",
77
+ "wineboot",
78
+ "-i",
79
+ ]);
80
+ });
81
+
82
+ test("boots again when the runtime changed from wine to proton", async () => {
83
+ const { context, runs } = testContext();
84
+
85
+ await prepareWinePrefix(context);
86
+ await prepareWinePrefix(context, {
87
+ proton: true,
88
+ appId: "1",
89
+ });
90
+
91
+ expect(runs).toHaveLength(2);
92
+ });
93
+
94
+ test("refuses a proton prefix with no app id", async () => {
95
+ const { context } = testContext();
96
+
97
+ await expect(
98
+ prepareWinePrefix(context, {
99
+ proton: true,
100
+ }),
101
+ ).rejects.toThrow("steam app id");
102
+ });
103
+
104
+ test("raises when wineboot fails and leaves no stamp", async () => {
105
+ const { context, stored } = testContext(1);
106
+
107
+ await expect(prepareWinePrefix(context)).rejects.toThrow("wineboot exploded");
108
+
109
+ expect(stored.has(WINE_PREFIX_STAMP)).toBe(false);
110
+ });
111
+ });