gentle-shell-bridge 0.3.0 → 0.3.2
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/LICENSE +202 -0
- package/README.md +91 -722
- package/docs/contract.md +92 -35
- package/package.json +13 -1
- package/src/api.ts +1 -1
- package/.codegraph/.gitignore +0 -5
- package/PRD.md +0 -144
- package/examples/bash-card.ts +0 -1694
- package/gentle-shell-bridge-0.3.0.tgz +0 -0
- package/odd/tasks/palette-sections.md +0 -30
- package/odd/tasks/plugin-keymap.md +0 -32
- package/odd/tasks/plugin-surface-contract.md +0 -55
- package/test/api.test.ts +0 -395
- package/test/apply-on-move.test.ts +0 -477
- package/test/arm.test.ts +0 -220
- package/test/bridge-ready.test.ts +0 -309
- package/test/bus.test.ts +0 -165
- package/test/button-rows.test.ts +0 -409
- package/test/capture.test.ts +0 -356
- package/test/card-style-rows.test.ts +0 -510
- package/test/card-style-transform.test.ts +0 -379
- package/test/card-style-wiring.test.ts +0 -258
- package/test/card-styles-e2e.test.ts +0 -491
- package/test/card-styles.test.ts +0 -324
- package/test/card-transcript-path.test.ts +0 -178
- package/test/config.test.ts +0 -93
- package/test/e2e.test.ts +0 -443
- package/test/example-bash-identity.test.ts +0 -372
- package/test/example-bash-matrix.test.ts +0 -856
- package/test/example-designs.test.ts +0 -734
- package/test/example-neon-transform.test.ts +0 -283
- package/test/example-plain-style.test.ts +0 -1268
- package/test/extension.test.ts +0 -160
- package/test/fake-events.ts +0 -58
- package/test/fake-host.ts +0 -211
- package/test/fake-sidebar.ts +0 -516
- package/test/fake-transcript.ts +0 -486
- package/test/frame-telemetry.test.ts +0 -417
- package/test/hook.test.ts +0 -196
- package/test/manifest.test.ts +0 -105
- package/test/new-category.test.ts +0 -979
- package/test/notices.test.ts +0 -328
- package/test/plugin-integration-lines.test.ts +0 -979
- package/test/plugin-surface.test.ts +0 -152
- package/test/presence.test.ts +0 -459
- package/test/rail-surfaces.test.ts +0 -457
- package/test/rail-width.test.ts +0 -423
- package/test/regression.test.ts +0 -483
- package/test/repaint-api.test.ts +0 -200
- package/test/reserved-categories.test.ts +0 -361
- package/test/row-labels.test.ts +0 -284
- package/test/row-names.test.ts +0 -473
- package/test/row-origin.test.ts +0 -395
- package/test/section-interaction.test.ts +0 -364
- package/test/section-panel.test.ts +0 -403
- package/test/sections-rows.test.ts +0 -291
- package/test/sidebar-backend.test.ts +0 -471
- package/test/sidebar-e2e.test.ts +0 -331
- package/test/sidebar-hover.test.ts +0 -276
- package/test/sidebar-independent.test.ts +0 -349
- package/test/sidebar-render.test.ts +0 -281
- package/test/sidebar-sections.test.ts +0 -245
- package/test/status-card-styles.test.ts +0 -201
- package/test/status-section-frame.test.ts +0 -181
- package/test/status-sections.test.ts +0 -243
- package/test/status.test.ts +0 -209
- package/test/store.test.ts +0 -192
- package/test/surface-repaint-cost.test.ts +0 -520
- package/test/tool-design-chain.test.ts +0 -483
- package/test/tool-design-cost.test.ts +0 -473
- package/test/tool-design-rows.test.ts +0 -359
- package/test/tool-designs.test.ts +0 -745
- package/test/transform-pipeline.test.ts +0 -465
- package/test/transforms-bus.test.ts +0 -233
- package/test/verifier-width-regressions.test.ts +0 -164
- package/tsconfig.json +0 -18
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, beforeEach, test } from "node:test";
|
|
3
|
-
import { bridgeApi } from "../src/api.ts";
|
|
4
|
-
import { resetNoticesForTests, snapshotNotices } from "../src/notices.ts";
|
|
5
|
-
import {
|
|
6
|
-
KEYMAP_RESOLVER_KEY,
|
|
7
|
-
PLUGIN_SURFACE_KEY,
|
|
8
|
-
registerCommands,
|
|
9
|
-
registerKeymap,
|
|
10
|
-
resolveKeymap,
|
|
11
|
-
} from "../src/plugin-surface.ts";
|
|
12
|
-
|
|
13
|
-
// The surface a plugin writes and the host reads: what a registration publishes, what the
|
|
14
|
-
// disposer removes, which ids and labels are refused, and the key resolution that keeps ONE
|
|
15
|
-
// implementation of environment-then-saved-then-default.
|
|
16
|
-
|
|
17
|
-
interface Surface {
|
|
18
|
-
schema?: unknown;
|
|
19
|
-
keymaps?: () => unknown;
|
|
20
|
-
commands?: () => unknown;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const STATE_KEY = Symbol.for("gentle-shell-bridge/plugin-surface/v1.state");
|
|
24
|
-
|
|
25
|
-
/** The published surface, read the way the host reads it. */
|
|
26
|
-
function surface(): Surface {
|
|
27
|
-
return (globalThis as Record<symbol, unknown>)[PLUGIN_SURFACE_KEY] as Surface;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function readKeymaps(): Record<string, unknown>[] {
|
|
31
|
-
return (surface().keymaps?.() ?? []) as Record<string, unknown>[];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function readCommands(): Record<string, unknown>[] {
|
|
35
|
-
return (surface().commands?.() ?? []) as Record<string, unknown>[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
// The surface object is published once per process; the state behind it is what a test resets.
|
|
40
|
-
delete (globalThis as Record<symbol, unknown>)[STATE_KEY];
|
|
41
|
-
delete (globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY];
|
|
42
|
-
resetNoticesForTests();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
afterEach(() => {
|
|
46
|
-
delete (globalThis as Record<symbol, unknown>)[STATE_KEY];
|
|
47
|
-
delete (globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY];
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("a registration reaches the published surface, and the disposer removes exactly that one", () => {
|
|
51
|
-
const dispose = registerKeymap({ id: "panel:open", label: "Open panel", defaultKey: "alt+p" });
|
|
52
|
-
assert.deepEqual(readKeymaps(), [{ id: "panel:open", label: "Open panel", defaultKey: "alt+p" }]);
|
|
53
|
-
dispose();
|
|
54
|
-
assert.deepEqual(readKeymaps(), []);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("the disposer of a replaced registration never deletes its successor", () => {
|
|
58
|
-
const first = registerKeymap({ id: "panel:open", label: "Open panel", defaultKey: "alt+p" });
|
|
59
|
-
registerKeymap({ id: "panel:open", label: "Open the panel", defaultKey: "ctrl+p" });
|
|
60
|
-
first();
|
|
61
|
-
assert.deepEqual(readKeymaps(), [{ id: "panel:open", label: "Open the panel", defaultKey: "ctrl+p" }]);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("a duplicate id replaces in place and is reported, never silently taken over", () => {
|
|
65
|
-
registerKeymap({ id: "panel:open", label: "Open panel" });
|
|
66
|
-
registerKeymap({ id: "panel:open", label: "Open the panel" });
|
|
67
|
-
assert.deepEqual(readKeymaps().map((entry) => entry.label), ["Open the panel"]);
|
|
68
|
-
assert.ok(snapshotNotices().entries.some((entry) => entry.message.includes('keymap "panel:open" was registered twice')));
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("a command: id is refused with the reason, so a plugin cannot claim a palette binding", () => {
|
|
72
|
-
assert.throws(
|
|
73
|
-
() => registerKeymap({ id: "command:gentle:changes", label: "Fake" }),
|
|
74
|
-
/reserved.*command:/u,
|
|
75
|
-
);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("an empty id, label or an optional field of the wrong type is refused before anything is published", () => {
|
|
79
|
-
assert.throws(() => registerKeymap({ id: " ", label: "Open panel" }), /id must be a non-empty string/u);
|
|
80
|
-
assert.throws(() => registerKeymap({ id: "panel:open", label: "" }), /label must be a non-empty string/u);
|
|
81
|
-
assert.throws(
|
|
82
|
-
() => registerKeymap({ id: "panel:open", label: "Open panel", defaultKey: 7 as unknown as string }),
|
|
83
|
-
/defaultKey must be a non-empty string/u,
|
|
84
|
-
);
|
|
85
|
-
assert.deepEqual(readKeymaps(), []);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("the surface answers with a copy: what the host holds cannot change what the bridge owns", () => {
|
|
89
|
-
registerKeymap({ id: "panel:open", label: "Open panel" });
|
|
90
|
-
const held = readKeymaps();
|
|
91
|
-
held.push({ id: "intruder", label: "Intruder" });
|
|
92
|
-
assert.deepEqual(readKeymaps().map((entry) => entry.id), ["panel:open"]);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("registerCommands takes one item or many, joins the section it names, and disposes exactly its own", () => {
|
|
96
|
-
const disposeOne = registerCommands({ id: "panel:open", label: "Open panel", command: "panel:open", section: "Session" });
|
|
97
|
-
const disposeMany = registerCommands([
|
|
98
|
-
{ id: "panel:close", label: "Close panel", command: "panel:close" },
|
|
99
|
-
{ id: "panel:focus", label: "Focus panel", command: "panel:focus", section: "Panel" },
|
|
100
|
-
]);
|
|
101
|
-
assert.deepEqual(readCommands().map((entry) => entry.id), ["panel:open", "panel:close", "panel:focus"]);
|
|
102
|
-
assert.equal(readCommands()[0]?.section, "Session");
|
|
103
|
-
assert.equal(readCommands()[1]?.section, undefined);
|
|
104
|
-
disposeOne();
|
|
105
|
-
assert.deepEqual(readCommands().map((entry) => entry.id), ["panel:close", "panel:focus"]);
|
|
106
|
-
disposeMany();
|
|
107
|
-
assert.deepEqual(readCommands(), []);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("the same id twice in one call is refused whole, so a batch never lands half-registered", () => {
|
|
111
|
-
assert.throws(
|
|
112
|
-
() => registerCommands([
|
|
113
|
-
{ id: "panel:close", label: "Close panel", command: "panel:close" },
|
|
114
|
-
{ id: "panel:close", label: "Close panel again", command: "panel:close" },
|
|
115
|
-
]),
|
|
116
|
-
/appears twice in one registration/u,
|
|
117
|
-
);
|
|
118
|
-
assert.deepEqual(readCommands(), []);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("a second call with the same id replaces in place and is reported, never a stack", () => {
|
|
122
|
-
const first = registerCommands({ id: "panel:open", label: "Open panel", command: "panel:open" });
|
|
123
|
-
registerCommands({ id: "panel:open", label: "Open the panel", command: "panel:open" });
|
|
124
|
-
assert.deepEqual(readCommands().map((entry) => entry.label), ["Open the panel"]);
|
|
125
|
-
first();
|
|
126
|
-
assert.deepEqual(readCommands().map((entry) => entry.label), ["Open the panel"]);
|
|
127
|
-
assert.ok(snapshotNotices().entries.some((entry) => entry.message.includes('palette item "panel:open" was registered twice')));
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("the published API object carries the three members and stays frozen", () => {
|
|
131
|
-
const api = bridgeApi();
|
|
132
|
-
assert.ok(api, "the API is published");
|
|
133
|
-
assert.equal(typeof api.registerKeymap, "function");
|
|
134
|
-
assert.equal(typeof api.registerCommands, "function");
|
|
135
|
-
assert.equal(typeof api.resolveKeymap, "function");
|
|
136
|
-
assert.ok(Object.isFrozen(api));
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("resolveKeymap says why it cannot answer when no shell publishes a resolver", () => {
|
|
140
|
-
const resolution = resolveKeymap("panel:open");
|
|
141
|
-
assert.equal(resolution.key, undefined);
|
|
142
|
-
assert.match(resolution.unavailable ?? "", /gentle-pi\/keymaps\/v1/u);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("resolveKeymap answers with the shell's key, and reports a throwing shell instead of throwing", () => {
|
|
146
|
-
(globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY] = { schema: 1, effectiveKey: () => "ctrl+p" };
|
|
147
|
-
assert.deepEqual(resolveKeymap("panel:open"), { key: "ctrl+p" });
|
|
148
|
-
(globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY] = { schema: 1, effectiveKey: () => { throw new Error("boom"); } };
|
|
149
|
-
assert.match(resolveKeymap("panel:open").unavailable ?? "", /boom/u);
|
|
150
|
-
(globalThis as Record<symbol, unknown>)[KEYMAP_RESOLVER_KEY] = { schema: 2, effectiveKey: () => "ctrl+p" };
|
|
151
|
-
assert.match(resolveKeymap("panel:open").unavailable ?? "", /does not publish its keymap resolver/u);
|
|
152
|
-
});
|
package/test/presence.test.ts
DELETED
|
@@ -1,459 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* T12/T13 — the RUNTIME presence gate for the required host package (S9).
|
|
3
|
-
*
|
|
4
|
-
* `gentle-pi` is the package that owns `/gentle:customize`, the surface this bridge feeds. Its
|
|
5
|
-
* presence is never read from disk: the sanctioned check is `pi.getCommands()` including the
|
|
6
|
-
* `gentle:customize` command, the same precedent gentle-shell itself uses
|
|
7
|
-
* (`extensions/gentle-design.ts:137`, `extensions/gentle-guide.ts:62`). All extensions are loaded
|
|
8
|
-
* before `session_start`, so that event is the moment the answer is real.
|
|
9
|
-
*
|
|
10
|
-
* What is asserted here is only what a user or a plugin can observe: whether a plugin's row reaches
|
|
11
|
-
* the host's menu, whether the ONE tool-design resolver was registered, whether the sidebar widget
|
|
12
|
-
* factory and the footer status surface were handed out, exactly which `ctx.ui.notify` messages
|
|
13
|
-
* appeared, and the `/bridge-status` lines. No internal field is read.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import assert from "node:assert/strict";
|
|
17
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
18
|
-
import { tmpdir } from "node:os";
|
|
19
|
-
import { join } from "node:path";
|
|
20
|
-
import test from "node:test";
|
|
21
|
-
|
|
22
|
-
import { bridgeApi } from "../src/api.ts";
|
|
23
|
-
import { registerRows, resetBusForTests } from "../src/bus.ts";
|
|
24
|
-
import { resetCardStyleRowStateForTests } from "../src/card-style-rows.ts";
|
|
25
|
-
import { resetCardStyleTransformForTests } from "../src/card-style-transform.ts";
|
|
26
|
-
import { disableCardStyleTransform, resetCardStyleWiringForTests } from "../src/card-style-wiring.ts";
|
|
27
|
-
import { getCaptureDiagnostics, resetCaptureStateForTests } from "../src/capture.ts";
|
|
28
|
-
import gentleShellBridge, {
|
|
29
|
-
STATUS_COMMAND,
|
|
30
|
-
type BridgeCommandEntry,
|
|
31
|
-
type BridgeSessionHandler,
|
|
32
|
-
type BridgeExtensionApi,
|
|
33
|
-
} from "../src/extension.ts";
|
|
34
|
-
import { resetHookStateForTests } from "../src/hook.ts";
|
|
35
|
-
import { resetNoticesForTests } from "../src/notices.ts";
|
|
36
|
-
import { getPluginLineReport, resetPluginLinesForTests } from "../src/plugin-lines.ts";
|
|
37
|
-
import {
|
|
38
|
-
GENTLE_PI_COMMAND,
|
|
39
|
-
checkGentlePi,
|
|
40
|
-
getGentlePiPresence,
|
|
41
|
-
hasGentlePi,
|
|
42
|
-
resetPresenceForTests,
|
|
43
|
-
} from "../src/presence.ts";
|
|
44
|
-
import { resetRepaintForTests } from "../src/repaint.ts";
|
|
45
|
-
import { resetRowBuildStateForTests } from "../src/rows.ts";
|
|
46
|
-
import { disableSidebarBackend, resetSidebarStateForTests } from "../src/sidebar.ts";
|
|
47
|
-
import { resetSectionFailuresForTests } from "../src/sidebar-render.ts";
|
|
48
|
-
import { collectStatus, formatStatus } from "../src/status.ts";
|
|
49
|
-
import { resetStoreStateForTests } from "../src/store.ts";
|
|
50
|
-
import { resetToolDesignRowStateForTests } from "../src/tool-design-rows.ts";
|
|
51
|
-
import { registerToolDesign, resetToolDesignsForTests } from "../src/tool-designs.ts";
|
|
52
|
-
import { resetToolDesignWiringForTests } from "../src/tool-design-wiring.ts";
|
|
53
|
-
import { createFakeHost } from "./fake-host.ts";
|
|
54
|
-
|
|
55
|
-
let configHome: string;
|
|
56
|
-
let previousConfigHome: string | undefined;
|
|
57
|
-
|
|
58
|
-
test.before(() => {
|
|
59
|
-
configHome = mkdtempSync(join(tmpdir(), "gsb-presence-"));
|
|
60
|
-
previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
61
|
-
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test.after(() => {
|
|
65
|
-
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
66
|
-
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
67
|
-
rmSync(configHome, { recursive: true, force: true });
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test.beforeEach(() => {
|
|
71
|
-
resetBusForTests();
|
|
72
|
-
resetCaptureStateForTests();
|
|
73
|
-
resetCardStyleRowStateForTests();
|
|
74
|
-
resetCardStyleTransformForTests();
|
|
75
|
-
resetCardStyleWiringForTests();
|
|
76
|
-
resetHookStateForTests();
|
|
77
|
-
resetNoticesForTests();
|
|
78
|
-
resetPluginLinesForTests();
|
|
79
|
-
resetRepaintForTests();
|
|
80
|
-
resetRowBuildStateForTests();
|
|
81
|
-
resetSectionFailuresForTests();
|
|
82
|
-
resetSidebarStateForTests();
|
|
83
|
-
resetStoreStateForTests();
|
|
84
|
-
resetToolDesignRowStateForTests();
|
|
85
|
-
resetToolDesignsForTests();
|
|
86
|
-
resetToolDesignWiringForTests();
|
|
87
|
-
resetPresenceForTests();
|
|
88
|
-
disableCardStyleTransform();
|
|
89
|
-
disableSidebarBackend();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
// ---------------------------------------------------------------------------------------------
|
|
93
|
-
// The harness: the real entry, a fake Pi whose command list can change, and one recording `ctx.ui`
|
|
94
|
-
// ---------------------------------------------------------------------------------------------
|
|
95
|
-
|
|
96
|
-
interface RecordingWorld {
|
|
97
|
-
/** The API object the entry was loaded against; `getCommands` disappears for the absent case. */
|
|
98
|
-
pi: BridgeExtensionApi;
|
|
99
|
-
start: () => void;
|
|
100
|
-
warnings: string[];
|
|
101
|
-
widgets: number;
|
|
102
|
-
statuses: Array<{ key: string; text?: string }>;
|
|
103
|
-
resolvers: unknown[];
|
|
104
|
-
host: ReturnType<typeof createFakeHost>;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Load the real entry against a fake Pi. `getCommands` is a live factory, so a test can model the
|
|
109
|
-
* host "showing up" later; passing `undefined` models a host that exposes no command surface at all.
|
|
110
|
-
*/
|
|
111
|
-
const createWorld = (getCommands: (() => unknown) | undefined, rowLabel?: string): RecordingWorld => {
|
|
112
|
-
const warnings: string[] = [];
|
|
113
|
-
const statuses: Array<{ key: string; text?: string }> = [];
|
|
114
|
-
const handlers: BridgeSessionHandler[] = [];
|
|
115
|
-
const resolvers: unknown[] = [];
|
|
116
|
-
let widgets = 0;
|
|
117
|
-
const host = createFakeHost();
|
|
118
|
-
const ui = host.ui as Record<string, unknown>;
|
|
119
|
-
ui.notify = (message: string) => warnings.push(message);
|
|
120
|
-
ui.setWidget = (_key: string, _factory: (tui: unknown, theme: unknown) => unknown) => {
|
|
121
|
-
widgets += 1;
|
|
122
|
-
};
|
|
123
|
-
ui.setStatus = (key: string, text?: string) => statuses.push({ key, text });
|
|
124
|
-
|
|
125
|
-
const pi: BridgeExtensionApi = {
|
|
126
|
-
on(event, handler) {
|
|
127
|
-
if (event === "session_start") handlers.push(handler);
|
|
128
|
-
},
|
|
129
|
-
registerCommand() {},
|
|
130
|
-
registerToolRenderer(resolver) {
|
|
131
|
-
resolvers.push(resolver);
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
if (getCommands !== undefined) {
|
|
135
|
-
// The host is a TRUST BOUNDARY: the cast models a host whose answer violates Pi's own type
|
|
136
|
-
// (a non-array, an entry with no name, a throwing call), which is exactly what `./presence.ts`
|
|
137
|
-
// re-validates at runtime.
|
|
138
|
-
pi.getCommands = getCommands as () => readonly BridgeCommandEntry[];
|
|
139
|
-
}
|
|
140
|
-
gentleShellBridge(pi);
|
|
141
|
-
|
|
142
|
-
if (rowLabel !== undefined) {
|
|
143
|
-
registerRows({
|
|
144
|
-
id: "presence.probe",
|
|
145
|
-
category: "Banner",
|
|
146
|
-
rows: () => [{ label: rowLabel, action: () => {} }],
|
|
147
|
-
});
|
|
148
|
-
registerToolDesign({
|
|
149
|
-
tool: "bash",
|
|
150
|
-
id: "presence.probe",
|
|
151
|
-
label: "Probe",
|
|
152
|
-
apply: (lines) => [...lines],
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
pi,
|
|
158
|
-
host,
|
|
159
|
-
warnings,
|
|
160
|
-
resolvers,
|
|
161
|
-
get widgets() {
|
|
162
|
-
return widgets;
|
|
163
|
-
},
|
|
164
|
-
statuses,
|
|
165
|
-
start: () => {
|
|
166
|
-
for (const handler of handlers) handler({}, { hasUI: true, ui });
|
|
167
|
-
},
|
|
168
|
-
};
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
/** `/bridge-status` through the command the entry registered, exactly as a user reads it. */
|
|
172
|
-
const reportThrough = (): string[] => {
|
|
173
|
-
const printed: string[] = [];
|
|
174
|
-
// The entry registers exactly one command; a fresh load per report keeps this independent of the
|
|
175
|
-
// world's handler bookkeeping.
|
|
176
|
-
const commands: Array<{ name: string; handler: (args: string, ctx: unknown) => unknown }> = [];
|
|
177
|
-
const pi = {
|
|
178
|
-
on() {},
|
|
179
|
-
registerCommand(name: string, command: { handler: (args: string, ctx: unknown) => unknown }) {
|
|
180
|
-
commands.push({ name, handler: command.handler });
|
|
181
|
-
},
|
|
182
|
-
} as unknown as BridgeExtensionApi;
|
|
183
|
-
gentleShellBridge(pi);
|
|
184
|
-
const status = commands.find((command) => command.name === STATUS_COMMAND);
|
|
185
|
-
assert.ok(status !== undefined, "the entry registers /bridge-status");
|
|
186
|
-
status.handler("", { ui: { notify: (message: string) => printed.push(message) } });
|
|
187
|
-
return printed.join("\n").split("\n");
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
// ---------------------------------------------------------------------------------------------
|
|
191
|
-
// T13 — present: the bridge arms exactly as it does today
|
|
192
|
-
// ---------------------------------------------------------------------------------------------
|
|
193
|
-
|
|
194
|
-
test("present: session_start arms every surface exactly as today", () => {
|
|
195
|
-
const world = createWorld(() => [{ name: GENTLE_PI_COMMAND }], "Probe row");
|
|
196
|
-
|
|
197
|
-
world.start();
|
|
198
|
-
|
|
199
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 1, "the row bridge is armed");
|
|
200
|
-
assert.equal(world.resolvers.length, 1, "the ONE tool-design resolver is registered");
|
|
201
|
-
assert.ok(world.widgets >= 1, "the sidebar widget factory is handed to the host");
|
|
202
|
-
assert.equal(
|
|
203
|
-
getPluginLineReport().availability,
|
|
204
|
-
"installed",
|
|
205
|
-
"the footer status surface is installed, so a plugin's INTEGRATIONS line is painted",
|
|
206
|
-
);
|
|
207
|
-
assert.deepEqual(world.warnings, [], "a present host needs no warning");
|
|
208
|
-
|
|
209
|
-
world.host.buildMenu();
|
|
210
|
-
assert.ok(world.host.rendered().includes("Probe row"), "a plugin's row reaches the host's menu");
|
|
211
|
-
|
|
212
|
-
const lines = reportThrough();
|
|
213
|
-
assert.equal(lines[0], "gentle-shell-bridge status");
|
|
214
|
-
assert.equal(lines[1], "gentle-pi: present");
|
|
215
|
-
assert.ok(lines.some((line) => line.startsWith("providers: 1")), lines.join("\n"));
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("present: a second session_start changes nothing (arming stays idempotent)", () => {
|
|
219
|
-
const world = createWorld(() => [{ name: GENTLE_PI_COMMAND }], "Probe row");
|
|
220
|
-
|
|
221
|
-
world.start();
|
|
222
|
-
world.start();
|
|
223
|
-
|
|
224
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 1);
|
|
225
|
-
assert.equal(world.resolvers.length, 1, "one resolver, however many times the event fires");
|
|
226
|
-
assert.equal(
|
|
227
|
-
getPluginLineReport().availability,
|
|
228
|
-
"installed",
|
|
229
|
-
"a second event re-installs the same surface, it does not duplicate it",
|
|
230
|
-
);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
// ---------------------------------------------------------------------------------------------
|
|
234
|
-
// T13 — absent: nothing armed, one warning, one honest status report
|
|
235
|
-
// ---------------------------------------------------------------------------------------------
|
|
236
|
-
|
|
237
|
-
test("absent: nothing is armed, one warning per absence, and the report says missing", () => {
|
|
238
|
-
const world = createWorld(undefined, "Probe row");
|
|
239
|
-
|
|
240
|
-
world.start();
|
|
241
|
-
world.start();
|
|
242
|
-
world.start();
|
|
243
|
-
|
|
244
|
-
assert.equal(world.warnings.length, 1, "exactly one warning for the same absence");
|
|
245
|
-
assert.match(world.warnings[0], /gentle-pi/u, "the warning names the missing package");
|
|
246
|
-
assert.match(world.warnings[0], /missing/u);
|
|
247
|
-
|
|
248
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 0, "no wrapper is armed");
|
|
249
|
-
assert.equal(world.resolvers.length, 0, "no tool-design resolver is registered");
|
|
250
|
-
assert.equal(world.widgets, 0, "no sidebar widget factory is handed out");
|
|
251
|
-
assert.deepEqual(world.statuses, [], "no footer line: the bridge never claims readiness");
|
|
252
|
-
assert.equal(
|
|
253
|
-
getPluginLineReport().availability,
|
|
254
|
-
"not-armed",
|
|
255
|
-
"the INTEGRATIONS surface is never installed, so no plugin is told 'ready'",
|
|
256
|
-
);
|
|
257
|
-
|
|
258
|
-
world.host.buildMenu();
|
|
259
|
-
assert.equal(
|
|
260
|
-
world.host.rendered().filter((label) => label === "Probe row").length,
|
|
261
|
-
0,
|
|
262
|
-
"a plugin's row is recorded but never reaches the menu",
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
const lines = reportThrough();
|
|
266
|
-
assert.deepEqual(lines.slice(0, 3), [
|
|
267
|
-
"gentle-shell-bridge status",
|
|
268
|
-
"gentle-pi: missing — the host has no getCommands()",
|
|
269
|
-
"bridge: disabled — gentle-pi is required; no rows, sections, card styles or tool designs are armed",
|
|
270
|
-
]);
|
|
271
|
-
for (const prefix of [
|
|
272
|
-
"providers:",
|
|
273
|
-
"sections:",
|
|
274
|
-
"sidebar:",
|
|
275
|
-
"card styles:",
|
|
276
|
-
"tool designs:",
|
|
277
|
-
"transforms:",
|
|
278
|
-
"plugin lines:",
|
|
279
|
-
]) {
|
|
280
|
-
assert.ok(
|
|
281
|
-
!lines.some((line) => line.startsWith(prefix)),
|
|
282
|
-
`no ${prefix} block while disabled: ${lines.join("\n")}`,
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
test("absent: a plugin's registrations still succeed and never throw", () => {
|
|
288
|
-
const world = createWorld(undefined);
|
|
289
|
-
|
|
290
|
-
assert.doesNotThrow(() =>
|
|
291
|
-
bridgeApi()?.registerRows({
|
|
292
|
-
id: "presence.offline",
|
|
293
|
-
category: "Banner",
|
|
294
|
-
rows: () => [{ label: "Offline", action: () => {} }],
|
|
295
|
-
}),
|
|
296
|
-
);
|
|
297
|
-
const api = bridgeApi();
|
|
298
|
-
assert.ok(api !== undefined, "the API object is still published while disabled");
|
|
299
|
-
assert.equal(typeof api.registerToolDesign, "function");
|
|
300
|
-
assert.doesNotThrow(() =>
|
|
301
|
-
api.registerToolDesign?.({
|
|
302
|
-
tool: "bash",
|
|
303
|
-
id: "presence.offline",
|
|
304
|
-
label: "Offline",
|
|
305
|
-
apply: (lines) => [...lines],
|
|
306
|
-
}),
|
|
307
|
-
);
|
|
308
|
-
|
|
309
|
-
world.start();
|
|
310
|
-
world.host.buildMenu();
|
|
311
|
-
|
|
312
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 0);
|
|
313
|
-
assert.equal(world.resolvers.length, 0);
|
|
314
|
-
assert.equal(world.host.rendered().includes("Offline"), false);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
// ---------------------------------------------------------------------------------------------
|
|
318
|
-
// T13 — the check is a guard, not a crash
|
|
319
|
-
// ---------------------------------------------------------------------------------------------
|
|
320
|
-
|
|
321
|
-
test("the predicate is true only for a command list that names gentle:customize", () => {
|
|
322
|
-
assert.equal(GENTLE_PI_COMMAND, "gentle:customize");
|
|
323
|
-
assert.equal(hasGentlePi({ getCommands: () => [{ name: GENTLE_PI_COMMAND }] }), true);
|
|
324
|
-
assert.equal(
|
|
325
|
-
hasGentlePi({ getCommands: () => [{ name: "other:command" }, { name: GENTLE_PI_COMMAND }] }),
|
|
326
|
-
true,
|
|
327
|
-
"the list is searched, not required to be a single entry",
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
for (const [what, host] of [
|
|
331
|
-
["no getCommands", {}],
|
|
332
|
-
["an undefined host", undefined],
|
|
333
|
-
["a null host", null],
|
|
334
|
-
["a non-object host", "gentle-pi"],
|
|
335
|
-
["a non-function getCommands", { getCommands: "gentle:customize" }],
|
|
336
|
-
["a throwing getCommands", { getCommands: () => { throw new Error("host exploded"); } }],
|
|
337
|
-
["a non-array command list", { getCommands: () => "gentle:customize" }],
|
|
338
|
-
["an entry without a name", { getCommands: () => [{ label: GENTLE_PI_COMMAND }] }],
|
|
339
|
-
["a non-object entry", { getCommands: () => ["gentle:customize"] }],
|
|
340
|
-
["a non-string name", { getCommands: () => [{ name: 42 }] }],
|
|
341
|
-
["a near-miss name", { getCommands: () => [{ name: "gentle:customize " }] }],
|
|
342
|
-
["an empty list", { getCommands: () => [] }],
|
|
343
|
-
] as const) {
|
|
344
|
-
assert.equal(hasGentlePi(host), false, `${what} means missing`);
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
test("a throwing getCommands on session_start is missing, warned and never armed", () => {
|
|
349
|
-
const world = createWorld(() => {
|
|
350
|
-
throw new Error("host exploded");
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
assert.doesNotThrow(() => world.start());
|
|
354
|
-
|
|
355
|
-
assert.equal(world.warnings.length, 1);
|
|
356
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 0);
|
|
357
|
-
assert.equal(world.resolvers.length, 0);
|
|
358
|
-
assert.equal(
|
|
359
|
-
reportThrough()[1],
|
|
360
|
-
"gentle-pi: missing — getCommands() threw: host exploded",
|
|
361
|
-
);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
// ---------------------------------------------------------------------------------------------
|
|
365
|
-
// T13 — a late host: missing, then present
|
|
366
|
-
// ---------------------------------------------------------------------------------------------
|
|
367
|
-
|
|
368
|
-
test("a late session_start after gentle-pi appears enables the bridge exactly once", () => {
|
|
369
|
-
let commands: unknown = [];
|
|
370
|
-
const world = createWorld(() => commands, "Probe row");
|
|
371
|
-
|
|
372
|
-
world.start();
|
|
373
|
-
world.start();
|
|
374
|
-
assert.equal(world.warnings.length, 1, "one warning for the one absence");
|
|
375
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 0);
|
|
376
|
-
|
|
377
|
-
// gentle-pi shows up: the next session_start is the re-check.
|
|
378
|
-
commands = [{ name: GENTLE_PI_COMMAND }];
|
|
379
|
-
world.start();
|
|
380
|
-
|
|
381
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 1, "the row bridge arms on the re-check");
|
|
382
|
-
assert.equal(world.resolvers.length, 1, "the resolver registers on the re-check");
|
|
383
|
-
assert.ok(world.widgets >= 1, "the sidebar widget factory is installed on the re-check");
|
|
384
|
-
assert.equal(getPluginLineReport().availability, "installed");
|
|
385
|
-
assert.equal(world.warnings.length, 1, "enabling does not repeat the warning");
|
|
386
|
-
world.host.buildMenu();
|
|
387
|
-
assert.ok(world.host.rendered().includes("Probe row"));
|
|
388
|
-
|
|
389
|
-
const present = reportThrough();
|
|
390
|
-
assert.equal(present[1], "gentle-pi: present");
|
|
391
|
-
|
|
392
|
-
// Exactly once: arming is idempotent and is not re-run as a second resolver or a second wrapper.
|
|
393
|
-
world.start();
|
|
394
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 1);
|
|
395
|
-
assert.equal(world.resolvers.length, 1);
|
|
396
|
-
assert.equal(world.warnings.length, 1);
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
test("present then missing again warns again for the new absence", () => {
|
|
400
|
-
let commands: unknown = [{ name: GENTLE_PI_COMMAND }];
|
|
401
|
-
const world = createWorld(() => commands, "Probe row");
|
|
402
|
-
|
|
403
|
-
world.start();
|
|
404
|
-
assert.deepEqual(world.warnings, []);
|
|
405
|
-
|
|
406
|
-
// The host vanishes: the next re-check must report it and warn again, exactly once.
|
|
407
|
-
commands = [{ name: "other:command" }];
|
|
408
|
-
world.start();
|
|
409
|
-
world.start();
|
|
410
|
-
|
|
411
|
-
assert.equal(world.warnings.length, 1, "one warning for the second absence");
|
|
412
|
-
assert.match(world.warnings[0], /gentle-pi/u);
|
|
413
|
-
|
|
414
|
-
const lines = reportThrough();
|
|
415
|
-
assert.equal(lines[1], "gentle-pi: missing — getCommands() lists no gentle:customize");
|
|
416
|
-
assert.ok(lines.some((line) => line.startsWith("bridge: disabled — gentle-pi is required")));
|
|
417
|
-
for (const prefix of ["card styles:", "tool designs:", "plugin lines:", "sidebar:"]) {
|
|
418
|
-
assert.ok(
|
|
419
|
-
!lines.some((line) => line.startsWith(prefix)),
|
|
420
|
-
`a vanished host must not report ${prefix} work: ${lines.join("\n")}`,
|
|
421
|
-
);
|
|
422
|
-
}
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
// ---------------------------------------------------------------------------------------------
|
|
426
|
-
// The presence record: process-global, absent until the first check
|
|
427
|
-
// ---------------------------------------------------------------------------------------------
|
|
428
|
-
|
|
429
|
-
test("collectStatus carries no presence field until a check has run", () => {
|
|
430
|
-
assert.equal(getGentlePiPresence(), undefined);
|
|
431
|
-
assert.equal(collectStatus().gentlePi, undefined);
|
|
432
|
-
assert.deepEqual(formatStatus(collectStatus()), [
|
|
433
|
-
"gentle-shell-bridge status",
|
|
434
|
-
"providers: none",
|
|
435
|
-
"backend: none",
|
|
436
|
-
"last capture: none",
|
|
437
|
-
"last failures: none",
|
|
438
|
-
"store problems: none",
|
|
439
|
-
"duplicate settings: no build yet",
|
|
440
|
-
]);
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
test("the presence record is shared by every copy in the process", () => {
|
|
444
|
-
const first = checkGentlePi({ getCommands: () => [{ name: GENTLE_PI_COMMAND }] });
|
|
445
|
-
assert.deepEqual(first, { present: true, warnNow: false });
|
|
446
|
-
assert.deepEqual(getGentlePiPresence(), { present: true });
|
|
447
|
-
assert.deepEqual(collectStatus().gentlePi, { present: true });
|
|
448
|
-
|
|
449
|
-
// The same process-global slot the next copy reads: no second warning for the same absence.
|
|
450
|
-
const second = checkGentlePi({});
|
|
451
|
-
assert.equal(second.present, false);
|
|
452
|
-
assert.equal(second.warnNow, true, "the absence transition owes the one warning");
|
|
453
|
-
const third = checkGentlePi({});
|
|
454
|
-
assert.equal(third.warnNow, false, "the same absence is not warned twice");
|
|
455
|
-
assert.deepEqual(getGentlePiPresence(), {
|
|
456
|
-
present: false,
|
|
457
|
-
reason: "the host has no getCommands()",
|
|
458
|
-
});
|
|
459
|
-
});
|