@desplega.ai/agent-swarm 1.78.1 → 1.79.1
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/README.md +1 -0
- package/openapi.json +1335 -236
- package/package.json +4 -4
- package/plugin/skills/artifacts/SKILL.md +151 -0
- package/plugin/skills/artifacts/examples/static-report.sh +1 -1
- package/plugin/skills/kv-storage/SKILL.md +168 -0
- package/plugin/skills/pages/SKILL.md +423 -0
- package/src/artifact-sdk/browser-sdk.ts +396 -19
- package/src/be/db.ts +548 -0
- package/src/be/migrations/059_pages.sql +34 -0
- package/src/be/migrations/060_page_versions.sql +19 -0
- package/src/be/migrations/061_kv_store.sql +34 -0
- package/src/be/migrations/062_pages_view_count.sql +9 -0
- package/src/commands/artifact.ts +17 -11
- package/src/commands/provider-credentials.ts +1 -1
- package/src/http/index.ts +9 -1
- package/src/http/kv.ts +658 -0
- package/src/http/page-proxy.ts +213 -0
- package/src/http/pages-public.ts +507 -0
- package/src/http/pages.ts +608 -0
- package/src/http/status.ts +1 -1
- package/src/http/utils.ts +68 -5
- package/src/pages/version.ts +44 -0
- package/src/prompts/session-templates.ts +51 -0
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/providers/pi-mono-extension.ts +1 -1
- package/src/server.ts +29 -1
- package/src/tasks/context-key.ts +28 -0
- package/src/telemetry.ts +65 -1
- package/src/tests/artifact-commands.test.ts +92 -0
- package/src/tests/artifact-sdk.test.ts +80 -74
- package/src/tests/context-key.test.ts +17 -0
- package/src/tests/create-page-tool.test.ts +197 -0
- package/src/tests/fixtures/sample-json-page.json +52 -0
- package/src/tests/kv-http.test.ts +331 -0
- package/src/tests/kv-namespace-resolution.test.ts +172 -0
- package/src/tests/kv-page-proxy.test.ts +212 -0
- package/src/tests/kv-storage.test.ts +227 -0
- package/src/tests/kv-tool.test.ts +217 -0
- package/src/tests/launch-password-rejection.test.ts +139 -0
- package/src/tests/page-proxy-authed.test.ts +146 -0
- package/src/tests/page-proxy.test.ts +270 -0
- package/src/tests/page-session.test.ts +169 -0
- package/src/tests/pages-actions-endpoint.test.ts +102 -0
- package/src/tests/pages-authed-mode.test.ts +211 -0
- package/src/tests/pages-http.test.ts +193 -0
- package/src/tests/pages-list-endpoint.test.ts +149 -0
- package/src/tests/pages-password-hash.test.ts +57 -0
- package/src/tests/pages-password-mode.test.ts +265 -0
- package/src/tests/pages-public-authed-401.test.ts +102 -0
- package/src/tests/pages-public-html.test.ts +151 -0
- package/src/tests/pages-public-json-redirect.test.ts +86 -0
- package/src/tests/pages-storage.test.ts +196 -0
- package/src/tests/pages-versioning.test.ts +231 -0
- package/src/tests/pages-view-count.test.ts +220 -0
- package/src/tests/prompt-template-session.test.ts +3 -2
- package/src/tests/skill-update-scope.test.ts +165 -0
- package/src/tests/swarm-diff.test.ts +303 -0
- package/src/tests/telemetry-init.test.ts +149 -0
- package/src/tests/workflow-wait-event.test.ts +4 -7
- package/src/tools/create-page.ts +263 -0
- package/src/tools/kv/index.ts +5 -0
- package/src/tools/kv/kv-delete.ts +89 -0
- package/src/tools/kv/kv-get.ts +64 -0
- package/src/tools/kv/kv-incr.ts +116 -0
- package/src/tools/kv/kv-list.ts +81 -0
- package/src/tools/kv/kv-set.ts +194 -0
- package/src/tools/kv/resolve-namespace.ts +58 -0
- package/src/tools/skills/skill-update.ts +26 -0
- package/src/tools/tool-config.ts +10 -0
- package/src/types.ts +107 -0
- package/src/utils/internal-ai/complete-structured.ts +2 -2
- package/src/utils/internal-ai/credentials.ts +3 -3
- package/src/utils/page-session.ts +254 -0
- package/plugin/skills/artifacts/skill.md +0 -70
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the `<swarm-diff>` custom element shipped inside
|
|
3
|
+
* `SWARM_UI_JS`. Existing browser-side tests in this repo are pure string-
|
|
4
|
+
* content checks against the SDK constant; we don't have happy-dom or jsdom
|
|
5
|
+
* in deps. To still verify the element produces sane DOM output, we evaluate
|
|
6
|
+
* the JS in a hand-rolled stub `window` / `HTMLElement` / `customElements`
|
|
7
|
+
* scaffold — minimal but enough to assert structural properties (row counts,
|
|
8
|
+
* anchor ids, severity badges) without dragging in a real DOM lib.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { SWARM_UI_JS } from "../artifact-sdk/browser-sdk";
|
|
12
|
+
|
|
13
|
+
const EXAMPLE_HUNK = {
|
|
14
|
+
hunks: [
|
|
15
|
+
{
|
|
16
|
+
old_start: 10,
|
|
17
|
+
old_lines: 3,
|
|
18
|
+
new_start: 10,
|
|
19
|
+
new_lines: 4,
|
|
20
|
+
lines: [
|
|
21
|
+
{ type: "context", text: " const x = 1;" },
|
|
22
|
+
{ type: "del", text: "- console.log(x);" },
|
|
23
|
+
{ type: "add", text: "+ logger.info({ x });" },
|
|
24
|
+
{ type: "add", text: "+ return x;" },
|
|
25
|
+
],
|
|
26
|
+
annotations: [{ line: 12, severity: "warn", text: "Avoid raw console.log" }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type StubInstance = {
|
|
32
|
+
innerHTML: string;
|
|
33
|
+
textContent: string;
|
|
34
|
+
isConnected: boolean;
|
|
35
|
+
connectedCallback?: () => void;
|
|
36
|
+
dispatchEvent: (evt: unknown) => boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a minimal stub window with just enough surface area to load
|
|
41
|
+
* SWARM_UI_JS, register the custom element, and exercise the render path.
|
|
42
|
+
*
|
|
43
|
+
* Returns both the element constructor and a microtask-flush helper so
|
|
44
|
+
* callers can simulate the real-browser parse-order race
|
|
45
|
+
* (`connectedCallback` fires → children get appended → microtask drains).
|
|
46
|
+
*/
|
|
47
|
+
function makeRig(): {
|
|
48
|
+
Ctor: new () => StubInstance;
|
|
49
|
+
setText: (el: StubInstance, attrs: Record<string, string>, text: string) => void;
|
|
50
|
+
flushMicrotasks: () => Promise<void>;
|
|
51
|
+
} {
|
|
52
|
+
const registry = new Map<string, new () => StubInstance>();
|
|
53
|
+
|
|
54
|
+
class StubHTMLElement {
|
|
55
|
+
innerHTML = "";
|
|
56
|
+
textContent = "";
|
|
57
|
+
isConnected = true;
|
|
58
|
+
_attrs: Record<string, string> = {};
|
|
59
|
+
getAttribute(name: string): string | null {
|
|
60
|
+
return this._attrs[name] ?? null;
|
|
61
|
+
}
|
|
62
|
+
setAttribute(name: string, value: string): void {
|
|
63
|
+
this._attrs[name] = value;
|
|
64
|
+
}
|
|
65
|
+
connectedCallback?(): void;
|
|
66
|
+
closest(_selector: string): null {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
querySelectorAll(_selector: string): unknown[] {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
dispatchEvent(_evt: unknown): boolean {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const customElements = {
|
|
78
|
+
define(name: string, ctor: new () => StubInstance) {
|
|
79
|
+
registry.set(name, ctor);
|
|
80
|
+
},
|
|
81
|
+
get(name: string) {
|
|
82
|
+
return registry.get(name);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const win = {
|
|
87
|
+
customElements,
|
|
88
|
+
swarmUi: undefined as { renderDiff?: (rootEl: unknown, data: unknown) => void } | undefined,
|
|
89
|
+
HTMLElement: StubHTMLElement,
|
|
90
|
+
CustomEvent: class {
|
|
91
|
+
constructor(
|
|
92
|
+
public type: string,
|
|
93
|
+
public init?: { bubbles?: boolean; detail?: unknown },
|
|
94
|
+
) {}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Provide `document` stubs the jump-list path uses.
|
|
99
|
+
const doc = {
|
|
100
|
+
querySelectorAll: () => [],
|
|
101
|
+
addEventListener: () => {},
|
|
102
|
+
removeEventListener: () => {},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Evaluate SWARM_UI_JS with our stubs in scope. The IIFE inside the
|
|
106
|
+
// constant captures `window`, `customElements`, `document`, `HTMLElement`,
|
|
107
|
+
// `CustomEvent`, and `queueMicrotask` — provide each as a free variable.
|
|
108
|
+
const factory = new Function(
|
|
109
|
+
"window",
|
|
110
|
+
"customElements",
|
|
111
|
+
"document",
|
|
112
|
+
"HTMLElement",
|
|
113
|
+
"CustomEvent",
|
|
114
|
+
"queueMicrotask",
|
|
115
|
+
"console",
|
|
116
|
+
`${SWARM_UI_JS}\nreturn window.customElements.get('swarm-diff');`,
|
|
117
|
+
);
|
|
118
|
+
const Ctor = factory(
|
|
119
|
+
win,
|
|
120
|
+
customElements,
|
|
121
|
+
doc,
|
|
122
|
+
StubHTMLElement,
|
|
123
|
+
win.CustomEvent,
|
|
124
|
+
queueMicrotask,
|
|
125
|
+
console,
|
|
126
|
+
) as (new () => StubInstance) | undefined;
|
|
127
|
+
if (!Ctor) throw new Error("custom element did not register");
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
Ctor,
|
|
131
|
+
setText(el, attrs, text) {
|
|
132
|
+
(el as unknown as { _attrs: Record<string, string> })._attrs = attrs;
|
|
133
|
+
el.textContent = text;
|
|
134
|
+
},
|
|
135
|
+
async flushMicrotasks() {
|
|
136
|
+
// Two yields: one drains the connectedCallback microtask, the next one
|
|
137
|
+
// drains anything queued by the render path itself.
|
|
138
|
+
await Promise.resolve();
|
|
139
|
+
await Promise.resolve();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Convenience wrapper for the existing "happy path" tests: build the rig,
|
|
146
|
+
* pre-set textContent + attrs, fire connectedCallback, flush microtasks,
|
|
147
|
+
* return innerHTML.
|
|
148
|
+
*/
|
|
149
|
+
async function renderViaStub(text: string, attrs: Record<string, string>): Promise<string> {
|
|
150
|
+
const rig = makeRig();
|
|
151
|
+
const el = new rig.Ctor();
|
|
152
|
+
rig.setText(el, attrs, text);
|
|
153
|
+
if (typeof el.connectedCallback === "function") el.connectedCallback();
|
|
154
|
+
await rig.flushMicrotasks();
|
|
155
|
+
return el.innerHTML;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
describe("SWARM_UI_JS", () => {
|
|
159
|
+
test("is a non-empty string", () => {
|
|
160
|
+
expect(typeof SWARM_UI_JS).toBe("string");
|
|
161
|
+
expect(SWARM_UI_JS.length).toBeGreaterThan(500);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("defines swarm-diff + swarm-diff-jumps custom elements", () => {
|
|
165
|
+
expect(SWARM_UI_JS).toContain("customElements.define('swarm-diff'");
|
|
166
|
+
expect(SWARM_UI_JS).toContain("customElements.define('swarm-diff-jumps'");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("exposes window.swarmUi.renderDiff as a programmatic entry point", () => {
|
|
170
|
+
expect(SWARM_UI_JS).toContain("window.swarmUi");
|
|
171
|
+
expect(SWARM_UI_JS).toContain("renderDiff");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("<swarm-diff> render", () => {
|
|
176
|
+
test("constructs and renders without throwing on the example input", async () => {
|
|
177
|
+
const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), {
|
|
178
|
+
file: "src/foo.ts",
|
|
179
|
+
"base-sha": "abc123",
|
|
180
|
+
"head-sha": "def456",
|
|
181
|
+
});
|
|
182
|
+
expect(html.length).toBeGreaterThan(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("renders one <tr> per line in each hunk", async () => {
|
|
186
|
+
const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), { file: "src/foo.ts" });
|
|
187
|
+
// 4 lines in the example hunk → 4 <tr> rows.
|
|
188
|
+
const trMatches = html.match(/<tr\b/g) || [];
|
|
189
|
+
expect(trMatches.length).toBe(4);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("renders file header and SHA range", async () => {
|
|
193
|
+
const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), {
|
|
194
|
+
file: "src/foo.ts",
|
|
195
|
+
"base-sha": "abc123",
|
|
196
|
+
"head-sha": "def456",
|
|
197
|
+
});
|
|
198
|
+
expect(html).toContain("src/foo.ts");
|
|
199
|
+
expect(html).toContain("abc123");
|
|
200
|
+
expect(html).toContain("def456");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("renders deterministic anchor id per hunk", async () => {
|
|
204
|
+
const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), { file: "src/foo.ts" });
|
|
205
|
+
expect(html).toContain('id="swarm-diff-src-foo-ts-10"');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("renders severity annotation badge on annotated line", async () => {
|
|
209
|
+
const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), { file: "src/foo.ts" });
|
|
210
|
+
expect(html).toContain("WARN");
|
|
211
|
+
expect(html).toContain("Avoid raw console.log");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("handles empty/missing JSON body gracefully (no rows, no throw)", async () => {
|
|
215
|
+
const html = await renderViaStub("", { file: "empty.ts" });
|
|
216
|
+
// Should still render an outer container with the file name.
|
|
217
|
+
expect(html).toContain("empty.ts");
|
|
218
|
+
// But no <tr> rows.
|
|
219
|
+
expect(html.match(/<tr\b/g) ?? []).toHaveLength(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("escapes user-controlled text content to prevent injection", async () => {
|
|
223
|
+
const xssHunk = {
|
|
224
|
+
hunks: [
|
|
225
|
+
{
|
|
226
|
+
old_start: 1,
|
|
227
|
+
old_lines: 1,
|
|
228
|
+
new_start: 1,
|
|
229
|
+
new_lines: 1,
|
|
230
|
+
lines: [{ type: "add", text: "<script>alert('xss')</script>" }],
|
|
231
|
+
annotations: [],
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
const html = await renderViaStub(JSON.stringify(xssHunk), { file: "<bad>" });
|
|
236
|
+
expect(html).not.toContain("<script>alert(");
|
|
237
|
+
expect(html).toContain("<script>");
|
|
238
|
+
expect(html).toContain("<bad>");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("<swarm-diff> parse-order regression (Bug #479-1)", () => {
|
|
243
|
+
// Real browsers fire connectedCallback when the parser sees the opening
|
|
244
|
+
// tag, BEFORE the JSON text children are parsed. The element MUST defer
|
|
245
|
+
// its parseHunks/render so it reads textContent AFTER the parser appends
|
|
246
|
+
// the children. Without the queueMicrotask defer in connectedCallback,
|
|
247
|
+
// the element renders an empty header and the JSON stays visible as
|
|
248
|
+
// orphan text — that's the production bug PR #479 shipped initially.
|
|
249
|
+
|
|
250
|
+
test("does NOT render synchronously inside connectedCallback (defer required)", () => {
|
|
251
|
+
const rig = makeRig();
|
|
252
|
+
const el = new rig.Ctor();
|
|
253
|
+
// Simulate the real-browser parse order: connectedCallback fires while
|
|
254
|
+
// textContent is still empty. The element must NOT have rendered yet
|
|
255
|
+
// — if it does, it's reading textContent too early.
|
|
256
|
+
rig.setText(el, { file: "src/foo.ts" }, "");
|
|
257
|
+
if (typeof el.connectedCallback === "function") el.connectedCallback();
|
|
258
|
+
expect(el.innerHTML).toBe("");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("renders correctly when textContent is appended AFTER connectedCallback but BEFORE microtask drain", async () => {
|
|
262
|
+
const rig = makeRig();
|
|
263
|
+
const el = new rig.Ctor();
|
|
264
|
+
// Parse order: connectedCallback fires with empty textContent, children
|
|
265
|
+
// get appended, then microtask drains.
|
|
266
|
+
rig.setText(el, { file: "src/foo.ts" }, "");
|
|
267
|
+
if (typeof el.connectedCallback === "function") el.connectedCallback();
|
|
268
|
+
// Parser would now append JSON children. Simulate by setting textContent.
|
|
269
|
+
el.textContent = JSON.stringify(EXAMPLE_HUNK);
|
|
270
|
+
await rig.flushMicrotasks();
|
|
271
|
+
// After the microtask drains, the element must have rendered against
|
|
272
|
+
// the post-callback textContent.
|
|
273
|
+
expect(el.innerHTML).toContain("src/foo.ts");
|
|
274
|
+
expect(el.innerHTML.match(/<tr\b/g) ?? []).toHaveLength(4);
|
|
275
|
+
expect(el.innerHTML).toContain("WARN");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("re-renders cleanly on reconnection (connectedCallback fires again)", async () => {
|
|
279
|
+
const rig = makeRig();
|
|
280
|
+
const el = new rig.Ctor();
|
|
281
|
+
rig.setText(el, { file: "src/foo.ts" }, JSON.stringify(EXAMPLE_HUNK));
|
|
282
|
+
if (typeof el.connectedCallback === "function") el.connectedCallback();
|
|
283
|
+
await rig.flushMicrotasks();
|
|
284
|
+
const firstRender = el.innerHTML;
|
|
285
|
+
expect(firstRender).toContain("src/foo.ts");
|
|
286
|
+
// Re-fire (element was moved or detached + reattached).
|
|
287
|
+
if (typeof el.connectedCallback === "function") el.connectedCallback();
|
|
288
|
+
await rig.flushMicrotasks();
|
|
289
|
+
expect(el.innerHTML).toContain("src/foo.ts");
|
|
290
|
+
expect(el.innerHTML.match(/<tr\b/g) ?? []).toHaveLength(4);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("aborts render if element disconnected before microtask drains", async () => {
|
|
294
|
+
const rig = makeRig();
|
|
295
|
+
const el = new rig.Ctor();
|
|
296
|
+
rig.setText(el, { file: "src/foo.ts" }, JSON.stringify(EXAMPLE_HUNK));
|
|
297
|
+
if (typeof el.connectedCallback === "function") el.connectedCallback();
|
|
298
|
+
// Element gets removed from the DOM before our microtask runs.
|
|
299
|
+
el.isConnected = false;
|
|
300
|
+
await rig.flushMicrotasks();
|
|
301
|
+
expect(el.innerHTML).toBe("");
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
_getInstallationIdForTests,
|
|
4
4
|
_resetTelemetryStateForTests,
|
|
5
|
+
_resolveCloudMode,
|
|
5
6
|
initTelemetry,
|
|
6
7
|
track,
|
|
7
8
|
} from "../telemetry";
|
|
@@ -13,6 +14,9 @@ process.env.ANONYMIZED_TELEMETRY = "true";
|
|
|
13
14
|
describe("initTelemetry", () => {
|
|
14
15
|
beforeEach(() => {
|
|
15
16
|
_resetTelemetryStateForTests();
|
|
17
|
+
// Tests below set MCP_BASE_URL to assert classification — clear between
|
|
18
|
+
// tests so cases that expect "unset" don't inherit a prior test's value.
|
|
19
|
+
delete process.env.MCP_BASE_URL;
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
test("without generateIfMissing + missing config → installationId stays null (track no-ops)", async () => {
|
|
@@ -241,4 +245,149 @@ describe("initTelemetry", () => {
|
|
|
241
245
|
expect(_getInstallationIdForTests()).toBe(existing);
|
|
242
246
|
expect(writesB).toEqual([]);
|
|
243
247
|
});
|
|
248
|
+
|
|
249
|
+
describe("_resolveCloudMode (URL → is_cloud)", () => {
|
|
250
|
+
test("cloud apex host → cloud=true", () => {
|
|
251
|
+
expect(_resolveCloudMode("https://agent-swarm-mcp.desplega.sh")).toEqual({ isCloud: true });
|
|
252
|
+
expect(_resolveCloudMode("https://api.agent-swarm.dev")).toEqual({ isCloud: true });
|
|
253
|
+
expect(_resolveCloudMode("https://agent-swarm.dev")).toEqual({ isCloud: true });
|
|
254
|
+
// Future cloud subdomains (suffix match)
|
|
255
|
+
expect(_resolveCloudMode("https://mcp.agent-swarm.dev/")).toEqual({ isCloud: true });
|
|
256
|
+
// Trailing path / port / auth must not change the host classification
|
|
257
|
+
expect(_resolveCloudMode("https://user:tok@api.agent-swarm.dev:443/api/foo?x=1")).toEqual({
|
|
258
|
+
isCloud: true,
|
|
259
|
+
});
|
|
260
|
+
// Case-insensitive
|
|
261
|
+
expect(_resolveCloudMode("https://API.Agent-Swarm.DEV")).toEqual({ isCloud: true });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("agent-swarm.cloud apex host → cloud=true", () => {
|
|
265
|
+
// Exact apex
|
|
266
|
+
expect(_resolveCloudMode("https://agent-swarm.cloud")).toEqual({ isCloud: true });
|
|
267
|
+
// Suffix subdomain
|
|
268
|
+
expect(_resolveCloudMode("https://api.agent-swarm.cloud")).toEqual({ isCloud: true });
|
|
269
|
+
expect(_resolveCloudMode("https://mcp.agent-swarm.cloud/")).toEqual({ isCloud: true });
|
|
270
|
+
// Trailing path / port / auth must not change classification
|
|
271
|
+
expect(_resolveCloudMode("https://user:tok@api.agent-swarm.cloud:443/api/foo?x=1")).toEqual({
|
|
272
|
+
isCloud: true,
|
|
273
|
+
});
|
|
274
|
+
// Case-insensitive
|
|
275
|
+
expect(_resolveCloudMode("https://API.Agent-Swarm.CLOUD")).toEqual({ isCloud: true });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("self-hosted hosts → cloud=false", () => {
|
|
279
|
+
expect(_resolveCloudMode("http://localhost:3013")).toEqual({ isCloud: false });
|
|
280
|
+
expect(_resolveCloudMode("https://my-internal-mcp.example.com")).toEqual({ isCloud: false });
|
|
281
|
+
// Substring trap — must NOT be treated as cloud
|
|
282
|
+
expect(_resolveCloudMode("https://agent-swarm.dev.attacker.com")).toEqual({ isCloud: false });
|
|
283
|
+
expect(_resolveCloudMode("https://agent-swarm.cloud.attacker.com")).toEqual({
|
|
284
|
+
isCloud: false,
|
|
285
|
+
});
|
|
286
|
+
// IPv4 self-host
|
|
287
|
+
expect(_resolveCloudMode("http://127.0.0.1:3013")).toEqual({ isCloud: false });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("bare hostname / unset / weird scheme → safe fallback", () => {
|
|
291
|
+
// Bare hostname (no scheme) — URL constructor throws
|
|
292
|
+
expect(_resolveCloudMode("agent-swarm-mcp.desplega.sh")).toEqual({ isCloud: false });
|
|
293
|
+
// Empty / undefined / null
|
|
294
|
+
expect(_resolveCloudMode(undefined)).toEqual({ isCloud: false });
|
|
295
|
+
expect(_resolveCloudMode(null)).toEqual({ isCloud: false });
|
|
296
|
+
expect(_resolveCloudMode("")).toEqual({ isCloud: false });
|
|
297
|
+
// Obvious garbage
|
|
298
|
+
expect(_resolveCloudMode("not a url")).toEqual({ isCloud: false });
|
|
299
|
+
// Weird scheme with no host component
|
|
300
|
+
expect(_resolveCloudMode("file:///tmp/foo")).toEqual({ isCloud: false });
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("track() ships is_cloud in properties", () => {
|
|
305
|
+
const originalFetch = globalThis.fetch;
|
|
306
|
+
let captured: Record<string, unknown> | null = null;
|
|
307
|
+
|
|
308
|
+
beforeEach(() => {
|
|
309
|
+
captured = null;
|
|
310
|
+
globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
|
|
311
|
+
captured = init?.body ? JSON.parse(init.body) : null;
|
|
312
|
+
return new Response(null, { status: 204 });
|
|
313
|
+
}) as typeof fetch;
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
afterEach(() => {
|
|
317
|
+
globalThis.fetch = originalFetch;
|
|
318
|
+
delete process.env.MCP_BASE_URL;
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("cloud MCP_BASE_URL → properties.is_cloud=true", async () => {
|
|
322
|
+
process.env.MCP_BASE_URL = "https://agent-swarm-mcp.desplega.sh";
|
|
323
|
+
await initTelemetry(
|
|
324
|
+
"worker",
|
|
325
|
+
async () => "install_cloud_test",
|
|
326
|
+
async () => {},
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
track({ event: "server.started", properties: { port: 3013 } });
|
|
330
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
331
|
+
|
|
332
|
+
const properties = (captured as { properties: Record<string, unknown> }).properties;
|
|
333
|
+
expect(properties.is_cloud).toBe(true);
|
|
334
|
+
// Hostname must NOT be emitted — telemetry is anonymous.
|
|
335
|
+
expect(properties.mcp_host).toBeUndefined();
|
|
336
|
+
// Caller's properties preserved alongside the cohort signal.
|
|
337
|
+
expect(properties.port).toBe(3013);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("self-hosted MCP_BASE_URL → properties.is_cloud=false", async () => {
|
|
341
|
+
process.env.MCP_BASE_URL = "http://localhost:3013";
|
|
342
|
+
await initTelemetry(
|
|
343
|
+
"worker",
|
|
344
|
+
async () => "install_self_test",
|
|
345
|
+
async () => {},
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
track({ event: "test.event", properties: {} });
|
|
349
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
350
|
+
|
|
351
|
+
const properties = (captured as { properties: Record<string, unknown> }).properties;
|
|
352
|
+
expect(properties.is_cloud).toBe(false);
|
|
353
|
+
expect(properties.mcp_host).toBeUndefined();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("missing MCP_BASE_URL → safe fallback (false)", async () => {
|
|
357
|
+
delete process.env.MCP_BASE_URL;
|
|
358
|
+
await initTelemetry(
|
|
359
|
+
"api-server",
|
|
360
|
+
async () => "install_no_url",
|
|
361
|
+
async () => {},
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
track({ event: "test.event", properties: {} });
|
|
365
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
366
|
+
|
|
367
|
+
const properties = (captured as { properties: Record<string, unknown> }).properties;
|
|
368
|
+
expect(properties.is_cloud).toBe(false);
|
|
369
|
+
expect(properties.mcp_host).toBeUndefined();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("caller properties cannot override is_cloud", async () => {
|
|
373
|
+
// Defense-in-depth: even if a caller passes through user-supplied
|
|
374
|
+
// values, the cohort signal shipped on every event must come from
|
|
375
|
+
// initTelemetry — not from arbitrary call sites.
|
|
376
|
+
process.env.MCP_BASE_URL = "https://agent-swarm-mcp.desplega.sh";
|
|
377
|
+
await initTelemetry(
|
|
378
|
+
"worker",
|
|
379
|
+
async () => "install_override_test",
|
|
380
|
+
async () => {},
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
track({
|
|
384
|
+
event: "test.event",
|
|
385
|
+
properties: { is_cloud: false },
|
|
386
|
+
});
|
|
387
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
388
|
+
|
|
389
|
+
const properties = (captured as { properties: Record<string, unknown> }).properties;
|
|
390
|
+
expect(properties.is_cloud).toBe(true);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
244
393
|
});
|
|
@@ -261,13 +261,10 @@ describe("WaitExecutor — event mode end-to-end", () => {
|
|
|
261
261
|
|
|
262
262
|
// Skip the 5s poller — fast-forward by directly calling the resume helper
|
|
263
263
|
// with status='timeout' (the poller would do exactly this once expiresAt
|
|
264
|
-
// passes).
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
await new Promise((r) => setTimeout(r, sleepMs));
|
|
269
|
-
const due = getDueWaitStates();
|
|
270
|
-
expect(due.find((d) => d.id === ws!.id)).toBeDefined();
|
|
264
|
+
// passes). Poll getDueWaitStates() instead of sleeping a fixed amount:
|
|
265
|
+
// SQLite's strftime('now') and JS Date.now() can drift by a few ms on
|
|
266
|
+
// loaded CI, and setTimeout cushions need to be generous to compensate.
|
|
267
|
+
await waitFor(() => getDueWaitStates().some((d) => d.id === ws!.id), 5000);
|
|
271
268
|
|
|
272
269
|
await resumeWaitState(ws!.id, "timeout", undefined, registry);
|
|
273
270
|
|