@serverkgg/bridge 0.1.1 → 0.1.3
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 +3 -2
- package/rules/package.md +60 -0
- package/src/blog/articleFrontmatter.test.ts +10 -10
- package/src/blog/compile.test.ts +9 -9
- package/src/bot/botDeploy.test.ts +16 -16
- package/src/bot/botEnvironment.test.ts +8 -8
- package/src/bot/botIdentity.test.ts +12 -12
- package/src/bot/botPanel.test.ts +41 -11
- package/src/bot/botPanel.ts +13 -5
- package/src/bot/botState.test.ts +3 -3
- package/src/bot/botTerminal.test.ts +5 -5
- package/src/bot/botVariables.test.ts +8 -8
- package/src/bot/detect/botToken.test.ts +6 -6
- package/src/bot/detect/detect.test.ts +21 -21
- package/src/bot/detect/detectTable.test.ts +5 -5
- package/src/bot/detect/envExample.test.ts +2 -2
- package/src/bot/detect/envScan.test.ts +4 -4
- package/src/bot/detect/inferBotPlatform.test.ts +4 -4
- package/src/bot/detect/keys.test.ts +3 -3
- package/src/bot/detect/sensitiveKey.test.ts +5 -5
- package/src/bot/detect/serverkJson.test.ts +5 -5
- package/src/catalogs/curseforge.test.ts +35 -35
- package/src/catalogs/thunderstore.test.ts +9 -9
- package/src/cli/detectTable.test.ts +3 -3
- package/src/cli/link.test.ts +11 -11
- package/src/cli/main.ts +8 -0
- package/src/cli/rules.test.ts +198 -0
- package/src/cli/rules.ts +188 -0
- package/src/cli/validate.test.ts +123 -29
- package/src/cli/validate.ts +37 -1
- package/src/codecs/ini.test.ts +13 -13
- package/src/codecs/javaProperties.test.ts +11 -11
- package/src/codecs/sourceCfg.test.ts +14 -14
- package/src/codecs/xmlProperties.test.ts +10 -10
- package/src/context/bridgeContext.ts +6 -1
- package/src/core/bridgeAction.ts +2 -0
- package/src/core/bridgeActivity.ts +9 -0
- package/src/core/bridgeAvailability.ts +10 -0
- package/src/core/bridgePort.test.ts +6 -6
- package/src/core/bridgeUserError.test.ts +13 -3
- package/src/core/bridgeUserError.ts +6 -2
- package/src/core/bridgeValue.test.ts +5 -5
- package/src/core/describeDownloadFailure.test.ts +58 -0
- package/src/core/describeDownloadFailure.ts +69 -0
- package/src/core/index.ts +3 -0
- package/src/guides/markdown.test.ts +31 -31
- package/src/install/installStamp.test.ts +9 -9
- package/src/manifest/extension.ts +36 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +208 -90
- package/src/manifest/manifest.ts +34 -0
- package/src/modules/bridge.ts +8 -1
- package/src/modules/bridgeModule.ts +13 -1
- package/src/net/loopback.test.ts +10 -10
- package/src/panel/bridgeDetail.ts +2 -1
- package/src/presence/rosterSync.test.ts +7 -7
- package/src/probes/a2s.test.ts +5 -5
- package/src/probes/minecraftPing.test.ts +17 -17
- package/src/probes/raknetPing.test.ts +13 -13
- package/src/protocol/bridgeActivity.ts +54 -0
- package/src/protocol/bridgeEvents.ts +30 -0
- package/src/protocol/bridgeFrame.ts +10 -1
- package/src/protocol/index.ts +1 -0
- package/src/rcon/rconAccess.test.ts +20 -20
- package/src/rcon/rconAccess.ts +13 -13
- package/src/rcon/rconClient.test.ts +49 -49
- package/src/rcon/rconFrame.test.ts +14 -14
- package/src/rcon/rconIds.test.ts +4 -4
- package/src/rcon/rconWriter.test.ts +6 -6
- package/src/runtime/bridgeActions.ts +19 -2
- package/src/runtime/bridgeActivity.test.ts +318 -0
- package/src/runtime/bridgeActivity.ts +268 -0
- package/src/runtime/bridgeBackupLease.test.ts +9 -9
- package/src/runtime/bridgeConfigReader.test.ts +5 -5
- package/src/runtime/bridgeConsoleHandler.test.ts +8 -8
- package/src/runtime/bridgeContext.test.ts +11 -5
- package/src/runtime/bridgeContext.ts +8 -1
- package/src/runtime/bridgeDriver.test.ts +114 -93
- package/src/runtime/bridgeDriver.ts +10 -7
- package/src/runtime/bridgeEvents.test.ts +15 -15
- package/src/runtime/bridgeFiles.test.ts +54 -54
- package/src/runtime/bridgeInstall.test.ts +10 -10
- package/src/runtime/bridgeLiveConfig.test.ts +6 -6
- package/src/runtime/bridgeLog.ts +10 -0
- package/src/runtime/bridgePending.test.ts +6 -6
- package/src/runtime/bridgeRedaction.test.ts +16 -16
- package/src/runtime/bridgeRefusalError.ts +13 -0
- package/src/runtime/bridgeRegistry.test.ts +1077 -34
- package/src/runtime/bridgeRegistry.ts +464 -65
- package/src/runtime/bridgeScopedContext.ts +55 -0
- package/src/runtime/bridgeServer.test.ts +424 -24
- package/src/runtime/bridgeServer.ts +125 -35
- package/src/runtime/bridgeSetup.test.ts +27 -27
- package/src/runtime/bridgeSetup.ts +1 -1
- package/src/runtime/bridgeSetupHandlers.test.ts +80 -17
- package/src/runtime/bridgeSetupHandlers.ts +45 -10
- package/src/runtime/bridgeSignal.test.ts +7 -7
- package/src/runtime/bridgeSupervisor.test.ts +42 -42
- package/src/runtime/serve.ts +45 -3
- package/src/steam/avatars.test.ts +13 -13
- package/src/steam/steamApp.test.ts +8 -8
- package/src/steam/steamcmd.test.ts +44 -13
- package/src/steam/steamcmd.ts +27 -4
- package/src/steam/steamcmdNarrator.test.ts +4 -4
- package/src/steam/workshop.test.ts +5 -5
- package/src/utils/changedSettingKeys.test.ts +65 -0
- package/src/utils/changedSettingKeys.ts +6 -0
- package/src/utils/describeExecFailure.test.ts +34 -0
- package/src/utils/describeExecFailure.ts +9 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/sameSettingValue.test.ts +35 -0
- package/src/utils/sameSettingValue.ts +40 -0
- package/src/utils/token.test.ts +7 -7
- package/src/wine/protonCommand.test.ts +5 -5
- package/src/wine/wineCommand.test.ts +6 -6
- package/src/wine/winePrefix.test.ts +6 -6
- package/src/wire/activity.test.ts +95 -0
- package/src/wire/activity.ts +41 -0
- package/src/wire/index.ts +1 -0
- package/src/wire/panel.test.ts +91 -25
- package/src/wire/panel.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverkgg/bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
|
|
5
5
|
"homepage": "https://serverk.gg",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"serverk-bridge": "src/cli/main.ts"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
-
"src"
|
|
17
|
+
"src",
|
|
18
|
+
"rules"
|
|
18
19
|
],
|
|
19
20
|
"exports": {
|
|
20
21
|
".": "./src/main.ts",
|
package/rules/package.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Serverk package
|
|
2
|
+
|
|
3
|
+
Generated by `serverk-bridge rules` from the pinned `@serverkgg/bridge`. Do not edit it here: every install rewrites it. The source is `modules/bridge/rules/package.md` in `serverk`.
|
|
4
|
+
|
|
5
|
+
## This repository
|
|
6
|
+
|
|
7
|
+
One Serverk package (a game or a bot runtime) on `@serverkgg/bridge`, with no pipeline: validated with `serverk-bridge validate`, released only from `../serverk`, the monorepo beside it.
|
|
8
|
+
|
|
9
|
+
- When `../serverk` exists, read `../serverk/CLAUDE.md` first; its instructions apply here.
|
|
10
|
+
- Before matching work, read its skill in `../serverk/.claude/skills/`: `serverk-copy` (every string a customer reads), `serverk-game-package` (a new game, its manifest, image or control channel), `serverk-game-art` (logo and banner), `serverk-deploy` (publishing to dev in `dev.md`, releasing in `bridge.md`).
|
|
11
|
+
|
|
12
|
+
## The driver
|
|
13
|
+
|
|
14
|
+
- `src/main.ts` is only `await serve(driver)`. `src/driver.ts` exports `driver: BridgeDriver` with the slots in this order (`install`, `lifecycle`, `events`, `query`, `backup`, `announce`, `pending`, `setup`, `terminal`, `panel`), then the panel modules under `modules`.
|
|
15
|
+
- Key a bridge-owned module by its constant (`[RCON_ACCESS_MODULE]: rconAccess`); any other key is its export name, which a panel section names (`module: "settings"`).
|
|
16
|
+
- Type each module you write with its `Bridge` alias and `kind` from `BridgeKind` (`export const settings: Bridge.Settings = { kind: BridgeKind.Settings, ... }`), never a `Bridge*Module` name. Export a helper-built module as returned (`export const deploy = createBotDeployModule()`).
|
|
17
|
+
|
|
18
|
+
## `src/` folders
|
|
19
|
+
|
|
20
|
+
- A driver slot lives in the folder of its name, exported under that name.
|
|
21
|
+
- Group panel modules by kind in `actions/`, `collections/`, `details/`, `options/` and `settings/` (`Bridge.Actions`, `Bridge.Collection`, `Bridge.Detail`, `Bridge.Options`, `Bridge.Settings`). A feature spanning kinds gets its own folder (`worlds/`, `modpacks/`).
|
|
22
|
+
- `shared/` holds parsers, file formats and control-channel calls several folders use; `testing/` holds test fakes. Every folder has an `index.ts` re-exporting each sibling, and other folders import only that barrel.
|
|
23
|
+
- Use a bridge helper (`@serverkgg/bridge/steam`, `rcon`, `install`, `catalogs`, `presence`, `probes`, `utils`, `wine`, `net`, `bot`) before writing one. A helper a second package needs moves into the bridge, not a copy.
|
|
24
|
+
|
|
25
|
+
## Code
|
|
26
|
+
|
|
27
|
+
- Functions are `const` arrows. No classes, `function` declarations, default exports or comments.
|
|
28
|
+
- Touch the server only through `Bridge.Context` (`files`, `exec`, `net`, `codec`, `rcon`, `variable`, `secret`), never `node:fs`, `fetch`, `Bun.spawn` or `process.env`.
|
|
29
|
+
- Log through `context.log`: a lowercase message, details second. Never `console` in `src/`.
|
|
30
|
+
- Refuse a customer's request with `new BridgeUserError({ ar, en })`; a plain lowercase English `Error` is for faults the customer cannot fix.
|
|
31
|
+
- A timeout, limit, pattern or file name is a `SCREAMING_SNAKE_CASE` constant (`STOP_TIMEOUT_SECONDS`), exported when a test or another folder needs it.
|
|
32
|
+
|
|
33
|
+
## Panel, setup and events
|
|
34
|
+
|
|
35
|
+
- A game declares each tab as `const <name>Tab: Bridge.Tab`, listed in `export const panel: Bridge.Panel`; a bot's panel is the tab `createBotPanel()` from `@serverkgg/bridge/bot` builds. Reusable copy is a `Bridge.Text` constant.
|
|
36
|
+
- Every label, title, help text and message is `{ ar, en }`, Arabic first.
|
|
37
|
+
- A settings form targets `BridgeFormTarget.Settings` and module `settings`, with a one-sentence `help` and `restartHint: true`.
|
|
38
|
+
- In a game, the roster and moderation tables go on `place: BridgePlace.Players`, metrics on `BridgePlace.Overview`.
|
|
39
|
+
- A setup `Form` step points at an existing panel form by `tab`, `section` and field keys, never redeclaring a field; `Form` and `Open` steps state `required`. A game's setup ends with the `Open` step `invite` targeting `GuideOpenTab.Access`.
|
|
40
|
+
- Emit event names from `BridgeEventName`, never strings, and list every event raised from code in `emits` (`patterns: []` when none match the log). A bot matches deployments with `DEPLOYMENT_EVENT_PATTERN`.
|
|
41
|
+
- `install.run` runs on every boot: keep it idempotent with `readStamp` and `writeStamp` (`@serverkgg/bridge/install`), the stamp in `files.protected`. A game protects the default `.serverk-install.json`; a bot writes `.serverk/install.json` (`INSTALL_STAMP`, `shared/botRuntime.ts`) and protects `.serverk/**`.
|
|
42
|
+
|
|
43
|
+
## `serverk.yml`
|
|
44
|
+
|
|
45
|
+
- Line one is `# yaml-language-server: $schema=./serverk.schema.json`; rerun `bun run schema` when the bridge pin moves.
|
|
46
|
+
- Write `ar:` before `en:` in every localized field.
|
|
47
|
+
- `secrets` are platform API keys the game can use, `required: false`; customer settings are a panel form, and a bot customer's own values go through `environment.vault`.
|
|
48
|
+
- Change an outcome by changing the declared value (`reset.keep: []`, `backup.only`), never by asking the platform to ignore a field.
|
|
49
|
+
|
|
50
|
+
## Guides and image
|
|
51
|
+
|
|
52
|
+
- A guide is `guides/<id>/ar.md` and `en.md`, starting at `##` with no `#` title; its title and summary live in `serverk.yml`. Every package has a `getting-started` guide tagged `start`.
|
|
53
|
+
- `image/Dockerfile` keeps the shared contract: `ARG BRIDGE_VERSION` with `LABEL gg.serverk.bridge.version`, a `container` user with uid 1000 and home `/home/container`, `COPY dist/bridge /serverk/bridge`, `ENTRYPOINT ["/usr/bin/tini", "--", "/serverk/bridge"]`, and no `USER` line. Game files download at install, a bot's code arrives with a deploy; neither is baked in.
|
|
54
|
+
- The outermost start wrapper ends in `exec`, with no `setsid`, `nohup` or double fork. Why: the bridge checks the uid of the pid it spawned, and stop escalation signals the process group, so an orphan outside it keeps the port.
|
|
55
|
+
|
|
56
|
+
## Tests and the pin
|
|
57
|
+
|
|
58
|
+
- Tests sit beside the file as `<name>.test.ts`, use `it` from `bun:test` (never `test`), and are named as a plain statement of behaviour, never `should`. `driver.test.ts` asserts the assembled driver's tab and section wiring, and in a game its setup targets and event names.
|
|
59
|
+
- `package.json` pins `@serverkgg/bridge` to an exact published version, bumped only after a bridge publish from `serverk`, never within other work. `serverk-bridge link` points `node_modules` at `../serverk/modules/bridge` without touching the pin.
|
|
60
|
+
- The two bot packages stay identical except where the runtime differs (`shared/botRuntime.ts`, `shared/botProcess.ts`, `terminal/terminal.ts`, the image's launcher and `Dockerfile`, and the tests asserting those). A change to any other file lands in both; behaviour both need belongs in `@serverkgg/bridge/bot`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { parseArticleFrontmatter, parseArticleTranslationFrontmatter, splitFrontmatter } from "./articleFrontmatter";
|
|
3
3
|
|
|
4
4
|
const article = (front: string, body = "## عنوان\n\nنص.\n") => `---\n${front}\n---\n\n${body}`;
|
|
@@ -10,7 +10,7 @@ tags:
|
|
|
10
10
|
- minecraft`;
|
|
11
11
|
|
|
12
12
|
describe("splitFrontmatter", () => {
|
|
13
|
-
|
|
13
|
+
it("blanks the frontmatter lines so body line numbers stay true", () => {
|
|
14
14
|
const split = splitFrontmatter("blog/x/ar.md", article(VALID));
|
|
15
15
|
|
|
16
16
|
expect(split.body.split("\n").slice(0, 7)).toEqual([
|
|
@@ -25,17 +25,17 @@ describe("splitFrontmatter", () => {
|
|
|
25
25
|
expect(split.body).toContain("## عنوان");
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
it("rejects a file that does not open with a fence", () => {
|
|
29
29
|
expect(() => splitFrontmatter("blog/x/ar.md", "## عنوان\n")).toThrow('must open with a "---" frontmatter block');
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
it("rejects a frontmatter block that is never closed", () => {
|
|
33
33
|
expect(() => splitFrontmatter("blog/x/ar.md", `---\n${VALID}\n`)).toThrow("never closed");
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
describe("parseArticleFrontmatter", () => {
|
|
38
|
-
|
|
38
|
+
it("reads every declared field", () => {
|
|
39
39
|
const parsed = parseArticleFrontmatter(
|
|
40
40
|
"blog/x/ar.md",
|
|
41
41
|
article(`${VALID}\nupdatedAt: 2026-09-06\nvideo: dQw4w9WgXcQ`),
|
|
@@ -50,7 +50,7 @@ describe("parseArticleFrontmatter", () => {
|
|
|
50
50
|
]);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
it("defaults tags to an empty list", () => {
|
|
54
54
|
const parsed = parseArticleFrontmatter(
|
|
55
55
|
"blog/x/ar.md",
|
|
56
56
|
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05"),
|
|
@@ -60,13 +60,13 @@ describe("parseArticleFrontmatter", () => {
|
|
|
60
60
|
expect(parsed.front.updatedAt).toBeUndefined();
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
it("rejects a missing title", () => {
|
|
64
64
|
expect(() => parseArticleFrontmatter("blog/x/ar.md", article("summary: ملخص.\npublishedAt: 2026-09-05"))).toThrow(
|
|
65
65
|
"frontmatter is invalid",
|
|
66
66
|
);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
it("rejects a publishedAt that is not a calendar date", () => {
|
|
70
70
|
expect(() =>
|
|
71
71
|
parseArticleFrontmatter(
|
|
72
72
|
"blog/x/ar.md",
|
|
@@ -75,7 +75,7 @@ describe("parseArticleFrontmatter", () => {
|
|
|
75
75
|
).toThrow("must be a calendar date");
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
it("rejects an unknown key", () => {
|
|
79
79
|
expect(() => parseArticleFrontmatter("blog/x/ar.md", article(`${VALID}\nauthor: mohammed`))).toThrow(
|
|
80
80
|
"frontmatter is invalid",
|
|
81
81
|
);
|
|
@@ -83,7 +83,7 @@ describe("parseArticleFrontmatter", () => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
describe("parseArticleTranslationFrontmatter", () => {
|
|
86
|
-
|
|
86
|
+
it("lets a translation omit the language-independent fields", () => {
|
|
87
87
|
const parsed = parseArticleTranslationFrontmatter(
|
|
88
88
|
"blog/x/en.md",
|
|
89
89
|
article("title: How to make a server\nsummary: The three ways."),
|
package/src/blog/compile.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect,
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -33,11 +33,11 @@ describe("compileArticles", () => {
|
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
it("returns nothing when the package has no blog directory", () => {
|
|
37
37
|
expect(compile().drafts).toEqual([]);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
it("compiles both languages into one article", () => {
|
|
41
41
|
write(
|
|
42
42
|
"make-a-server",
|
|
43
43
|
article("title: كيف تسوي سيرفر\nsummary: ثلاث طرق.\npublishedAt: 2026-09-05\ntags:\n - minecraft", "عنوان"),
|
|
@@ -63,7 +63,7 @@ describe("compileArticles", () => {
|
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
it("records both markdown files so the bundle digest covers them", () => {
|
|
67
67
|
write(
|
|
68
68
|
"one",
|
|
69
69
|
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
|
|
@@ -76,7 +76,7 @@ describe("compileArticles", () => {
|
|
|
76
76
|
]);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
it("orders newest first and breaks ties by id", () => {
|
|
80
80
|
write(
|
|
81
81
|
"beta",
|
|
82
82
|
article("title: ب\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
|
|
@@ -100,7 +100,7 @@ describe("compileArticles", () => {
|
|
|
100
100
|
]);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
it("rejects a translation that contradicts a language-independent field", () => {
|
|
104
104
|
write(
|
|
105
105
|
"one",
|
|
106
106
|
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05", "عنوان"),
|
|
@@ -110,7 +110,7 @@ describe("compileArticles", () => {
|
|
|
110
110
|
expect(compile).toThrow("a language-independent field must match");
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
it("accepts a translation that repeats a language-independent field with the same value", () => {
|
|
114
114
|
write(
|
|
115
115
|
"one",
|
|
116
116
|
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05\ntags:\n - minecraft", "عنوان"),
|
|
@@ -120,7 +120,7 @@ describe("compileArticles", () => {
|
|
|
120
120
|
expect(compile().drafts).toHaveLength(1);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
it("reports a missing english translation", () => {
|
|
124
124
|
mkdirSync(join(root, "blog", "one"), {
|
|
125
125
|
recursive: true,
|
|
126
126
|
});
|
|
@@ -132,7 +132,7 @@ describe("compileArticles", () => {
|
|
|
132
132
|
expect(compile).toThrow("missing article body blog/one/en.md");
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
it("resolves into a compiled article the contract accepts", () => {
|
|
136
136
|
write(
|
|
137
137
|
"one",
|
|
138
138
|
article("title: عنوان\nsummary: ملخص.\npublishedAt: 2026-09-05\nupdatedAt: 2026-09-06", "عنوان"),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import type { BridgeValues } from "../core";
|
|
3
3
|
import { BridgeEventName } from "../protocol";
|
|
4
4
|
import {
|
|
@@ -121,7 +121,7 @@ const liveState = (deployment: string, source: BotDeploymentSource = BotDeployme
|
|
|
121
121
|
};
|
|
122
122
|
|
|
123
123
|
describe("deploymentLine", () => {
|
|
124
|
-
|
|
124
|
+
it("announces a ready deployment with the short commit", () => {
|
|
125
125
|
expect(
|
|
126
126
|
deploymentLine({
|
|
127
127
|
deployment: "dep_a1b2c3d4",
|
|
@@ -131,7 +131,7 @@ describe("deploymentLine", () => {
|
|
|
131
131
|
).toBe("serverk: deployment dep_a1b2c3d4 ready at 0123456789ab");
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
it("announces a ready deployment with no commit for files and snapshots", () => {
|
|
135
135
|
expect(
|
|
136
136
|
deploymentLine({
|
|
137
137
|
deployment: "dep_a1b2c3d4",
|
|
@@ -141,7 +141,7 @@ describe("deploymentLine", () => {
|
|
|
141
141
|
).toBe("serverk: deployment dep_a1b2c3d4 ready");
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
it("names the stage and one line of detail for a failure", () => {
|
|
145
145
|
expect(
|
|
146
146
|
deploymentLine({
|
|
147
147
|
deployment: "dep_a1b2c3d4",
|
|
@@ -154,7 +154,7 @@ describe("deploymentLine", () => {
|
|
|
154
154
|
);
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
it("round-trips through the line pattern", () => {
|
|
158
158
|
const ready = DEPLOYMENT_LINE.exec(
|
|
159
159
|
deploymentLine({
|
|
160
160
|
deployment: "dep_a1b2c3d4",
|
|
@@ -186,7 +186,7 @@ describe("deploymentLine", () => {
|
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
it("cuts the detail at 200 characters, counting an emoji as one the way the Python launcher does", () => {
|
|
190
190
|
const line = deploymentLine({
|
|
191
191
|
deployment: "dep_a1b2c3d4",
|
|
192
192
|
outcome: BotDeploymentOutcome.Failed,
|
|
@@ -198,12 +198,12 @@ describe("deploymentLine", () => {
|
|
|
198
198
|
expect(DEPLOYMENT_LINE.exec(line)?.groups?.detail).toBe("🤖".repeat(200));
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
-
|
|
201
|
+
it("reads only deployment ids of at most 64 characters", () => {
|
|
202
202
|
expect(DEPLOYMENT_LINE.test(`serverk: deployment ${"d".repeat(64)} ready`)).toBe(true);
|
|
203
203
|
expect(DEPLOYMENT_LINE.test(`serverk: deployment ${"d".repeat(65)} ready`)).toBe(false);
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
it("is reported as a server update tagged as a deployment", () => {
|
|
207
207
|
expect(DEPLOYMENT_EVENT_PATTERN.emit).toBe(BridgeEventName.ServerUpdated);
|
|
208
208
|
expect(DEPLOYMENT_EVENT_PATTERN.payload).toEqual({
|
|
209
209
|
kind: "deployment",
|
|
@@ -214,7 +214,7 @@ describe("deploymentLine", () => {
|
|
|
214
214
|
});
|
|
215
215
|
|
|
216
216
|
describe("requestDeploy", () => {
|
|
217
|
-
|
|
217
|
+
it("writes the request as JSON and restarts a running bot", async () => {
|
|
218
218
|
const { context, stored, events } = deployContext(true);
|
|
219
219
|
|
|
220
220
|
await requestDeploy(context, {
|
|
@@ -239,7 +239,7 @@ describe("requestDeploy", () => {
|
|
|
239
239
|
expect(Number.isNaN(Date.parse(markerOf(stored).requestedAt))).toBe(false);
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
-
|
|
242
|
+
it("records the request without a restart while the bot is stopped", async () => {
|
|
243
243
|
const { context, stored, events } = deployContext(false);
|
|
244
244
|
|
|
245
245
|
await requestDeploy(context, {
|
|
@@ -259,7 +259,7 @@ describe("requestDeploy", () => {
|
|
|
259
259
|
});
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
-
|
|
262
|
+
it("does nothing for a deployment that is already live and reports it again", async () => {
|
|
263
263
|
const { context, stored, events, emitted } = deployContext(true, liveState("dep_a1b2c3d4"));
|
|
264
264
|
|
|
265
265
|
await requestDeploy(context, {
|
|
@@ -286,7 +286,7 @@ describe("requestDeploy", () => {
|
|
|
286
286
|
]);
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
|
|
289
|
+
it("reports no commit again for a live deployment of uploaded code", async () => {
|
|
290
290
|
const { context, emitted } = deployContext(true, liveState("dep_a1b2c3d4", BotDeploymentSource.Snapshot));
|
|
291
291
|
|
|
292
292
|
await requestDeploy(context, {
|
|
@@ -302,7 +302,7 @@ describe("requestDeploy", () => {
|
|
|
302
302
|
});
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
-
|
|
305
|
+
it("replaces a request the bot has not taken yet", async () => {
|
|
306
306
|
const { context, stored } = deployContext(false, {
|
|
307
307
|
[DEPLOY_MARKER]: "{}",
|
|
308
308
|
});
|
|
@@ -317,7 +317,7 @@ describe("requestDeploy", () => {
|
|
|
317
317
|
expect(markerOf(stored).deployment).toBe("dep_newer000");
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
-
|
|
320
|
+
it("still reinstalls a deployment that is already live", async () => {
|
|
321
321
|
const { context, events } = deployContext(true, liveState("dep_a1b2c3d4"));
|
|
322
322
|
|
|
323
323
|
await requestDeploy(context, {
|
|
@@ -330,7 +330,7 @@ describe("requestDeploy", () => {
|
|
|
330
330
|
expect(events.at(-1)).toBe("restart");
|
|
331
331
|
});
|
|
332
332
|
|
|
333
|
-
|
|
333
|
+
it("runs a new deployment when another one is live", async () => {
|
|
334
334
|
const { context, events } = deployContext(true, liveState("dep_old00000"));
|
|
335
335
|
|
|
336
336
|
await requestDeploy(context, {
|
|
@@ -345,7 +345,7 @@ describe("requestDeploy", () => {
|
|
|
345
345
|
});
|
|
346
346
|
|
|
347
347
|
describe("deployPending", () => {
|
|
348
|
-
|
|
348
|
+
it("is true only while a running bot has not taken the marker yet", async () => {
|
|
349
349
|
const running = deployContext(true);
|
|
350
350
|
|
|
351
351
|
expect(await deployPending(running.context)).toBe(false);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
botProcessEnvironment,
|
|
4
4
|
customerEnvironment,
|
|
@@ -36,7 +36,7 @@ const config = (overrides: Partial<BotConfig> = {}): BotConfig => ({
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
describe("customerEnvironment", () => {
|
|
39
|
-
|
|
39
|
+
it("hands the process every vault key and nothing the platform or the runtime owns", () => {
|
|
40
40
|
expect(
|
|
41
41
|
customerEnvironment(
|
|
42
42
|
{
|
|
@@ -71,7 +71,7 @@ describe("customerEnvironment", () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
it("lets a secret win over a plain value under the same key", () => {
|
|
75
75
|
expect(
|
|
76
76
|
customerEnvironment(
|
|
77
77
|
{
|
|
@@ -88,7 +88,7 @@ describe("customerEnvironment", () => {
|
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
describe("launcherEnvironment", () => {
|
|
91
|
-
|
|
91
|
+
it("maps the platform-owned keys onto the launcher internals", () => {
|
|
92
92
|
expect(
|
|
93
93
|
launcherEnvironment(
|
|
94
94
|
config({
|
|
@@ -116,7 +116,7 @@ describe("launcherEnvironment", () => {
|
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
it("keeps the git keys out when no repository is linked", () => {
|
|
120
120
|
expect(
|
|
121
121
|
launcherEnvironment(
|
|
122
122
|
config({
|
|
@@ -132,7 +132,7 @@ describe("launcherEnvironment", () => {
|
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
it("owns only SERVERK_ names the launcher strips before the bot starts", () => {
|
|
136
136
|
expect(LAUNCHER_KEYS.every((key) => key.startsWith("SERVERK_"))).toBe(true);
|
|
137
137
|
expect(LAUNCHER_KEYS).toContain(LAUNCHER_REPO_REF);
|
|
138
138
|
expect(LAUNCHER_KEYS).toContain(LAUNCHER_INSTALL_COMMAND);
|
|
@@ -141,7 +141,7 @@ describe("launcherEnvironment", () => {
|
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
describe("platformEnvironment", () => {
|
|
144
|
-
|
|
144
|
+
it("marks the process as a Serverk bot and names the platform when known", () => {
|
|
145
145
|
expect(platformEnvironment("abc", BotPlatform.Discord)).toEqual({
|
|
146
146
|
SERVERK_BOT: "1",
|
|
147
147
|
SERVERK_SERVER_CODE: "abc",
|
|
@@ -155,7 +155,7 @@ describe("platformEnvironment", () => {
|
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
describe("botProcessEnvironment", () => {
|
|
158
|
-
|
|
158
|
+
it("layers tool defaults under the customer, then the launcher and the platform", () => {
|
|
159
159
|
expect(
|
|
160
160
|
botProcessEnvironment({
|
|
161
161
|
tool: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
botNameOf,
|
|
4
4
|
IDENTITY_CACHE_FILE,
|
|
@@ -40,7 +40,7 @@ const memoryFiles = (initial: Record<string, string> = {}) => {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
describe("LOGIN_LINE", () => {
|
|
43
|
-
|
|
43
|
+
it("reads the name from the login lines the popular libraries and our templates print", () => {
|
|
44
44
|
const names = [
|
|
45
45
|
"Logged in as MyBot#1234, /ping is registered",
|
|
46
46
|
"Logged in as MyBot (ID: 1136470478027245683)",
|
|
@@ -56,19 +56,19 @@ describe("LOGIN_LINE", () => {
|
|
|
56
56
|
]);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
it("ignores lines that only mention logging", () => {
|
|
60
60
|
expect(LOGIN_LINE.test("serverk: bot process starting")).toBe(false);
|
|
61
61
|
expect(LOGIN_LINE.test("user logged out")).toBe(false);
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
describe("identityFromToken", () => {
|
|
66
|
-
|
|
66
|
+
it("reads a Telegram bot id and a Discord application id without the network", () => {
|
|
67
67
|
expect(identityFromToken(BotPlatform.Telegram, TELEGRAM_TOKEN)).toBe("7123456789");
|
|
68
68
|
expect(identityFromToken(BotPlatform.Discord, DISCORD_TOKEN)).toBe(DISCORD_ID);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
it("refuses anything that is not shaped like the platform's token", () => {
|
|
72
72
|
expect(identityFromToken(BotPlatform.Discord, JWT)).toBeNull();
|
|
73
73
|
expect(identityFromToken(BotPlatform.Discord, TELEGRAM_TOKEN)).toBeNull();
|
|
74
74
|
expect(identityFromToken(BotPlatform.Telegram, DISCORD_TOKEN)).toBeNull();
|
|
@@ -77,7 +77,7 @@ describe("identityFromToken", () => {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
describe("the name cache", () => {
|
|
80
|
-
|
|
80
|
+
it("keeps only the name the bot printed, per platform", async () => {
|
|
81
81
|
const { files, stored } = memoryFiles();
|
|
82
82
|
|
|
83
83
|
await writeIdentityCache(files, {
|
|
@@ -95,7 +95,7 @@ describe("the name cache", () => {
|
|
|
95
95
|
expect(await readIdentityCache(files, BotPlatform.Discord)).toBeNull();
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
it("reads nothing from a broken or older cache", async () => {
|
|
99
99
|
expect(
|
|
100
100
|
await readIdentityCache(
|
|
101
101
|
memoryFiles({
|
|
@@ -119,7 +119,7 @@ describe("the name cache", () => {
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
describe("inviteLink", () => {
|
|
122
|
-
|
|
122
|
+
it("invites a Discord bot by id and opens a Telegram bot by name", () => {
|
|
123
123
|
expect(
|
|
124
124
|
inviteLink({
|
|
125
125
|
platform: BotPlatform.Discord,
|
|
@@ -138,7 +138,7 @@ describe("inviteLink", () => {
|
|
|
138
138
|
).toBe("https://t.me/serverk_test_bot");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
it("offers nothing it cannot build", () => {
|
|
142
142
|
expect(
|
|
143
143
|
inviteLink({
|
|
144
144
|
platform: BotPlatform.Discord,
|
|
@@ -159,7 +159,7 @@ describe("inviteLink", () => {
|
|
|
159
159
|
});
|
|
160
160
|
|
|
161
161
|
describe("watchLoginLine", () => {
|
|
162
|
-
|
|
162
|
+
it("remembers the first login name and stops listening", async () => {
|
|
163
163
|
const { files, stored } = memoryFiles();
|
|
164
164
|
const watchers: ((match: RegExpMatchArray) => void)[] = [];
|
|
165
165
|
let unsubscribed = 0;
|
|
@@ -210,7 +210,7 @@ describe("watchLoginLine", () => {
|
|
|
210
210
|
});
|
|
211
211
|
|
|
212
212
|
describe("resolveBotIdentity", () => {
|
|
213
|
-
|
|
213
|
+
it("combines the inferred platform, the id from the token and the remembered name", async () => {
|
|
214
214
|
const { files } = memoryFiles({
|
|
215
215
|
[IDENTITY_CACHE_FILE]: JSON.stringify({
|
|
216
216
|
platform: BotPlatform.Discord,
|
|
@@ -237,7 +237,7 @@ describe("resolveBotIdentity", () => {
|
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
-
|
|
240
|
+
it("knows nothing about a bot with no platform hint", async () => {
|
|
241
241
|
expect(
|
|
242
242
|
await resolveBotIdentity({
|
|
243
243
|
files: memoryFiles().files,
|
package/src/bot/botPanel.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { BridgeKind, BridgeUserError, type BridgeValues } from "../core";
|
|
3
3
|
import { BridgeLayout, BridgePlace } from "../panel";
|
|
4
4
|
import { bridgePanelSchema } from "../wire";
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
const SHA = "0123456789abcdef0123456789abcdef01234567";
|
|
20
20
|
|
|
21
21
|
describe("createBotPanel", () => {
|
|
22
|
-
|
|
22
|
+
it("is one bot tab holding only the identity card on the overview", () => {
|
|
23
23
|
const tab = createBotPanel();
|
|
24
24
|
|
|
25
25
|
expect(tab.id).toBe(BOT_TAB);
|
|
@@ -33,7 +33,7 @@ describe("createBotPanel", () => {
|
|
|
33
33
|
expect(identity?.layout === BridgeLayout.Detail && identity.module).toBe(BOT_IDENTITY_MODULE);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
it("parses through the panel mirror the agent and the api read", () => {
|
|
37
37
|
expect(
|
|
38
38
|
bridgePanelSchema.safeParse({
|
|
39
39
|
tabs: [
|
|
@@ -43,7 +43,7 @@ describe("createBotPanel", () => {
|
|
|
43
43
|
).toBe(true);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
it("names the deploy module the platform invokes", () => {
|
|
47
47
|
expect(BOT_DEPLOY_MODULE).toBe("botDeploy");
|
|
48
48
|
expect(Object.values(BotDeployAction).map(String)).toEqual([
|
|
49
49
|
"redeploy",
|
|
@@ -53,7 +53,7 @@ describe("createBotPanel", () => {
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
describe("deployRequestOf", () => {
|
|
56
|
-
|
|
56
|
+
it("takes the deployment, the pinned ref and the reinstall flag from the invoke values", () => {
|
|
57
57
|
expect(
|
|
58
58
|
deployRequestOf(
|
|
59
59
|
{
|
|
@@ -71,7 +71,7 @@ describe("deployRequestOf", () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
it("treats a bare press as a redeploy of whatever is configured", () => {
|
|
75
75
|
expect(deployRequestOf({}, false)).toEqual({
|
|
76
76
|
deployment: null,
|
|
77
77
|
ref: null,
|
|
@@ -81,7 +81,7 @@ describe("deployRequestOf", () => {
|
|
|
81
81
|
expect(deployRequestOf({}, true).reinstall).toBe(true);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
it("refuses a short ref, a bad id and a ref beside a snapshot", () => {
|
|
85
85
|
const refusals: BridgeValues[] = [
|
|
86
86
|
{
|
|
87
87
|
ref: "0123456",
|
|
@@ -111,7 +111,7 @@ describe("deployRequestOf", () => {
|
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
describe("the bot modules", () => {
|
|
114
|
-
|
|
114
|
+
it("read the identity while the bot is stopped and refresh it every minute", () => {
|
|
115
115
|
const read = async () => null;
|
|
116
116
|
|
|
117
117
|
expect(createBotIdentityModule(read)).toEqual({
|
|
@@ -174,7 +174,7 @@ describe("the bot modules", () => {
|
|
|
174
174
|
};
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
it("deploy while the bot is stopped too, so the platform can start the new code", async () => {
|
|
178
178
|
const module = createBotDeployModule();
|
|
179
179
|
const { context, stored, restarts } = deployContext(false);
|
|
180
180
|
|
|
@@ -193,7 +193,7 @@ describe("the bot modules", () => {
|
|
|
193
193
|
expect(restarts()).toBe(0);
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
it("reinstall forces the flag and restarts a running bot", async () => {
|
|
197
197
|
const module = createBotDeployModule();
|
|
198
198
|
const { context, stored, restarts } = deployContext(true);
|
|
199
199
|
|
|
@@ -205,7 +205,7 @@ describe("the bot modules", () => {
|
|
|
205
205
|
expect(restarts()).toBe(1);
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
it("refuses a second press while a running bot has not taken the first", async () => {
|
|
209
209
|
const module = createBotDeployModule();
|
|
210
210
|
const { context } = deployContext(true, [
|
|
211
211
|
DEPLOY_MARKER,
|
|
@@ -213,4 +213,34 @@ describe("the bot modules", () => {
|
|
|
213
213
|
|
|
214
214
|
await expect(module.actions[BotDeployAction.Redeploy]?.(context, {})).rejects.toThrow(BridgeUserError);
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
it("greys out both deploy buttons with the refusal's words while a running bot has not taken a deploy", async () => {
|
|
218
|
+
const module = createBotDeployModule();
|
|
219
|
+
const pending = deployContext(true, [
|
|
220
|
+
DEPLOY_MARKER,
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
const unavailable = await module.unavailable?.(pending.context);
|
|
224
|
+
|
|
225
|
+
expect(Object.keys(unavailable ?? {}).sort()).toEqual([
|
|
226
|
+
BotDeployAction.Redeploy,
|
|
227
|
+
BotDeployAction.Reinstall,
|
|
228
|
+
]);
|
|
229
|
+
await expect(module.actions[BotDeployAction.Redeploy]?.(pending.context, {})).rejects.toThrow(
|
|
230
|
+
unavailable?.[BotDeployAction.Redeploy]?.en,
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("offers both deploy buttons when nothing is pending or the bot is stopped", async () => {
|
|
235
|
+
const module = createBotDeployModule();
|
|
236
|
+
|
|
237
|
+
expect(await module.unavailable?.(deployContext(true).context)).toEqual({});
|
|
238
|
+
expect(
|
|
239
|
+
await module.unavailable?.(
|
|
240
|
+
deployContext(false, [
|
|
241
|
+
DEPLOY_MARKER,
|
|
242
|
+
]).context,
|
|
243
|
+
),
|
|
244
|
+
).toEqual({});
|
|
245
|
+
});
|
|
216
246
|
});
|