@serverkgg/bridge 0.0.10 → 0.0.11
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.test.ts +116 -1
- package/src/cli/validate.ts +50 -4
- package/src/manifest/guide.ts +1 -0
- package/src/panel/bridgeControl.ts +1 -0
- package/src/panel/bridgeDetail.ts +1 -0
- package/src/panel/bridgePanel.ts +2 -0
- package/src/protocol/bridgeLimits.ts +8 -0
- package/src/runtime/bridgeDriver.test.ts +240 -0
- package/src/runtime/bridgeDriver.ts +137 -0
- package/src/runtime/bridgeSetup.test.ts +387 -1
- package/src/runtime/bridgeSetup.ts +247 -7
- package/src/runtime/bridgeSetupHandlers.test.ts +303 -0
- package/src/runtime/bridgeSetupHandlers.ts +71 -0
- package/src/runtime/serve.ts +8 -41
- package/src/wire/panel.test.ts +62 -0
- package/src/wire/panel.ts +3 -0
package/package.json
CHANGED
package/src/cli/validate.test.ts
CHANGED
|
@@ -2,7 +2,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { BridgeKind, BridgeSetupStepKind } from "../core";
|
|
6
|
+
import { GuideOpenTab } from "../guides";
|
|
7
|
+
import type { BridgeSetupModule } from "../modules";
|
|
8
|
+
import { runValidate, setupReportLines } from "./validate";
|
|
9
|
+
|
|
10
|
+
const text = (value: string) => ({
|
|
11
|
+
ar: value,
|
|
12
|
+
en: value,
|
|
13
|
+
});
|
|
6
14
|
|
|
7
15
|
describe("validate", () => {
|
|
8
16
|
let root = "";
|
|
@@ -87,3 +95,110 @@ describe("validate", () => {
|
|
|
87
95
|
await expect(validate()).rejects.toThrow("missing serverk.yml");
|
|
88
96
|
});
|
|
89
97
|
});
|
|
98
|
+
|
|
99
|
+
describe("the setup report", () => {
|
|
100
|
+
test("says none for a driver that declares no setup", () => {
|
|
101
|
+
expect(setupReportLines(undefined)).toEqual([
|
|
102
|
+
" setup: none",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("names every step, its kind, whether it is required, and where it lands", () => {
|
|
107
|
+
const setup: BridgeSetupModule = {
|
|
108
|
+
kind: BridgeKind.Setup,
|
|
109
|
+
steps: [
|
|
110
|
+
{
|
|
111
|
+
kind: BridgeSetupStepKind.Form,
|
|
112
|
+
id: "license",
|
|
113
|
+
title: text("Licence"),
|
|
114
|
+
required: true,
|
|
115
|
+
tab: "license",
|
|
116
|
+
section: "license",
|
|
117
|
+
fields: [
|
|
118
|
+
"LICENSE_KEY",
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
kind: BridgeSetupStepKind.Driver,
|
|
123
|
+
id: "verify",
|
|
124
|
+
title: text("Verify"),
|
|
125
|
+
required: true,
|
|
126
|
+
requiresRunning: true,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
kind: BridgeSetupStepKind.Open,
|
|
130
|
+
id: "txadmin",
|
|
131
|
+
title: text("txAdmin"),
|
|
132
|
+
required: false,
|
|
133
|
+
target: {
|
|
134
|
+
tab: GuideOpenTab.Panel,
|
|
135
|
+
tabId: "license",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
kind: BridgeSetupStepKind.Open,
|
|
140
|
+
id: "invite",
|
|
141
|
+
title: text("Invite"),
|
|
142
|
+
required: false,
|
|
143
|
+
target: {
|
|
144
|
+
tab: GuideOpenTab.Access,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
submit: async () => {
|
|
149
|
+
return;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
expect(setupReportLines(setup)).toEqual([
|
|
154
|
+
" setup: 4 step(s)",
|
|
155
|
+
" license — form, required → license/license [LICENSE_KEY]",
|
|
156
|
+
" verify — driver, required → driver (needs the game running)",
|
|
157
|
+
' txadmin — open, optional → panel tab "license"',
|
|
158
|
+
" invite — open, optional → access",
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("leaves a driver step that needs nothing running with no target", () => {
|
|
163
|
+
expect(
|
|
164
|
+
setupReportLines({
|
|
165
|
+
kind: BridgeKind.Setup,
|
|
166
|
+
steps: [
|
|
167
|
+
{
|
|
168
|
+
kind: BridgeSetupStepKind.Driver,
|
|
169
|
+
id: "sign-in",
|
|
170
|
+
title: text("Sign in"),
|
|
171
|
+
required: false,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
submit: async () => {
|
|
175
|
+
return;
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
).toEqual([
|
|
179
|
+
" setup: 1 step(s)",
|
|
180
|
+
" sign-in — driver, optional → driver",
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("reads an omitted required as required, the way the platform does", () => {
|
|
185
|
+
expect(
|
|
186
|
+
setupReportLines({
|
|
187
|
+
kind: BridgeKind.Setup,
|
|
188
|
+
steps: [
|
|
189
|
+
{
|
|
190
|
+
kind: BridgeSetupStepKind.Driver,
|
|
191
|
+
id: "sign-in",
|
|
192
|
+
title: text("Sign in"),
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
submit: async () => {
|
|
196
|
+
return;
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
).toEqual([
|
|
200
|
+
" setup: 1 step(s)",
|
|
201
|
+
" sign-in — driver, required → driver",
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
});
|
package/src/cli/validate.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { type GuidePack, readGuidePack, readGuidePackage } from "../guides";
|
|
6
|
+
import { GuideOpenTab, type GuideOpenTarget, type GuidePack, readGuidePack, readGuidePackage } from "../guides";
|
|
7
7
|
import {
|
|
8
8
|
declaredFiles,
|
|
9
9
|
type GuidePackage,
|
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
readAssets,
|
|
14
14
|
readPackageDocument,
|
|
15
15
|
} from "../manifest";
|
|
16
|
-
import type { BridgeDriver } from "../modules";
|
|
17
|
-
import { isBridgeEventName } from "../protocol";
|
|
16
|
+
import type { BridgeDriver, BridgeSetupModule } from "../modules";
|
|
17
|
+
import { type BridgeSetupStep, BridgeSetupStepKind, isBridgeEventName } from "../protocol";
|
|
18
18
|
import { validateDriver, validatePortActivation } from "../runtime/bridgeDriver";
|
|
19
19
|
import { directorySource } from "./directorySource";
|
|
20
20
|
|
|
@@ -186,6 +186,49 @@ const reportActionTargets = (driver: BridgeDriver) => {
|
|
|
186
186
|
}
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
+
const openTargetOf = (target: GuideOpenTarget) => {
|
|
190
|
+
if (target.tab === GuideOpenTab.Panel) {
|
|
191
|
+
return `panel tab "${target.tabId}"`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (target.tab === GuideOpenTab.Files) {
|
|
195
|
+
return target.path ? `files at "${target.path}"` : "files";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return target.tab;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const setupTargetOf = (step: BridgeSetupStep) => {
|
|
202
|
+
if (step.kind === BridgeSetupStepKind.Form) {
|
|
203
|
+
const fields = step.fields ?? [];
|
|
204
|
+
|
|
205
|
+
return `→ ${step.tab}/${step.section}${fields.length === 0 ? "" : ` [${fields.join(", ")}]`}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (step.kind === BridgeSetupStepKind.Open) {
|
|
209
|
+
return `→ ${openTargetOf(step.target)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return step.requiresRunning ? "→ driver (needs the game running)" : "→ driver";
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
export const setupReportLines = (setup: BridgeSetupModule | undefined): string[] => {
|
|
216
|
+
if (!setup) {
|
|
217
|
+
return [
|
|
218
|
+
" setup: none",
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return [
|
|
223
|
+
` setup: ${setup.steps.length} step(s)`,
|
|
224
|
+
...setup.steps.map((step) => {
|
|
225
|
+
const target = setupTargetOf(step);
|
|
226
|
+
|
|
227
|
+
return ` ${step.id} — ${step.kind}, ${step.required === false ? "optional" : "required"}${target === "" ? "" : ` ${target}`}`;
|
|
228
|
+
}),
|
|
229
|
+
];
|
|
230
|
+
};
|
|
231
|
+
|
|
189
232
|
const validateGamePackage = async (source: PackageSource, manifest: Manifest, dir: string, driverArg?: string) => {
|
|
190
233
|
const assets = readAssets(source, manifest);
|
|
191
234
|
const guideFiles = manifest.guides.flatMap((guide) => [
|
|
@@ -230,7 +273,10 @@ const validateGamePackage = async (source: PackageSource, manifest: Manifest, di
|
|
|
230
273
|
` driver: ${Object.keys(driver.modules ?? {}).length} panel module(s), ${driver.panel?.tabs.length ?? 0} tab(s)`,
|
|
231
274
|
);
|
|
232
275
|
console.log(` pending: ${driver.pending ? `declared (${driver.pending.extensions.length} extension(s))` : "none"}`);
|
|
233
|
-
|
|
276
|
+
for (const line of setupReportLines(driver.setup)) {
|
|
277
|
+
console.log(line);
|
|
278
|
+
}
|
|
279
|
+
|
|
234
280
|
reportEvents(driver);
|
|
235
281
|
reportActionTargets(driver);
|
|
236
282
|
};
|
package/src/manifest/guide.ts
CHANGED
package/src/panel/bridgePanel.ts
CHANGED
|
@@ -40,6 +40,8 @@ export const BRIDGE_PENDING_FILES_LIMIT = 64;
|
|
|
40
40
|
|
|
41
41
|
export const BRIDGE_SETUP_STEPS_LIMIT = 8;
|
|
42
42
|
|
|
43
|
+
export const BRIDGE_SETUP_ACTIONS_LIMIT = 4;
|
|
44
|
+
|
|
43
45
|
export const BRIDGE_ID_LENGTH_LIMIT = 32;
|
|
44
46
|
|
|
45
47
|
export const BRIDGE_KEY_LENGTH_LIMIT = 64;
|
|
@@ -53,3 +55,9 @@ export const BRIDGE_PLACEHOLDER_LENGTH_LIMIT = 256;
|
|
|
53
55
|
export const BRIDGE_STAGING_LENGTH_LIMIT = 128;
|
|
54
56
|
|
|
55
57
|
export const BRIDGE_EXTENSION_LENGTH_LIMIT = 16;
|
|
58
|
+
|
|
59
|
+
export const BRIDGE_PATTERN_LENGTH_LIMIT = 256;
|
|
60
|
+
|
|
61
|
+
export const BRIDGE_SETUP_CODE_LENGTH_LIMIT = 64;
|
|
62
|
+
|
|
63
|
+
export const BRIDGE_SETUP_DETAIL_LENGTH_LIMIT = 256;
|
|
@@ -1155,6 +1155,11 @@ const driverWithSetup = (setup: BridgeSetupModule): BridgeDriver => ({
|
|
|
1155
1155
|
fields: [
|
|
1156
1156
|
field("SERVER_NAME"),
|
|
1157
1157
|
field("MOTD"),
|
|
1158
|
+
{
|
|
1159
|
+
key: "ADMIN_PASSWORD",
|
|
1160
|
+
control: BridgeControl.Secret,
|
|
1161
|
+
label: text("Admin password"),
|
|
1162
|
+
},
|
|
1158
1163
|
],
|
|
1159
1164
|
},
|
|
1160
1165
|
],
|
|
@@ -1172,6 +1177,40 @@ const driverStep = (id: string) => ({
|
|
|
1172
1177
|
const setupWith = (steps: BridgeSetupStep[]): BridgeSetupModule => ({
|
|
1173
1178
|
kind: BridgeKind.Setup,
|
|
1174
1179
|
steps,
|
|
1180
|
+
submit: async () => {
|
|
1181
|
+
return;
|
|
1182
|
+
},
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
const setupWithoutSubmit = (steps: BridgeSetupStep[]): BridgeSetupModule => ({
|
|
1186
|
+
kind: BridgeKind.Setup,
|
|
1187
|
+
steps,
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
const driverWithFields = (fields: BridgeField[]): BridgeDriver => ({
|
|
1191
|
+
install: {
|
|
1192
|
+
kind: BridgeKind.Install,
|
|
1193
|
+
run: async () => {
|
|
1194
|
+
return;
|
|
1195
|
+
},
|
|
1196
|
+
},
|
|
1197
|
+
lifecycle: {
|
|
1198
|
+
kind: BridgeKind.Lifecycle,
|
|
1199
|
+
command: async () => [
|
|
1200
|
+
"run",
|
|
1201
|
+
],
|
|
1202
|
+
},
|
|
1203
|
+
panel: {
|
|
1204
|
+
tabs: [
|
|
1205
|
+
{
|
|
1206
|
+
id: "settings",
|
|
1207
|
+
title: text("Settings"),
|
|
1208
|
+
sections: [
|
|
1209
|
+
formSection("server", fields),
|
|
1210
|
+
],
|
|
1211
|
+
},
|
|
1212
|
+
],
|
|
1213
|
+
},
|
|
1175
1214
|
});
|
|
1176
1215
|
|
|
1177
1216
|
describe("the setup singleton", () => {
|
|
@@ -1370,6 +1409,58 @@ describe("the setup singleton", () => {
|
|
|
1370
1409
|
).not.toThrow();
|
|
1371
1410
|
});
|
|
1372
1411
|
|
|
1412
|
+
test("takes a form step over a secret field", () => {
|
|
1413
|
+
expect(() =>
|
|
1414
|
+
validateDriver(
|
|
1415
|
+
driverWithSetup(
|
|
1416
|
+
setupWith([
|
|
1417
|
+
{
|
|
1418
|
+
kind: BridgeSetupStepKind.Form,
|
|
1419
|
+
id: "password",
|
|
1420
|
+
title: text("Password"),
|
|
1421
|
+
tab: "settings",
|
|
1422
|
+
section: "server",
|
|
1423
|
+
fields: [
|
|
1424
|
+
"ADMIN_PASSWORD",
|
|
1425
|
+
],
|
|
1426
|
+
},
|
|
1427
|
+
]),
|
|
1428
|
+
),
|
|
1429
|
+
),
|
|
1430
|
+
).not.toThrow();
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
test("rejects a driver step while the setup exports no submit", () => {
|
|
1434
|
+
expect(() =>
|
|
1435
|
+
validateDriver(
|
|
1436
|
+
driverWithSetup(
|
|
1437
|
+
setupWithoutSubmit([
|
|
1438
|
+
driverStep("sign-in"),
|
|
1439
|
+
]),
|
|
1440
|
+
),
|
|
1441
|
+
),
|
|
1442
|
+
).toThrow("the setup module declares a driver step but exports no submit");
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
test("takes a setup with no submit while every step is answered elsewhere", () => {
|
|
1446
|
+
expect(() =>
|
|
1447
|
+
validateDriver(
|
|
1448
|
+
driverWithSetup(
|
|
1449
|
+
setupWithoutSubmit([
|
|
1450
|
+
{
|
|
1451
|
+
kind: BridgeSetupStepKind.Open,
|
|
1452
|
+
id: "invite",
|
|
1453
|
+
title: text("Invite"),
|
|
1454
|
+
target: {
|
|
1455
|
+
tab: GuideOpenTab.Access,
|
|
1456
|
+
},
|
|
1457
|
+
},
|
|
1458
|
+
]),
|
|
1459
|
+
),
|
|
1460
|
+
),
|
|
1461
|
+
).not.toThrow();
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1373
1464
|
test("reserves the setup module id for the singleton", () => {
|
|
1374
1465
|
expect(() =>
|
|
1375
1466
|
validateDriver({
|
|
@@ -1395,3 +1486,152 @@ describe("the setup singleton", () => {
|
|
|
1395
1486
|
).toThrow('module id "setup" is reserved');
|
|
1396
1487
|
});
|
|
1397
1488
|
});
|
|
1489
|
+
|
|
1490
|
+
describe("field patterns", () => {
|
|
1491
|
+
test("takes a pattern on a text field and on a secret one", () => {
|
|
1492
|
+
expect(() =>
|
|
1493
|
+
validateDriver(
|
|
1494
|
+
driverWithFields([
|
|
1495
|
+
{
|
|
1496
|
+
key: "LICENSE_KEY",
|
|
1497
|
+
control: BridgeControl.Secret,
|
|
1498
|
+
label: text("Licence key"),
|
|
1499
|
+
pattern: "^cfxk_\\w{1,60}_\\w{1,20}$",
|
|
1500
|
+
patternHint: text("A licence key starts with cfxk_"),
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
key: "SERVER_NAME",
|
|
1504
|
+
control: BridgeControl.Text,
|
|
1505
|
+
label: text("Name"),
|
|
1506
|
+
pattern: "^.{1,32}$",
|
|
1507
|
+
},
|
|
1508
|
+
]),
|
|
1509
|
+
),
|
|
1510
|
+
).not.toThrow();
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
test("rejects a pattern that is not a regular expression", () => {
|
|
1514
|
+
expect(() =>
|
|
1515
|
+
validateDriver(
|
|
1516
|
+
driverWithFields([
|
|
1517
|
+
{
|
|
1518
|
+
key: "LICENSE_KEY",
|
|
1519
|
+
control: BridgeControl.Text,
|
|
1520
|
+
label: text("Licence key"),
|
|
1521
|
+
pattern: "^cfxk_(",
|
|
1522
|
+
},
|
|
1523
|
+
]),
|
|
1524
|
+
),
|
|
1525
|
+
).toThrow('declares pattern "^cfxk_(", which is not a valid regular expression');
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
test("takes the FiveM licence pattern, whose repeats are all bounded", () => {
|
|
1529
|
+
expect(() =>
|
|
1530
|
+
validateDriver(
|
|
1531
|
+
driverWithFields([
|
|
1532
|
+
{
|
|
1533
|
+
key: "LICENSE_KEY",
|
|
1534
|
+
control: BridgeControl.Secret,
|
|
1535
|
+
label: text("Licence key"),
|
|
1536
|
+
pattern: "^(?:cfxk_\\w{1,60}_\\w{1,20}|\\w{32})$",
|
|
1537
|
+
},
|
|
1538
|
+
]),
|
|
1539
|
+
),
|
|
1540
|
+
).not.toThrow();
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
test("rejects a pattern that repeats a group which already repeats", () => {
|
|
1544
|
+
expect(() =>
|
|
1545
|
+
validateDriver(
|
|
1546
|
+
driverWithFields([
|
|
1547
|
+
{
|
|
1548
|
+
key: "LICENSE_KEY",
|
|
1549
|
+
control: BridgeControl.Text,
|
|
1550
|
+
label: text("Licence key"),
|
|
1551
|
+
pattern: "^(a+)+$",
|
|
1552
|
+
},
|
|
1553
|
+
]),
|
|
1554
|
+
),
|
|
1555
|
+
).toThrow('declares pattern "^(a+)+$", which repeats a group that already repeats');
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
test("rejects a starred group whose body repeats without a bound", () => {
|
|
1559
|
+
expect(() =>
|
|
1560
|
+
validateDriver(
|
|
1561
|
+
driverWithFields([
|
|
1562
|
+
{
|
|
1563
|
+
key: "SERVER_NAME",
|
|
1564
|
+
control: BridgeControl.Text,
|
|
1565
|
+
label: text("Name"),
|
|
1566
|
+
pattern: "^(\\w+\\s?)*$",
|
|
1567
|
+
},
|
|
1568
|
+
]),
|
|
1569
|
+
),
|
|
1570
|
+
).toThrow("repeats a group that already repeats");
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
test("rejects a pattern longer than the platform takes", () => {
|
|
1574
|
+
expect(() =>
|
|
1575
|
+
validateDriver(
|
|
1576
|
+
driverWithFields([
|
|
1577
|
+
{
|
|
1578
|
+
key: "LICENSE_KEY",
|
|
1579
|
+
control: BridgeControl.Text,
|
|
1580
|
+
label: text("Licence key"),
|
|
1581
|
+
pattern: `^${"a".repeat(256)}$`,
|
|
1582
|
+
},
|
|
1583
|
+
]),
|
|
1584
|
+
),
|
|
1585
|
+
).toThrow("pattern is 258 characters — the platform takes at most 256");
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
test("rejects a pattern hint with no pattern to explain", () => {
|
|
1589
|
+
expect(() =>
|
|
1590
|
+
validateDriver(
|
|
1591
|
+
driverWithFields([
|
|
1592
|
+
{
|
|
1593
|
+
key: "LICENSE_KEY",
|
|
1594
|
+
control: BridgeControl.Text,
|
|
1595
|
+
label: text("Licence key"),
|
|
1596
|
+
patternHint: text("A licence key starts with cfxk_"),
|
|
1597
|
+
},
|
|
1598
|
+
]),
|
|
1599
|
+
),
|
|
1600
|
+
).toThrow("declares a pattern hint with no pattern to explain");
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
test("rejects a pattern on a control that never matches one", () => {
|
|
1604
|
+
expect(() =>
|
|
1605
|
+
validateDriver(
|
|
1606
|
+
driverWithFields([
|
|
1607
|
+
{
|
|
1608
|
+
key: "WHITELIST",
|
|
1609
|
+
control: BridgeControl.Boolean,
|
|
1610
|
+
label: text("Whitelist"),
|
|
1611
|
+
pattern: "^true$",
|
|
1612
|
+
},
|
|
1613
|
+
]),
|
|
1614
|
+
),
|
|
1615
|
+
).toThrow('declares a pattern on a "boolean" control — only text and secret controls match one');
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
test("rejects a secret control that offers options", () => {
|
|
1619
|
+
expect(() =>
|
|
1620
|
+
validateDriver(
|
|
1621
|
+
driverWithFields([
|
|
1622
|
+
{
|
|
1623
|
+
key: "ADMIN_PASSWORD",
|
|
1624
|
+
control: BridgeControl.Secret,
|
|
1625
|
+
label: text("Admin password"),
|
|
1626
|
+
options: [
|
|
1627
|
+
{
|
|
1628
|
+
value: "letmein",
|
|
1629
|
+
label: text("letmein"),
|
|
1630
|
+
},
|
|
1631
|
+
],
|
|
1632
|
+
},
|
|
1633
|
+
]),
|
|
1634
|
+
),
|
|
1635
|
+
).toThrow("is a secret control with options — a secret is typed, never chosen from a list");
|
|
1636
|
+
});
|
|
1637
|
+
});
|
|
@@ -9,6 +9,7 @@ import { GuideOpenTab } from "../guides";
|
|
|
9
9
|
import type { ManifestPort } from "../manifest";
|
|
10
10
|
import type { BridgeDriver, BridgePanelModule } from "../modules";
|
|
11
11
|
import {
|
|
12
|
+
BridgeControl,
|
|
12
13
|
type BridgeField,
|
|
13
14
|
BridgeFormTarget,
|
|
14
15
|
BridgeLayout,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
BRIDGE_MODULES_LIMIT,
|
|
33
34
|
BRIDGE_OPTION_VALUE_LENGTH_LIMIT,
|
|
34
35
|
BRIDGE_PANEL_TABS_LIMIT,
|
|
36
|
+
BRIDGE_PATTERN_LENGTH_LIMIT,
|
|
35
37
|
BRIDGE_PLACEHOLDER_LENGTH_LIMIT,
|
|
36
38
|
BRIDGE_ROW_ACTIONS_LIMIT,
|
|
37
39
|
BRIDGE_SECTION_ACTIONS_LIMIT,
|
|
@@ -121,6 +123,110 @@ const moduleOf = (modules: Record<string, BridgePanelModule>, id: string, where:
|
|
|
121
123
|
return module as BridgePanelModule;
|
|
122
124
|
};
|
|
123
125
|
|
|
126
|
+
const compilePattern = (pattern: string): RegExp | null => {
|
|
127
|
+
try {
|
|
128
|
+
return new RegExp(pattern);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const OPEN_ENDED_REPEAT = /^\{\d+,\}/;
|
|
135
|
+
|
|
136
|
+
const repeatsWithoutBound = (source: string): boolean => {
|
|
137
|
+
let index = 0;
|
|
138
|
+
let inClass = false;
|
|
139
|
+
|
|
140
|
+
while (index < source.length) {
|
|
141
|
+
const char = source[index];
|
|
142
|
+
|
|
143
|
+
if (char === "\\") {
|
|
144
|
+
index += 2;
|
|
145
|
+
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (inClass) {
|
|
150
|
+
inClass = char !== "]";
|
|
151
|
+
index += 1;
|
|
152
|
+
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (char === "[") {
|
|
157
|
+
inClass = true;
|
|
158
|
+
index += 1;
|
|
159
|
+
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (char === "*" || char === "+" || (char === "{" && OPEN_ENDED_REPEAT.test(source.slice(index)))) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
index += 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return false;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const nestsQuantifiers = (pattern: string): boolean => {
|
|
174
|
+
const opened: number[] = [];
|
|
175
|
+
|
|
176
|
+
let index = 0;
|
|
177
|
+
let inClass = false;
|
|
178
|
+
|
|
179
|
+
while (index < pattern.length) {
|
|
180
|
+
const char = pattern[index];
|
|
181
|
+
|
|
182
|
+
if (char === "\\") {
|
|
183
|
+
index += 2;
|
|
184
|
+
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (inClass) {
|
|
189
|
+
inClass = char !== "]";
|
|
190
|
+
index += 1;
|
|
191
|
+
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (char === "[") {
|
|
196
|
+
inClass = true;
|
|
197
|
+
index += 1;
|
|
198
|
+
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (char === "(") {
|
|
203
|
+
opened.push(index);
|
|
204
|
+
index += 1;
|
|
205
|
+
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (char === ")") {
|
|
210
|
+
const start = opened.pop();
|
|
211
|
+
|
|
212
|
+
index += 1;
|
|
213
|
+
|
|
214
|
+
const quantified =
|
|
215
|
+
pattern[index] === "*" || pattern[index] === "+" || OPEN_ENDED_REPEAT.test(pattern.slice(index));
|
|
216
|
+
|
|
217
|
+
if (start !== undefined && quantified && repeatsWithoutBound(pattern.slice(start + 1, index - 1))) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
index += 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return false;
|
|
228
|
+
};
|
|
229
|
+
|
|
124
230
|
const validateVisibleWhen = (visibleWhen: BridgeVisibleWhen | undefined, where: string) => {
|
|
125
231
|
if (!visibleWhen) {
|
|
126
232
|
return;
|
|
@@ -149,12 +255,39 @@ const validateFields = (modules: Record<string, BridgePanelModule>, fields: Brid
|
|
|
149
255
|
validateText(field.label, "label", at);
|
|
150
256
|
validateText(field.help, "help", at);
|
|
151
257
|
validateText(field.warning, "warning", at);
|
|
258
|
+
validateText(field.patternHint, "pattern hint", at);
|
|
152
259
|
validateVisibleWhen(field.visibleWhen, at);
|
|
153
260
|
|
|
154
261
|
if (field.placeholder) {
|
|
155
262
|
lengthIn(field.placeholder, BRIDGE_PLACEHOLDER_LENGTH_LIMIT, "placeholder", at);
|
|
156
263
|
}
|
|
157
264
|
|
|
265
|
+
if (field.pattern !== undefined) {
|
|
266
|
+
if (field.control !== BridgeControl.Text && field.control !== BridgeControl.Secret) {
|
|
267
|
+
fail(`${at} declares a pattern on a "${field.control}" control — only text and secret controls match one`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
lengthIn(field.pattern, BRIDGE_PATTERN_LENGTH_LIMIT, "pattern", at);
|
|
271
|
+
|
|
272
|
+
if (!compilePattern(field.pattern)) {
|
|
273
|
+
fail(`${at} declares pattern "${field.pattern}", which is not a valid regular expression`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (nestsQuantifiers(field.pattern)) {
|
|
277
|
+
fail(
|
|
278
|
+
`${at} declares pattern "${field.pattern}", which repeats a group that already repeats — a value can make it run for minutes`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (field.patternHint && field.pattern === undefined) {
|
|
284
|
+
fail(`${at} declares a pattern hint with no pattern to explain`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (field.control === BridgeControl.Secret && field.options) {
|
|
288
|
+
fail(`${at} is a secret control with options — a secret is typed, never chosen from a list`);
|
|
289
|
+
}
|
|
290
|
+
|
|
158
291
|
if (Array.isArray(field.options)) {
|
|
159
292
|
countIn(field.options.length, BRIDGE_FIELD_OPTIONS_LIMIT, "options", at);
|
|
160
293
|
|
|
@@ -515,6 +648,10 @@ const validateSetup = (driver: BridgeDriver) => {
|
|
|
515
648
|
}
|
|
516
649
|
}
|
|
517
650
|
}
|
|
651
|
+
|
|
652
|
+
if (!setup.submit && setup.steps.some((step) => step.kind === BridgeSetupStepKind.Driver)) {
|
|
653
|
+
fail(`${where} declares a driver step but exports no submit — a driver step is answered through submit`);
|
|
654
|
+
}
|
|
518
655
|
};
|
|
519
656
|
|
|
520
657
|
const validateTab = (modules: Record<string, BridgePanelModule>, tab: BridgeTab) => {
|