@serverkgg/bridge 0.0.4 → 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 +1 -1
- package/src/cli/validate.ts +5 -2
- package/src/core/bridgeAction.ts +2 -0
- package/src/core/bridgeKind.ts +1 -0
- package/src/core/bridgePort.ts +5 -0
- package/src/manifest/manifest.test.ts +222 -0
- package/src/manifest/manifest.ts +40 -1
- package/src/modules/bridge.ts +4 -0
- package/src/modules/bridgeModule.ts +7 -0
- package/src/panel/bridgePanel.ts +5 -0
- package/src/protocol/bridgeLimits.ts +2 -0
- package/src/protocol/bridgePreview.ts +7 -0
- package/src/protocol/index.ts +1 -0
- package/src/runtime/bridgeActions.ts +6 -0
- package/src/runtime/bridgeDriver.test.ts +204 -2
- package/src/runtime/bridgeDriver.ts +49 -0
- package/src/runtime/bridgeRegistry.test.ts +140 -3
- package/src/runtime/bridgeRegistry.ts +18 -0
package/package.json
CHANGED
package/src/cli/validate.ts
CHANGED
|
@@ -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}`,
|
package/src/core/bridgeAction.ts
CHANGED
|
@@ -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,
|
package/src/core/bridgeKind.ts
CHANGED
package/src/core/bridgePort.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { PortJoin } from "../core";
|
|
3
4
|
import { GameCategory } from "./category";
|
|
4
5
|
import { guidePackageSchema, MANIFEST_SCHEMA_VERSION, manifestSchema, packageSchema } from "./manifest";
|
|
5
6
|
|
|
@@ -55,6 +56,40 @@ const failure = (input: unknown) => {
|
|
|
55
56
|
}));
|
|
56
57
|
};
|
|
57
58
|
|
|
59
|
+
const withPort = (extra: Record<string, unknown>) => {
|
|
60
|
+
const manifest = base();
|
|
61
|
+
const [game] = manifest.container.ports;
|
|
62
|
+
|
|
63
|
+
if (!game) {
|
|
64
|
+
throw new Error("fixture is missing its port");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
...manifest,
|
|
69
|
+
container: {
|
|
70
|
+
...manifest.container,
|
|
71
|
+
ports: [
|
|
72
|
+
{
|
|
73
|
+
...game,
|
|
74
|
+
...extra,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const withPortSrv = (srv: unknown) => {
|
|
82
|
+
return withPort({
|
|
83
|
+
srv,
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const withPortActiveWhen = (activeWhen: unknown) => {
|
|
88
|
+
return withPort({
|
|
89
|
+
activeWhen,
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
58
93
|
describe("defaults", () => {
|
|
59
94
|
test("fills in the optional sections so consumers never guard", () => {
|
|
60
95
|
const manifest = manifestSchema.parse(base());
|
|
@@ -72,6 +107,193 @@ describe("defaults", () => {
|
|
|
72
107
|
});
|
|
73
108
|
expect(manifest.guides).toEqual([]);
|
|
74
109
|
expect(manifest.secrets).toEqual([]);
|
|
110
|
+
expect(manifest.container.ports[0]?.srv).toBeUndefined();
|
|
111
|
+
expect(manifest.container.ports[0]?.name).toBeUndefined();
|
|
112
|
+
expect(manifest.container.ports[0]?.hint).toBeUndefined();
|
|
113
|
+
expect(manifest.container.ports[0]?.join).toBe(PortJoin.Combined);
|
|
114
|
+
expect(manifest.container.ports[0]?.activeWhen).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("port presentation", () => {
|
|
119
|
+
test("keeps the localized name, hint and join style a port declares", () => {
|
|
120
|
+
const manifest = manifestSchema.parse(
|
|
121
|
+
withPort({
|
|
122
|
+
name: {
|
|
123
|
+
ar: "بيدروك",
|
|
124
|
+
en: "Bedrock",
|
|
125
|
+
},
|
|
126
|
+
hint: {
|
|
127
|
+
ar: "العنوان في خانة والمنفذ في خانة ثانية",
|
|
128
|
+
en: "Address and port go in separate fields",
|
|
129
|
+
},
|
|
130
|
+
join: "separate",
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(manifest.container.ports[0]?.name).toEqual({
|
|
135
|
+
ar: "بيدروك",
|
|
136
|
+
en: "Bedrock",
|
|
137
|
+
});
|
|
138
|
+
expect(manifest.container.ports[0]?.hint?.en).toBe("Address and port go in separate fields");
|
|
139
|
+
expect(manifest.container.ports[0]?.join).toBe(PortJoin.Separate);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("rejects a port name missing one language", () => {
|
|
143
|
+
expect(
|
|
144
|
+
failure(
|
|
145
|
+
withPort({
|
|
146
|
+
name: {
|
|
147
|
+
en: "Bedrock",
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
).toEqual([
|
|
152
|
+
{
|
|
153
|
+
path: "container.ports.0.name.ar",
|
|
154
|
+
message: "Invalid input: expected string, received undefined",
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("port srv", () => {
|
|
161
|
+
test("keeps the service label a port declares", () => {
|
|
162
|
+
const manifest = manifestSchema.parse(
|
|
163
|
+
withPortSrv({
|
|
164
|
+
service: "_minecraft",
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(manifest.container.ports[0]?.srv).toEqual({
|
|
169
|
+
service: "_minecraft",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("rejects a service label without its underscore", () => {
|
|
174
|
+
expect(
|
|
175
|
+
failure(
|
|
176
|
+
withPortSrv({
|
|
177
|
+
service: "minecraft",
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
).toEqual([
|
|
181
|
+
{
|
|
182
|
+
path: "container.ports.0.srv.service",
|
|
183
|
+
message: "must be an underscore-prefixed service label such as _minecraft",
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
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
|
+
]);
|
|
75
297
|
});
|
|
76
298
|
});
|
|
77
299
|
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { PortProtocol } from "../core";
|
|
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()
|
|
@@ -43,6 +58,30 @@ const manifestPortSchema = z.object({
|
|
|
43
58
|
containerPort: zPort,
|
|
44
59
|
protocol: z.enum(PortProtocol).default(PortProtocol.Tcp),
|
|
45
60
|
hostRange: manifestHostRangeSchema,
|
|
61
|
+
name: localizedText(48).optional().meta({
|
|
62
|
+
description: "Player-facing name of this port, such as Java or Bedrock. The key is shown when omitted.",
|
|
63
|
+
}),
|
|
64
|
+
hint: localizedText(160).optional().meta({
|
|
65
|
+
description: "One short line telling the player where this address goes in the game client.",
|
|
66
|
+
}),
|
|
67
|
+
join: z.enum(PortJoin).default(PortJoin.Combined).meta({
|
|
68
|
+
description: "How the game client takes the address: combined as host:port, or separate address and port fields.",
|
|
69
|
+
}),
|
|
70
|
+
srv: z
|
|
71
|
+
.object({
|
|
72
|
+
service: z
|
|
73
|
+
.string()
|
|
74
|
+
.regex(/^_[a-z0-9-]{1,15}$/, "must be an underscore-prefixed service label such as _minecraft"),
|
|
75
|
+
})
|
|
76
|
+
.optional()
|
|
77
|
+
.meta({
|
|
78
|
+
description:
|
|
79
|
+
"Publishes this port as an SRV record under the server hostname, so players join by name with no port.",
|
|
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
|
+
}),
|
|
46
85
|
});
|
|
47
86
|
|
|
48
87
|
export type ManifestPort = z.infer<typeof manifestPortSchema>;
|
package/src/modules/bridge.ts
CHANGED
|
@@ -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 =
|
package/src/panel/bridgePanel.ts
CHANGED
|
@@ -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;
|
package/src/protocol/index.ts
CHANGED
|
@@ -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 =
|
|
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);
|