@sleep2agi/commhub-server 0.9.0-preview.42 → 0.9.0-preview.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/db.ts +76 -1
- package/src/report-status-host-schema.test.ts +135 -0
- package/src/server.ts +66 -3
- package/src/tools.ts +146 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +22 -19
- package/src/user-inbox-schema.test.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.44",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// #1353 Fix ② — /api/host-supervisors surfaces daemon's self-reported
|
|
2
|
+
// can_create_nodes + create_nodes_blocked_reason so dashboard's
|
|
3
|
+
// "选服务器" picker can filter or dim broken daemons.
|
|
4
|
+
//
|
|
5
|
+
// Fix ① (PR #1510) added the hub-side dispatch gate — daemon that
|
|
6
|
+
// reports can_create_nodes=false gets refused with the reason. But
|
|
7
|
+
// the dashboard picker still LISTS the daemon and the user finds out
|
|
8
|
+
// only after clicking. This exposes the fact one layer earlier so the
|
|
9
|
+
// picker never surfaces the daemon in the first place.
|
|
10
|
+
//
|
|
11
|
+
// 🔴 Pre-#1371 compat is the same shape as Fix ①: only include the
|
|
12
|
+
// two keys when the daemon actually reported them. Old daemons (that
|
|
13
|
+
// never report) get the same undefined-passthrough dashboard sees a
|
|
14
|
+
// row WITHOUT the keys, not a row with can_create_nodes:false, so
|
|
15
|
+
// the dashboard's own default UI doesn't silently dim every legacy
|
|
16
|
+
// daemon.
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
19
|
+
import "./require-explicit-test-db.js";
|
|
20
|
+
import { register, login } from "./auth.js";
|
|
21
|
+
import { db } from "./db.js";
|
|
22
|
+
|
|
23
|
+
let BASE = "";
|
|
24
|
+
let server: any = null;
|
|
25
|
+
|
|
26
|
+
let userToken = "";
|
|
27
|
+
let userNetworkId = "";
|
|
28
|
+
let userId = "";
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
process.env.HOST = "127.0.0.1";
|
|
32
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
33
|
+
const password = "BootstrapPw123Aa!";
|
|
34
|
+
|
|
35
|
+
// Seed admin so subsequent registrations aren't auto-admin (admin
|
|
36
|
+
// path skips resolveRestNetworkScope's default-network fallback —
|
|
37
|
+
// that would change our fixture's role/scope in ways unrelated to
|
|
38
|
+
// this fix).
|
|
39
|
+
const seed = register(`hsc_seed_${suffix}`, password, undefined, "seed");
|
|
40
|
+
if (!seed.ok) throw new Error("seed failed");
|
|
41
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`hsc_seed_${suffix}`]);
|
|
42
|
+
|
|
43
|
+
const u = register(`hsc_u_${suffix}`, password, undefined, "seed");
|
|
44
|
+
if (!u.ok || !u.token) throw new Error("user register failed");
|
|
45
|
+
userToken = u.token;
|
|
46
|
+
userNetworkId = u.network_id ?? "";
|
|
47
|
+
userId = login(`hsc_u_${suffix}`, password).user!.user_id;
|
|
48
|
+
|
|
49
|
+
const { bootServer } = await import("./server.js");
|
|
50
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
51
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
52
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
try { server?.stop?.(true); } catch {}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function seedDaemon(alias: string, snapshotOverride: object | null): void {
|
|
60
|
+
const nodeId = `hsc_n_${alias}`;
|
|
61
|
+
const snap = snapshotOverride === null
|
|
62
|
+
? null
|
|
63
|
+
: JSON.stringify(snapshotOverride);
|
|
64
|
+
db.run(
|
|
65
|
+
`INSERT OR REPLACE INTO nodes (
|
|
66
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
67
|
+
channels, server, hostname, network_id,
|
|
68
|
+
config_revision, config_snapshot
|
|
69
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
|
|
70
|
+
[nodeId, alias, alias, "claude-agent-sdk", "claude-sonnet-4-5",
|
|
71
|
+
"/tmp/cfg.json", "[]", "test-host", "test-host", userNetworkId,
|
|
72
|
+
0, snap],
|
|
73
|
+
);
|
|
74
|
+
db.run(
|
|
75
|
+
`INSERT OR REPLACE INTO api_tokens (
|
|
76
|
+
token_id, token_hash, user_id, network_id, name, scope
|
|
77
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
78
|
+
[`hsc_t_${alias}`, `hash_${alias}`, userId, userNetworkId, `node:${alias}`, "network"],
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clearDaemons(): void {
|
|
83
|
+
db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
|
|
84
|
+
db.run("DELETE FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%'", [userNetworkId]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function listDaemons(): Promise<any[]> {
|
|
88
|
+
const res = await fetch(`${BASE}/api/host-supervisors`, {
|
|
89
|
+
headers: { "Authorization": `Bearer ${userToken}` },
|
|
90
|
+
});
|
|
91
|
+
const body = await res.json();
|
|
92
|
+
if (!body.ok) throw new Error("list failed: " + JSON.stringify(body));
|
|
93
|
+
return body.daemons;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function findDaemon(rows: any[], alias: string): any {
|
|
97
|
+
const d = rows.find(r => r.alias === alias);
|
|
98
|
+
if (!d) throw new Error(`daemon ${alias} not in response`);
|
|
99
|
+
return d;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("#1353 Fix ② — /api/host-supervisors surfaces can_create_nodes + blocked_reason", () => {
|
|
103
|
+
test("🔴 witnessed-red: blocked daemon → response carries can_create_nodes:false + blocked_reason", async () => {
|
|
104
|
+
clearDaemons();
|
|
105
|
+
seedDaemon("blocked-a", {
|
|
106
|
+
role: "host_supervisor",
|
|
107
|
+
daemon_capabilities: {
|
|
108
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
109
|
+
can_create_nodes: false,
|
|
110
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const d = findDaemon(await listDaemons(), "blocked-a");
|
|
114
|
+
expect(d.can_create_nodes).toBe(false);
|
|
115
|
+
expect(d.create_nodes_blocked_reason).toBe("anet_bin_permission");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("healthy daemon (can_create_nodes:true) → can_create_nodes:true, NO blocked_reason", async () => {
|
|
119
|
+
clearDaemons();
|
|
120
|
+
seedDaemon("healthy-a", {
|
|
121
|
+
role: "host_supervisor",
|
|
122
|
+
daemon_capabilities: {
|
|
123
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
124
|
+
can_create_nodes: true,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const d = findDaemon(await listDaemons(), "healthy-a");
|
|
128
|
+
expect(d.can_create_nodes).toBe(true);
|
|
129
|
+
// 🔴 defensive: dashboard's picker likely renders blocked_reason
|
|
130
|
+
// when present regardless of can_create_nodes value. Don't
|
|
131
|
+
// confuse it by shipping a stale reason on a healthy daemon.
|
|
132
|
+
expect(d.create_nodes_blocked_reason).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("healthy daemon that ALSO reports stale blocked_reason → reason NOT emitted (defensive)", async () => {
|
|
136
|
+
// Belt-and-braces: even if a future daemon shape bug leaves a
|
|
137
|
+
// stale reason string in the snapshot after recovering, the REST
|
|
138
|
+
// response should not surface it when the daemon is currently OK.
|
|
139
|
+
clearDaemons();
|
|
140
|
+
seedDaemon("healthy-b", {
|
|
141
|
+
role: "host_supervisor",
|
|
142
|
+
daemon_capabilities: {
|
|
143
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
144
|
+
can_create_nodes: true,
|
|
145
|
+
create_nodes_blocked_reason: "anet_bin_permission", // stale
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const d = findDaemon(await listDaemons(), "healthy-b");
|
|
149
|
+
expect(d.can_create_nodes).toBe(true);
|
|
150
|
+
expect(d.create_nodes_blocked_reason).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("🔴 pre-#1371 compat: daemon that never reports can_create_nodes → BOTH keys absent from response", async () => {
|
|
154
|
+
// Same "known-blocked, NOT unknown-treated-as-blocked" rule as
|
|
155
|
+
// Fix ①. A pre-#1371 daemon (preview.10 through ~preview.55) never
|
|
156
|
+
// reports these fields. The response for it must not fabricate a
|
|
157
|
+
// false value — the dashboard could reasonably render an
|
|
158
|
+
// explicit `false` as "dim / hide" which would silently start
|
|
159
|
+
// hiding every daemon that hasn't upgraded.
|
|
160
|
+
clearDaemons();
|
|
161
|
+
seedDaemon("legacy-a", {
|
|
162
|
+
role: "host_supervisor",
|
|
163
|
+
daemon_capabilities: {
|
|
164
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
165
|
+
// no can_create_nodes / create_nodes_blocked_reason
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const d = findDaemon(await listDaemons(), "legacy-a");
|
|
169
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
170
|
+
expect("create_nodes_blocked_reason" in d).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("pre-#1371 compat: daemon with no daemon_capabilities at all → BOTH keys absent", async () => {
|
|
174
|
+
clearDaemons();
|
|
175
|
+
seedDaemon("legacy-b", { role: "host_supervisor" });
|
|
176
|
+
const d = findDaemon(await listDaemons(), "legacy-b");
|
|
177
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
178
|
+
expect("create_nodes_blocked_reason" in d).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("pre-#1371 compat: NULL config_snapshot (very old daemon) → BOTH keys absent, daemon does NOT crash the listing", async () => {
|
|
182
|
+
// A daemon that never called report_status leaves config_snapshot NULL.
|
|
183
|
+
// /api/host-supervisors filters by role from the snapshot, so a NULL
|
|
184
|
+
// snapshot drops from the list entirely — but if it did surface (via
|
|
185
|
+
// any future path), it must not crash the reducer.
|
|
186
|
+
clearDaemons();
|
|
187
|
+
seedDaemon("legacy-c", { role: "host_supervisor" }); // seed with something to be listable
|
|
188
|
+
const rows = await listDaemons();
|
|
189
|
+
// Just prove the list doesn't throw and the daemon is present with
|
|
190
|
+
// no capability keys.
|
|
191
|
+
const d = findDaemon(rows, "legacy-c");
|
|
192
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("all 4 categorized reason codes round-trip through the REST response", async () => {
|
|
196
|
+
const codes = ["anet_bin_identity", "anet_bin_source", "anet_bin_shape", "anet_bin_permission"];
|
|
197
|
+
for (const code of codes) {
|
|
198
|
+
clearDaemons();
|
|
199
|
+
seedDaemon(`blocked-${code}`, {
|
|
200
|
+
role: "host_supervisor",
|
|
201
|
+
daemon_capabilities: {
|
|
202
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
203
|
+
can_create_nodes: false,
|
|
204
|
+
create_nodes_blocked_reason: code,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const d = findDaemon(await listDaemons(), `blocked-${code}`);
|
|
208
|
+
expect(d.can_create_nodes).toBe(false);
|
|
209
|
+
expect(d.create_nodes_blocked_reason).toBe(code);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test(".40-shape 'anet_bin_pin_unresolved' pre-#1377 code also passes through the REST response", async () => {
|
|
214
|
+
// The Zod enum still accepts this coarse pre-#1377 code (agent-node
|
|
215
|
+
// @2.5.0-preview.40 daemons in the wild emit it), and Fix ① surfaces
|
|
216
|
+
// it verbatim to callers; the REST surface must too, otherwise
|
|
217
|
+
// dashboard sees the daemon as "blocked with no reason".
|
|
218
|
+
clearDaemons();
|
|
219
|
+
seedDaemon("blocked-40shape", {
|
|
220
|
+
role: "host_supervisor",
|
|
221
|
+
daemon_capabilities: {
|
|
222
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
223
|
+
can_create_nodes: false,
|
|
224
|
+
create_nodes_blocked_reason: "anet_bin_pin_unresolved",
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const d = findDaemon(await listDaemons(), "blocked-40shape");
|
|
228
|
+
expect(d.create_nodes_blocked_reason).toBe("anet_bin_pin_unresolved");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("malformed config_snapshot: existing behavior (daemon drops from list due to role-filter) unchanged", async () => {
|
|
232
|
+
// The parse is wrapped in try/catch (existing behavior). A malformed
|
|
233
|
+
// snapshot means snapRole stays null → filter drops it. This just
|
|
234
|
+
// pins that our added parsing doesn't turn a malformed snapshot
|
|
235
|
+
// into a crash.
|
|
236
|
+
clearDaemons();
|
|
237
|
+
// Seed a legit daemon so the list has SOMETHING to prove no crash.
|
|
238
|
+
seedDaemon("healthy-c", {
|
|
239
|
+
role: "host_supervisor",
|
|
240
|
+
daemon_capabilities: { runtimes_supported: [] },
|
|
241
|
+
});
|
|
242
|
+
// Seed the malformed one — should be filtered out silently.
|
|
243
|
+
const nodeId = "hsc_n_malformed";
|
|
244
|
+
db.run(
|
|
245
|
+
`INSERT OR REPLACE INTO nodes (
|
|
246
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
247
|
+
channels, server, hostname, network_id,
|
|
248
|
+
config_revision, config_snapshot
|
|
249
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
|
|
250
|
+
[nodeId, "malformed", "malformed", "claude-agent-sdk", "claude-sonnet-4-5",
|
|
251
|
+
"/tmp/cfg.json", "[]", "test-host", "test-host", userNetworkId,
|
|
252
|
+
0, "{not json"],
|
|
253
|
+
);
|
|
254
|
+
db.run(
|
|
255
|
+
`INSERT OR REPLACE INTO api_tokens (
|
|
256
|
+
token_id, token_hash, user_id, network_id, name, scope
|
|
257
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
258
|
+
["hsc_t_malformed", "hash_malformed", userId, userNetworkId, "node:malformed", "network"],
|
|
259
|
+
);
|
|
260
|
+
const rows = await listDaemons();
|
|
261
|
+
// healthy-c is present; malformed is filtered out because
|
|
262
|
+
// snapRole stayed null.
|
|
263
|
+
expect(rows.find((r: any) => r.alias === "healthy-c")).toBeDefined();
|
|
264
|
+
expect(rows.find((r: any) => r.alias === "malformed")).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/* #1545 —— `can_create_nodes` 说的是「能不能」,这一组说的是「**那是什么时候测的**」。
|
|
269
|
+
*
|
|
270
|
+
* 为什么非要分开:agent-node ≤ 2.5.0-preview.54 用一个进程级缓存只算一次
|
|
271
|
+
* (`cli.ts` 的 `_createCapCache`)。开机时二进制好、之后被 chmod 掉 ⇒ 它会
|
|
272
|
+
* **永远上报 ready**。hub 这边,那条记录和一个 3 秒前刚测出来的 ready
|
|
273
|
+
* 在 `last_seen_at` 上一模一样 —— 心跳是新的,那一格不是。
|
|
274
|
+
*
|
|
275
|
+
* 🔴 缺席**不得**当成 0。把「没报」渲染成「刚测的」,正好朝「没问题」方向说谎,
|
|
276
|
+
* 而这正是 #1545 里比沉默更糟的那一半。
|
|
277
|
+
*/
|
|
278
|
+
describe("#1545 /api/host-supervisors —— can_create_nodes 的年龄", () => {
|
|
279
|
+
test("daemon 报了年龄 → 原样带出", async () => {
|
|
280
|
+
clearDaemons();
|
|
281
|
+
seedDaemon("age-fresh", {
|
|
282
|
+
role: "host_supervisor",
|
|
283
|
+
daemon_capabilities: {
|
|
284
|
+
can_create_nodes: false,
|
|
285
|
+
create_nodes_blocked_reason: "anet_bin_source",
|
|
286
|
+
create_capability_observed_ms_ago: 12,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const d = findDaemon(await listDaemons(), "age-fresh");
|
|
290
|
+
expect(d.create_capability_observed_ms_ago).toBe(12);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("🔴 daemon 没报年龄 → 这一格必须**缺席**,不能是 0/null", async () => {
|
|
294
|
+
clearDaemons();
|
|
295
|
+
seedDaemon("age-absent", {
|
|
296
|
+
role: "host_supervisor",
|
|
297
|
+
daemon_capabilities: { can_create_nodes: true },
|
|
298
|
+
});
|
|
299
|
+
const d = findDaemon(await listDaemons(), "age-absent");
|
|
300
|
+
// 能力本身照常带出 —— 加年龄这一格不许把旧 daemon 的既有信息弄丢。
|
|
301
|
+
expect(d.can_create_nodes).toBe(true);
|
|
302
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/* 🔴 消毒**不是 clamp**:clamp 会把一个坏值变成一个看起来正常的年龄,
|
|
306
|
+
* 而这一格存在的全部意义就是让人分辨新鲜和陈旧。坏值一律当作「没报」。 */
|
|
307
|
+
test.each([
|
|
308
|
+
["负数", -1],
|
|
309
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
310
|
+
["null(schema 的 .catch 兜底值)", null],
|
|
311
|
+
["字符串(直接写库,绕过 schema)", "12"],
|
|
312
|
+
])("坏值当作没报,而不是 clamp 成边界:%s", async (label, value) => {
|
|
313
|
+
clearDaemons();
|
|
314
|
+
const alias = `age-bad-${String(label).slice(0, 4)}`;
|
|
315
|
+
seedDaemon(alias, {
|
|
316
|
+
role: "host_supervisor",
|
|
317
|
+
daemon_capabilities: {
|
|
318
|
+
can_create_nodes: true,
|
|
319
|
+
create_capability_observed_ms_ago: value,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
const d = findDaemon(await listDaemons(), alias);
|
|
323
|
+
expect(d.can_create_nodes).toBe(true); // 能力仍在
|
|
324
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false); // 年龄不在
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/* 边界校准:上面用 400 天证明「太旧被丢」,这里用**刚好一年**证明门不是恒丢。
|
|
328
|
+
* 只验被丢那一侧的话,一个恒丢的实现能全绿通过。 */
|
|
329
|
+
test("边界:恰好一年被接受 ⇒ 上面那条不是因为恒丢才绿", async () => {
|
|
330
|
+
clearDaemons();
|
|
331
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
332
|
+
seedDaemon("age-boundary", {
|
|
333
|
+
role: "host_supervisor",
|
|
334
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: oneYear },
|
|
335
|
+
});
|
|
336
|
+
const d = findDaemon(await listDaemons(), "age-boundary");
|
|
337
|
+
expect(d.create_capability_observed_ms_ago).toBe(oneYear);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("年龄只在 can_create_nodes 存在时出现(旧 daemon 两格都没有)", async () => {
|
|
341
|
+
clearDaemons();
|
|
342
|
+
seedDaemon("age-legacy", {
|
|
343
|
+
role: "host_supervisor",
|
|
344
|
+
daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"] },
|
|
345
|
+
});
|
|
346
|
+
const d = findDaemon(await listDaemons(), "age-legacy");
|
|
347
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
348
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
});
|