@serverkgg/bridge 0.0.5 → 0.0.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Serverk game bridge — driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -15,7 +15,7 @@ import {
15
15
  } from "../manifest";
16
16
  import type { BridgeDriver } from "../modules";
17
17
  import { isBridgeEventName } from "../protocol";
18
- import { validateDriver } from "../runtime/bridgeDriver";
18
+ import { validateDriver, validatePortActivation } from "../runtime/bridgeDriver";
19
19
  import { directorySource } from "./directorySource";
20
20
 
21
21
  const HELP = `serverk-bridge validate [options]
@@ -214,10 +214,13 @@ const validateGamePackage = async (source: PackageSource, manifest: Manifest, di
214
214
  }
215
215
 
216
216
  validateDriver(driver);
217
+ validatePortActivation(manifest.container.ports, driver);
218
+
219
+ const conditionalPorts = manifest.container.ports.filter((port) => port.activeWhen.length > 0).length;
217
220
 
218
221
  console.log(`${manifest.metadata.id} is valid`);
219
222
  console.log(
220
- ` manifest: ${manifest.container.ports.length} port(s), ${manifest.secrets.length} secret(s), ${manifest.guides.length} guide(s)`,
223
+ ` manifest: ${manifest.container.ports.length} port(s), ${conditionalPorts} conditional, ${manifest.secrets.length} secret(s), ${manifest.guides.length} guide(s)`,
221
224
  );
222
225
  console.log(
223
226
  ` assets: logo ${assets.logo.dimensions.width}x${assets.logo.dimensions.height}, banner ${assets.banner.dimensions.width}x${assets.banner.dimensions.height}`,
@@ -3,6 +3,7 @@ export enum BridgeAction {
3
3
  Install = "install",
4
4
  Installed = "installed",
5
5
  List = "list",
6
+ Preview = "preview",
6
7
  Quiesce = "quiesce",
7
8
  Read = "read",
8
9
  Release = "release",
@@ -16,6 +17,7 @@ export enum BridgeAction {
16
17
  export const BRIDGE_READ_ACTIONS: readonly string[] = [
17
18
  BridgeAction.Installed,
18
19
  BridgeAction.List,
20
+ BridgeAction.Preview,
19
21
  BridgeAction.Read,
20
22
  BridgeAction.Sample,
21
23
  BridgeAction.Search,
@@ -9,6 +9,7 @@ export enum BridgeKind {
9
9
  Install = "install",
10
10
  Lifecycle = "lifecycle",
11
11
  Options = "options",
12
+ Preview = "preview",
12
13
  Query = "query",
13
14
  Settings = "settings",
14
15
  }
@@ -84,6 +84,12 @@ const withPortSrv = (srv: unknown) => {
84
84
  });
85
85
  };
86
86
 
87
+ const withPortActiveWhen = (activeWhen: unknown) => {
88
+ return withPort({
89
+ activeWhen,
90
+ });
91
+ };
92
+
87
93
  describe("defaults", () => {
88
94
  test("fills in the optional sections so consumers never guard", () => {
89
95
  const manifest = manifestSchema.parse(base());
@@ -105,6 +111,7 @@ describe("defaults", () => {
105
111
  expect(manifest.container.ports[0]?.name).toBeUndefined();
106
112
  expect(manifest.container.ports[0]?.hint).toBeUndefined();
107
113
  expect(manifest.container.ports[0]?.join).toBe(PortJoin.Combined);
114
+ expect(manifest.container.ports[0]?.activeWhen).toEqual([]);
108
115
  });
109
116
  });
110
117
 
@@ -179,6 +186,117 @@ describe("port srv", () => {
179
186
  });
180
187
  });
181
188
 
189
+ describe("port activeWhen", () => {
190
+ test("keeps every condition a port declares", () => {
191
+ const manifest = manifestSchema.parse(
192
+ withPortActiveWhen([
193
+ {
194
+ variable: "SERVER_TYPE",
195
+ values: [
196
+ "paper",
197
+ "fabric",
198
+ ],
199
+ },
200
+ {
201
+ variable: "CROSSPLAY",
202
+ values: [
203
+ "true",
204
+ ],
205
+ },
206
+ ]),
207
+ );
208
+
209
+ expect(manifest.container.ports[0]?.activeWhen).toEqual([
210
+ {
211
+ variable: "SERVER_TYPE",
212
+ values: [
213
+ "paper",
214
+ "fabric",
215
+ ],
216
+ },
217
+ {
218
+ variable: "CROSSPLAY",
219
+ values: [
220
+ "true",
221
+ ],
222
+ },
223
+ ]);
224
+ });
225
+
226
+ test("rejects a condition that declares neither values nor empty", () => {
227
+ expect(
228
+ failure(
229
+ withPortActiveWhen([
230
+ {
231
+ variable: "CROSSPLAY",
232
+ },
233
+ ]),
234
+ ),
235
+ ).toEqual([
236
+ {
237
+ path: "container.ports.0.activeWhen.0",
238
+ message: "declare values, empty, or both",
239
+ },
240
+ ]);
241
+ });
242
+
243
+ test("rejects a condition whose values list is empty", () => {
244
+ expect(
245
+ failure(
246
+ withPortActiveWhen([
247
+ {
248
+ variable: "CROSSPLAY",
249
+ values: [],
250
+ },
251
+ ]),
252
+ ),
253
+ ).toEqual([
254
+ {
255
+ path: "container.ports.0.activeWhen.0.values",
256
+ message: "Too small: expected array to have >=1 items",
257
+ },
258
+ ]);
259
+ });
260
+
261
+ test("rejects a fifth condition", () => {
262
+ expect(
263
+ failure(
264
+ withPortActiveWhen(
265
+ Array.from({
266
+ length: 5,
267
+ }).map((_entry, index) => ({
268
+ variable: `RULE_${index}`,
269
+ empty: false,
270
+ })),
271
+ ),
272
+ ),
273
+ ).toEqual([
274
+ {
275
+ path: "container.ports.0.activeWhen",
276
+ message: "Too big: expected array to have <=4 items",
277
+ },
278
+ ]);
279
+ });
280
+
281
+ test("rejects a variable that is not an environment key", () => {
282
+ expect(
283
+ failure(
284
+ withPortActiveWhen([
285
+ {
286
+ variable: "crossplay",
287
+ empty: false,
288
+ },
289
+ ]),
290
+ ),
291
+ ).toEqual([
292
+ {
293
+ path: "container.ports.0.activeWhen.0.variable",
294
+ message: "must be SCREAMING_SNAKE_CASE, starting with a letter",
295
+ },
296
+ ]);
297
+ });
298
+ });
299
+
182
300
  describe("cross-entry checks", () => {
183
301
  test("rejects an image that does not match metadata.id", () => {
184
302
  const manifest = base();
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { PortJoin, PortProtocol } from "../core";
3
3
  import { BridgeIcon } from "../panel/bridgeIcon";
4
+ import { BRIDGE_OPTION_VALUE_LENGTH_LIMIT, BRIDGE_VISIBLE_VALUES_LIMIT } from "../protocol/bridgeLimits";
4
5
  import { GameCategory } from "./category";
5
6
  import { type GuideEntry, type GuideTag, guideEntrySchema, guideTagSchema } from "./guide";
6
7
  import { IMAGE_NAMESPACE } from "./image";
@@ -34,6 +35,20 @@ const manifestHostRangeSchema = z
34
35
 
35
36
  export type ManifestHostRange = z.infer<typeof manifestHostRangeSchema>;
36
37
 
38
+ const manifestPortActivationSchema = z
39
+ .object({
40
+ variable: zEnvKey,
41
+ values: z
42
+ .array(z.string().min(1).max(BRIDGE_OPTION_VALUE_LENGTH_LIMIT))
43
+ .min(1)
44
+ .max(BRIDGE_VISIBLE_VALUES_LIMIT)
45
+ .optional(),
46
+ empty: z.boolean().optional(),
47
+ })
48
+ .refine((rule) => rule.values !== undefined || rule.empty !== undefined, "declare values, empty, or both");
49
+
50
+ export type ManifestPortActivation = z.infer<typeof manifestPortActivationSchema>;
51
+
37
52
  const manifestPortSchema = z.object({
38
53
  key: z
39
54
  .string()
@@ -63,6 +78,10 @@ const manifestPortSchema = z.object({
63
78
  description:
64
79
  "Publishes this port as an SRV record under the server hostname, so players join by name with no port.",
65
80
  }),
81
+ activeWhen: z.array(manifestPortActivationSchema).max(4).default([]).meta({
82
+ description:
83
+ "Conditions on the server's variables that must all hold for the game to listen on this port. The port is still allocated; the panel hides the address otherwise.",
84
+ }),
66
85
  });
67
86
 
68
87
  export type ManifestPort = z.infer<typeof manifestPortSchema>;
@@ -9,6 +9,7 @@ import type {
9
9
  BridgeCatalogProvider,
10
10
  BridgeCatalogQuery,
11
11
  } from "../protocol/bridgeCatalog";
12
+ import type { BridgePreview } from "../protocol/bridgePreview";
12
13
  import type { BridgeTerminalArg, BridgeTerminalCommand, BridgeTerminalRule } from "../terminal";
13
14
  import type { BridgeDriver } from "./bridgeDriver";
14
15
  import type {
@@ -22,6 +23,7 @@ import type {
22
23
  BridgeInstallModule,
23
24
  BridgeLifecycleModule,
24
25
  BridgeOptionsModule,
26
+ BridgePreviewModule,
25
27
  BridgeQueryModule,
26
28
  BridgeRowAction,
27
29
  BridgeSample,
@@ -54,6 +56,8 @@ export namespace Bridge {
54
56
  export type Option = BridgeOption;
55
57
  export type Options = BridgeOptionsModule;
56
58
  export type Panel = BridgePanel;
59
+ export type Preview = BridgePreviewModule;
60
+ export type PreviewResult = BridgePreview;
57
61
  export type Query = BridgeQueryModule;
58
62
  export type Reply = BridgeReply;
59
63
  export type Row = BridgeRow;
@@ -2,6 +2,7 @@ import type { BridgeContext } from "../context";
2
2
  import type { BridgeKind, BridgeOption, BridgeRow, BridgeStream, BridgeValues } from "../core";
3
3
  import type { BridgeCatalogEntry, BridgeCatalogPage, BridgeCatalogQuery } from "../protocol/bridgeCatalog";
4
4
  import type { BridgeDescribeResult } from "../protocol/bridgeFrame";
5
+ import type { BridgePreview } from "../protocol/bridgePreview";
5
6
  import type { BridgeTerminalCommand, BridgeTerminalRule } from "../terminal";
6
7
 
7
8
  export type BridgeDescription = Omit<BridgeDescribeResult, "supported">;
@@ -69,6 +70,11 @@ export interface BridgeOptionsModule {
69
70
  list(context: BridgeContext): Promise<BridgeOption[]>;
70
71
  }
71
72
 
73
+ export interface BridgePreviewModule {
74
+ kind: BridgeKind.Preview;
75
+ preview(context: BridgeContext, values: BridgeValues): Promise<BridgePreview>;
76
+ }
77
+
72
78
  export interface BridgeSettingsModule {
73
79
  kind: BridgeKind.Settings;
74
80
  read(context: BridgeContext): Promise<BridgeValues>;
@@ -113,6 +119,7 @@ export type BridgePanelModule =
113
119
  | BridgeCatalogModule
114
120
  | BridgeCollectionModule
115
121
  | BridgeOptionsModule
122
+ | BridgePreviewModule
116
123
  | BridgeSettingsModule;
117
124
 
118
125
  export type BridgeModule =
@@ -16,6 +16,10 @@ export interface BridgeOptionsRef {
16
16
  module: string;
17
17
  }
18
18
 
19
+ export interface BridgePreviewRef {
20
+ module: string;
21
+ }
22
+
19
23
  export interface BridgeField {
20
24
  key: string;
21
25
  control: BridgeControl;
@@ -68,6 +72,7 @@ export interface BridgeFormSection {
68
72
  title?: LocalizedText;
69
73
  target: BridgeFormTarget;
70
74
  module?: string;
75
+ preview?: BridgePreviewRef;
71
76
  reinstall?: boolean;
72
77
  restartHint?: boolean;
73
78
  confirm?: BridgeConfirm;
@@ -16,6 +16,8 @@ export const BRIDGE_FIELD_OPTIONS_LIMIT = 64;
16
16
 
17
17
  export const BRIDGE_VISIBLE_VALUES_LIMIT = 16;
18
18
 
19
+ export const BRIDGE_PREVIEW_LINES_LIMIT = 8;
20
+
19
21
  export const BRIDGE_UPLOAD_EXTENSIONS_LIMIT = 8;
20
22
 
21
23
  export const BRIDGE_MODULES_LIMIT = 32;
@@ -0,0 +1,7 @@
1
+ import type { LocalizedText } from "../core";
2
+ import type { BridgeConfirm } from "../panel";
3
+
4
+ export interface BridgePreview {
5
+ confirm: BridgeConfirm;
6
+ lines: LocalizedText[];
7
+ }
@@ -9,4 +9,5 @@ export * from "./bridgeFrame";
9
9
  export * from "./bridgeLimits";
10
10
  export * from "./bridgeLine";
11
11
  export * from "./bridgeManifest";
12
+ export * from "./bridgePreview";
12
13
  export * from "./bridgeVersion";
@@ -13,6 +13,12 @@ export const actionsOf = (module: BridgePanelModule): string[] => {
13
13
  ];
14
14
  }
15
15
 
16
+ case BridgeKind.Preview: {
17
+ return [
18
+ BridgeAction.Preview,
19
+ ];
20
+ }
21
+
16
22
  case BridgeKind.Settings: {
17
23
  return [
18
24
  BridgeAction.Read,
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { BridgeKind } from "../core";
2
+ import { BridgeKind, PortJoin, PortProtocol } from "../core";
3
+ import type { ManifestPort, ManifestPortActivation } from "../manifest";
3
4
  import type { BridgeDriver } from "../modules";
4
5
  import {
6
+ BridgeConfirm,
5
7
  BridgeControl,
6
8
  type BridgeField,
7
9
  BridgeFormTarget,
@@ -9,7 +11,7 @@ import {
9
11
  type BridgeSectionAction,
10
12
  type BridgeVisibleWhen,
11
13
  } from "../panel";
12
- import { validateDriver } from "./bridgeDriver";
14
+ import { validateDriver, validatePortActivation } from "./bridgeDriver";
13
15
 
14
16
  const text = (value: string) => ({
15
17
  ar: value,
@@ -110,6 +112,124 @@ const formSection = (id: string, fields: BridgeField[]) => ({
110
112
  fields,
111
113
  });
112
114
 
115
+ const driverWithPreview = (): BridgeDriver => ({
116
+ install: {
117
+ kind: BridgeKind.Install,
118
+ run: async () => {
119
+ return;
120
+ },
121
+ },
122
+ lifecycle: {
123
+ kind: BridgeKind.Lifecycle,
124
+ command: async () => [
125
+ "run",
126
+ ],
127
+ },
128
+ modules: {
129
+ transition: {
130
+ kind: BridgeKind.Preview,
131
+ preview: async () => ({
132
+ confirm: BridgeConfirm.Normal,
133
+ lines: [],
134
+ }),
135
+ },
136
+ },
137
+ panel: {
138
+ tabs: [
139
+ {
140
+ id: "version",
141
+ title: text("Version"),
142
+ sections: [
143
+ {
144
+ ...formSection("version", [
145
+ field("MC_VERSION"),
146
+ ]),
147
+ preview: {
148
+ module: "transition",
149
+ },
150
+ },
151
+ ],
152
+ },
153
+ ],
154
+ },
155
+ });
156
+
157
+ const formSectionOf = (driver: BridgeDriver) => {
158
+ const [section] = driver.panel?.tabs[0]?.sections ?? [];
159
+
160
+ if (!section || section.layout !== BridgeLayout.Form) {
161
+ throw new Error("fixture is missing its form section");
162
+ }
163
+
164
+ return section;
165
+ };
166
+
167
+ const driverWithVariables = (): BridgeDriver => ({
168
+ install: {
169
+ kind: BridgeKind.Install,
170
+ run: async () => {
171
+ return;
172
+ },
173
+ },
174
+ lifecycle: {
175
+ kind: BridgeKind.Lifecycle,
176
+ command: async () => [
177
+ "run",
178
+ ],
179
+ },
180
+ modules: {
181
+ modpacks: {
182
+ kind: BridgeKind.Catalog,
183
+ search: async () => ({
184
+ hits: [],
185
+ total: 0,
186
+ providers: [],
187
+ categories: [],
188
+ sorts: [],
189
+ }),
190
+ installed: async () => [],
191
+ install: async () => {
192
+ throw new Error("not installable in the fixture");
193
+ },
194
+ remove: async () => {
195
+ return;
196
+ },
197
+ },
198
+ },
199
+ panel: {
200
+ tabs: [
201
+ {
202
+ id: "version",
203
+ title: text("Version"),
204
+ sections: [
205
+ formSection("version", [
206
+ field("MC_VERSION"),
207
+ ]),
208
+ {
209
+ layout: BridgeLayout.Catalog,
210
+ id: "modpacks",
211
+ module: "modpacks",
212
+ variable: "MODPACK",
213
+ },
214
+ ],
215
+ },
216
+ ],
217
+ },
218
+ });
219
+
220
+ const port = (activeWhen: ManifestPortActivation[]): ManifestPort => ({
221
+ key: "bedrock",
222
+ containerPort: 19_132,
223
+ protocol: PortProtocol.Udp,
224
+ hostRange: {
225
+ default: 19_132,
226
+ min: 19_132,
227
+ max: 19_232,
228
+ },
229
+ join: PortJoin.Combined,
230
+ activeWhen,
231
+ });
232
+
113
233
  const driverWithTabs = (tabs: number, sections: number): BridgeDriver => ({
114
234
  install: {
115
235
  kind: BridgeKind.Install,
@@ -448,3 +568,85 @@ describe("visibleWhen", () => {
448
568
  expect(() => validateDriver(driver)).toThrow('field "MC_VERSION" is visible when "SERVER_TYPE" matches nothing');
449
569
  });
450
570
  });
571
+
572
+ describe("form preview", () => {
573
+ test("accepts a variables form pointing at a preview module", () => {
574
+ expect(() => validateDriver(driverWithPreview())).not.toThrow();
575
+ });
576
+
577
+ test("rejects a preview ref to a module that is not a preview module", () => {
578
+ const driver = driverWithPreview();
579
+
580
+ driver.modules = {
581
+ transition: {
582
+ kind: BridgeKind.Settings,
583
+ read: async () => ({}),
584
+ },
585
+ };
586
+
587
+ expect(() => validateDriver(driver)).toThrow('references "transition", which is not a preview module');
588
+ });
589
+
590
+ test("rejects a preview on a form that writes settings", () => {
591
+ const driver = driverWithPreview();
592
+ const section = formSectionOf(driver);
593
+
594
+ section.target = BridgeFormTarget.Settings;
595
+ section.module = "config";
596
+
597
+ driver.modules = {
598
+ ...driver.modules,
599
+ config: {
600
+ kind: BridgeKind.Settings,
601
+ read: async () => ({}),
602
+ write: async () => {
603
+ return;
604
+ },
605
+ },
606
+ };
607
+
608
+ expect(() => validateDriver(driver)).toThrow("declares a preview but does not target variables");
609
+ });
610
+ });
611
+
612
+ describe("validatePortActivation", () => {
613
+ test("accepts a variable a variables form or a catalog section declares", () => {
614
+ expect(() =>
615
+ validatePortActivation(
616
+ [
617
+ port([
618
+ {
619
+ variable: "MC_VERSION",
620
+ values: [
621
+ "26.2",
622
+ ],
623
+ },
624
+ {
625
+ variable: "MODPACK",
626
+ empty: true,
627
+ },
628
+ ]),
629
+ ],
630
+ driverWithVariables(),
631
+ ),
632
+ ).not.toThrow();
633
+ });
634
+
635
+ test("rejects a variable nothing in the panel declares", () => {
636
+ expect(() =>
637
+ validatePortActivation(
638
+ [
639
+ port([
640
+ {
641
+ variable: "CROSSPLAY",
642
+ values: [
643
+ "true",
644
+ ],
645
+ },
646
+ ]),
647
+ ],
648
+ driverWithVariables(),
649
+ ),
650
+ ).toThrow('port "bedrock" activeWhen[0] names "CROSSPLAY", which no variables form or catalog declares');
651
+ });
652
+ });
@@ -1,4 +1,5 @@
1
1
  import { BRIDGE_ID_PATTERN, BRIDGE_MODULE_ID_PATTERN, BridgeKind, type LocalizedText } from "../core";
2
+ import type { ManifestPort } from "../manifest";
2
3
  import type { BridgeDriver, BridgePanelModule } from "../modules";
3
4
  import {
4
5
  type BridgeField,
@@ -237,6 +238,18 @@ const validateSection = (modules: Record<string, BridgePanelModule>, section: Br
237
238
  }
238
239
  }
239
240
 
241
+ if (section.preview) {
242
+ if (section.target !== BridgeFormTarget.Variables) {
243
+ fail(`${where} declares a preview but does not target variables`);
244
+ }
245
+
246
+ const preview = moduleOf(modules, section.preview.module, where);
247
+
248
+ if (preview.kind !== BridgeKind.Preview) {
249
+ fail(`${where} references "${section.preview.module}", which is not a preview module`);
250
+ }
251
+ }
252
+
240
253
  validateText(section.confirmText, "confirm text", where);
241
254
 
242
255
  if (section.fields.length === 0) {
@@ -417,3 +430,39 @@ export const validateDriver = (driver: BridgeDriver) => {
417
430
  validateTab(modules, tab);
418
431
  }
419
432
  };
433
+
434
+ const declaredVariables = (driver: BridgeDriver) => {
435
+ const declared = new Set<string>();
436
+
437
+ for (const tab of driver.panel?.tabs ?? []) {
438
+ for (const section of tab.sections) {
439
+ if (section.layout === BridgeLayout.Form && section.target === BridgeFormTarget.Variables) {
440
+ for (const field of section.fields) {
441
+ declared.add(field.key);
442
+ }
443
+ }
444
+
445
+ if (section.layout === BridgeLayout.Catalog && section.variable) {
446
+ declared.add(section.variable);
447
+ }
448
+ }
449
+ }
450
+
451
+ return declared;
452
+ };
453
+
454
+ export const validatePortActivation = (ports: ManifestPort[], driver: BridgeDriver): void => {
455
+ const declared = declaredVariables(driver);
456
+
457
+ for (const port of ports) {
458
+ for (const [index, rule] of port.activeWhen.entries()) {
459
+ if (declared.has(rule.variable)) {
460
+ continue;
461
+ }
462
+
463
+ fail(
464
+ `port "${port.key}" activeWhen[${index}] names "${rule.variable}", which no variables form or catalog declares`,
465
+ );
466
+ }
467
+ }
468
+ };
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { tmpdir } from "node:os";
3
3
  import { BridgeAction, BridgeKind, type BridgeRow, type BridgeValues } from "../core";
4
4
  import type { BridgeDriver } from "../modules";
5
- import { type BridgeConfig, BridgeEventName } from "../protocol";
5
+ import { type BridgeConfig, BridgeConfirm, BridgeEventName } from "../protocol";
6
6
  import { createContext } from "./bridgeContext";
7
7
  import { createEventRing } from "./bridgeEvents";
8
8
  import { log } from "./bridgeLog";
@@ -38,8 +38,11 @@ const baseDriver = (): BridgeDriver => ({
38
38
  },
39
39
  });
40
40
 
41
- const registryFor = (driver: BridgeDriver) => {
42
- const settings = config();
41
+ const registryFor = (driver: BridgeDriver, variables: Record<string, string> = {}) => {
42
+ const settings = {
43
+ ...config(),
44
+ variables,
45
+ };
43
46
 
44
47
  const supervisor = createSupervisor({
45
48
  cwd: settings.cwd,
@@ -289,3 +292,137 @@ describe("collection row actions", () => {
289
292
  ]);
290
293
  });
291
294
  });
295
+
296
+ describe("preview modules", () => {
297
+ test("lists the preview action in the manifest", () => {
298
+ const registry = registryFor({
299
+ ...baseDriver(),
300
+ modules: {
301
+ transition: {
302
+ kind: BridgeKind.Preview,
303
+ preview: async () => ({
304
+ confirm: BridgeConfirm.Normal,
305
+ lines: [],
306
+ }),
307
+ },
308
+ },
309
+ });
310
+
311
+ expect(registry.manifest().modules).toEqual([
312
+ {
313
+ id: "transition",
314
+ kind: BridgeKind.Preview,
315
+ requiresRunning: false,
316
+ refreshSeconds: null,
317
+ pageSize: null,
318
+ dependsOn: [],
319
+ actions: [
320
+ BridgeAction.Preview,
321
+ ],
322
+ },
323
+ ]);
324
+ });
325
+
326
+ test("shows the module the pending values as variables and leaves the rest as they are stored", async () => {
327
+ const seen: {
328
+ version: string | null;
329
+ count: string | null;
330
+ type: string | null;
331
+ args: BridgeValues;
332
+ }[] = [];
333
+
334
+ const registry = registryFor(
335
+ {
336
+ ...baseDriver(),
337
+ modules: {
338
+ transition: {
339
+ kind: BridgeKind.Preview,
340
+ preview: async (context, values) => {
341
+ seen.push({
342
+ version: context.variable("MC_VERSION"),
343
+ count: context.variable("n"),
344
+ type: context.variable("SERVER_TYPE"),
345
+ args: values,
346
+ });
347
+
348
+ return {
349
+ confirm: BridgeConfirm.Strong,
350
+ lines: [],
351
+ };
352
+ },
353
+ },
354
+ },
355
+ },
356
+ {
357
+ MC_VERSION: "26.2",
358
+ SERVER_TYPE: "paper",
359
+ },
360
+ );
361
+
362
+ await registry.invoke("transition", BridgeAction.Preview, {
363
+ MC_VERSION: "1.21",
364
+ n: 3,
365
+ });
366
+
367
+ expect(seen).toEqual([
368
+ {
369
+ version: "1.21",
370
+ count: null,
371
+ type: "paper",
372
+ args: {
373
+ MC_VERSION: "1.21",
374
+ n: 3,
375
+ },
376
+ },
377
+ ]);
378
+ });
379
+
380
+ test("returns what the module answers", async () => {
381
+ const registry = registryFor({
382
+ ...baseDriver(),
383
+ modules: {
384
+ transition: {
385
+ kind: BridgeKind.Preview,
386
+ preview: async () => ({
387
+ confirm: BridgeConfirm.Strong,
388
+ lines: [
389
+ {
390
+ ar: "عالمك يبقى",
391
+ en: "Your world stays",
392
+ },
393
+ ],
394
+ }),
395
+ },
396
+ },
397
+ });
398
+
399
+ expect(await registry.invoke("transition", BridgeAction.Preview)).toEqual({
400
+ confirm: BridgeConfirm.Strong,
401
+ lines: [
402
+ {
403
+ ar: "عالمك يبقى",
404
+ en: "Your world stays",
405
+ },
406
+ ],
407
+ });
408
+ });
409
+
410
+ test("refuses an action a preview module does not answer", async () => {
411
+ const registry = registryFor({
412
+ ...baseDriver(),
413
+ modules: {
414
+ transition: {
415
+ kind: BridgeKind.Preview,
416
+ preview: async () => ({
417
+ confirm: BridgeConfirm.Normal,
418
+ lines: [],
419
+ }),
420
+ },
421
+ },
422
+ });
423
+
424
+ await expect(registry.invoke("transition", BridgeAction.Read)).rejects.toThrow(
425
+ 'module "transition" does not support "read"',
426
+ );
427
+ });
428
+ });
@@ -228,6 +228,24 @@ export const createRegistry = (
228
228
  break;
229
229
  }
230
230
 
231
+ case BridgeKind.Preview: {
232
+ if (action === BridgeAction.Preview) {
233
+ const overlay: Record<string, string> = {};
234
+
235
+ for (const [key, value] of Object.entries(args)) {
236
+ if (typeof value === "string") {
237
+ overlay[key] = value;
238
+ }
239
+ }
240
+
241
+ const scoped = withVariables(context, overlay);
242
+
243
+ return await module.preview(scoped, args);
244
+ }
245
+
246
+ break;
247
+ }
248
+
231
249
  case BridgeKind.Settings: {
232
250
  if (action === BridgeAction.Read) {
233
251
  return await module.read(context);