@bongos/core 1.19.517 → 1.19.519
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/.bongos-core.json +41 -26
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -0
- package/docs/copy-inventory.md +117 -112
- package/docs/copy-registry.json +168 -123
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/lib/validate.js +306 -0
- package/modules/builder-settings/builder-needs.js +28 -2
- package/modules/hall-ui/public/collab.html +19 -0
- package/modules/hall-ui/public/collab.js +78 -0
- package/modules/hall-ui/public/hall-render.js +6 -0
- package/modules/hall-ui/public/shell.js +30 -1
- package/modules/lifecycle/lifecycle.js +8 -0
- package/modules/lifecycle/mingle.js +17 -1
- package/modules/lifecycle/routes/mingle.js +5 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/me.js +39 -1
- package/src/module-api.js +1 -1
- package/tests/agents_validate.mjs +354 -0
- package/tests/mingle.mjs +26 -0
- package/tests/rank_tier_single_source.mjs +11 -0
- package/tests/social_mode.mjs +181 -0
package/src/bongos/routes/me.js
CHANGED
|
@@ -105,7 +105,7 @@ module.exports = function buildMeRouter() {
|
|
|
105
105
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
106
106
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
|
|
107
107
|
behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
|
|
108
|
-
taskRecommendationCount, boardVotesPending,
|
|
108
|
+
taskRecommendationCount, boardVotesPending, socialMode, mingleIntroductionCount,
|
|
109
109
|
] = await Promise.all([
|
|
110
110
|
db.getBuilderById(req.builder.id),
|
|
111
111
|
lc().getActiveClaimsForBuilder(req.builder.id),
|
|
@@ -205,6 +205,29 @@ module.exports = function buildMeRouter() {
|
|
|
205
205
|
return await government.pendingBoardVotesFor(req.builder.id);
|
|
206
206
|
} catch (_) { return null; }
|
|
207
207
|
})(),
|
|
208
|
+
// task 1003178 (C08): SOCIAL MODE — the pair of switches C07 resolved
|
|
209
|
+
// (the project enabled mingle AND this builder opted in). It rides /me
|
|
210
|
+
// because the criterion's whole point is that social changes the HALL
|
|
211
|
+
// rather than adding a destination: every page already loads /me, so the
|
|
212
|
+
// shell can stamp the mode on the document and any surface can read it.
|
|
213
|
+
// Same fan-out slot and FAIL-OPEN posture as the reads above — null, not a
|
|
214
|
+
// throw, and the shell treats null as OFF (a mode that cannot be read is
|
|
215
|
+
// not on).
|
|
216
|
+
(async () => {
|
|
217
|
+
try {
|
|
218
|
+
const lifecycle = lc();
|
|
219
|
+
if (!lifecycle || !lifecycle.socialModeFor) return null;
|
|
220
|
+
return await lifecycle.socialModeFor(req.builder.id);
|
|
221
|
+
} catch (_) { return null; }
|
|
222
|
+
})(),
|
|
223
|
+
// The introductions waiting on this builder, for the needs roster.
|
|
224
|
+
(async () => {
|
|
225
|
+
try {
|
|
226
|
+
const lifecycle = lc();
|
|
227
|
+
if (!lifecycle || !lifecycle.openMingleIntroductionsFor) return 0;
|
|
228
|
+
return await lifecycle.openMingleIntroductionsFor(req.builder.id);
|
|
229
|
+
} catch (_) { return 0; }
|
|
230
|
+
})(),
|
|
208
231
|
]);
|
|
209
232
|
// V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
|
|
210
233
|
// monthly budget. Surfaced here so the CLI (session-start ping) and the hall
|
|
@@ -257,6 +280,19 @@ module.exports = function buildMeRouter() {
|
|
|
257
280
|
// Resolved by economy (never re-derived); null if economy is disabled.
|
|
258
281
|
reward: rewardPolicySafe(),
|
|
259
282
|
est_vs_actual: estVsActual,
|
|
283
|
+
// task 1003178 (C08, goal 1000075): SOCIAL MODE. The owner's directive is
|
|
284
|
+
// that social is "scoped into the ENTIRE builders hall, not a single social
|
|
285
|
+
// tab", and this key is the mechanism: every hall page already loads /me,
|
|
286
|
+
// so the shell stamps the mode on the document and any surface reads it
|
|
287
|
+
// from there. A /social ROUTE is what the criterion explicitly does not
|
|
288
|
+
// accept, so there is deliberately no route to add — the mode travels with
|
|
289
|
+
// the identity the whole hall is already fetching.
|
|
290
|
+
//
|
|
291
|
+
// Shape: { enabled, project_enabled, opted_in, opted_in_at }, both switches
|
|
292
|
+
// reported separately so a surface can say WHICH half is off (C07). null
|
|
293
|
+
// when lifecycle is disabled or the read failed, and the shell treats null
|
|
294
|
+
// as OFF — a mode that cannot be read is not on.
|
|
295
|
+
social: socialMode,
|
|
260
296
|
unlocked,
|
|
261
297
|
locked_with_progress: lockedWithProgress,
|
|
262
298
|
streak: {
|
|
@@ -354,6 +390,8 @@ module.exports = function buildMeRouter() {
|
|
|
354
390
|
// this builder, read through the same `lifecycle` port so core never
|
|
355
391
|
// imports the module (ADR 0093).
|
|
356
392
|
openTaskRecommendations: taskRecommendationCount,
|
|
393
|
+
// task 1003178 (C08): open mingle introductions waiting on this builder.
|
|
394
|
+
openMingleIntroductions: mingleIntroductionCount,
|
|
357
395
|
}) : [],
|
|
358
396
|
};
|
|
359
397
|
// Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.519'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// tests/agents_validate.mjs — the pure agent-definition validator (task 1002487,
|
|
2
|
+
// goal 1000038).
|
|
3
|
+
//
|
|
4
|
+
// Gates criterion `agents-malformed-rejected`: a malformed definition (unknown
|
|
5
|
+
// trigger; missing persona; `trigger_spec.event` not in EVENT_CATALOG; scope
|
|
6
|
+
// exceeding an allowed module) is refused with a clear error, and the validator
|
|
7
|
+
// NEVER throws — the loader logs and skips, it does not crash the boot.
|
|
8
|
+
//
|
|
9
|
+
// No database, no fixtures, no fs. Every input is a literal, because the thing
|
|
10
|
+
// under test is a pure decision.
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/agents_validate.mjs
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const {
|
|
19
|
+
validateAgentDefinition,
|
|
20
|
+
EVENT_CATALOG,
|
|
21
|
+
TRIGGER_TYPES,
|
|
22
|
+
MODEL_TIERS,
|
|
23
|
+
RANK_ORDER,
|
|
24
|
+
SCOPE_RANK_FLOOR,
|
|
25
|
+
} = require('../modules/agents/lib/validate.js');
|
|
26
|
+
|
|
27
|
+
let passed = 0;
|
|
28
|
+
let failed = 0;
|
|
29
|
+
async function test(name, fn) {
|
|
30
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
31
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A definition that passes everything, so each case below can break exactly one
|
|
35
|
+
// thing and attribute the refusal to that one thing.
|
|
36
|
+
const OK_DEF = Object.freeze({
|
|
37
|
+
name: 'historian',
|
|
38
|
+
persona: 'Observe the shipped task and report what changed.',
|
|
39
|
+
trigger_type: 'event',
|
|
40
|
+
trigger_spec: { event: 'task.shipped' },
|
|
41
|
+
model_tier: 'default',
|
|
42
|
+
scope_modules: ['agents'],
|
|
43
|
+
});
|
|
44
|
+
const OPTS = Object.freeze({ authorRank: 'archon', allowedModules: ['agents'] });
|
|
45
|
+
|
|
46
|
+
const codes = (r) => r.errors.map((e) => e.code);
|
|
47
|
+
const hasCode = (r, code) => codes(r).includes(code);
|
|
48
|
+
|
|
49
|
+
// --- THE BASELINE -----------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
await test('the exemplar definition validates, and normalizes into a value', () => {
|
|
52
|
+
const r = validateAgentDefinition(OK_DEF, OPTS);
|
|
53
|
+
assert.equal(r.ok, true, `expected ok, got errors: ${JSON.stringify(r.errors)}`);
|
|
54
|
+
assert.deepEqual(r.errors, []);
|
|
55
|
+
assert.equal(r.value.name, 'historian');
|
|
56
|
+
assert.equal(r.value.model_tier, 'default');
|
|
57
|
+
assert.deepEqual(r.value.scope_modules, ['agents']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await test('a failed validation returns NO value — nothing downstream can insert it', () => {
|
|
61
|
+
const r = validateAgentDefinition({ ...OK_DEF, persona: '' }, OPTS);
|
|
62
|
+
assert.equal(r.ok, false);
|
|
63
|
+
assert.equal(r.value, null, 'a rejected definition must not hand back a usable row');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// --- THE EVENT CATALOG — the fail-closed rule -------------------------------
|
|
67
|
+
|
|
68
|
+
await test('EVENT_CATALOG is exactly the v1 wired set, and is frozen', () => {
|
|
69
|
+
assert.deepEqual(EVENT_CATALOG, ['task.shipped']);
|
|
70
|
+
assert.equal(Object.isFrozen(EVENT_CATALOG), true, 'a caller must not be able to widen the catalog at runtime');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await test('an UNWIRED event name is REFUSED, never silently accepted', () => {
|
|
74
|
+
// The whole point of the catalog: an event nothing dispatches would produce an
|
|
75
|
+
// agent that looks armed and never fires.
|
|
76
|
+
for (const bad of ['task.completed', 'task.shipped ', 'TASK.SHIPPED', 'claim.created', 'task.*']) {
|
|
77
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: { event: bad } }, OPTS);
|
|
78
|
+
assert.equal(r.ok, false, `"${bad}" must be refused`);
|
|
79
|
+
assert.ok(hasCode(r, 'trigger_event_unwired'), `"${bad}" → ${JSON.stringify(codes(r))}`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('the refusal NAMES the offending event and the catalog', () => {
|
|
84
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: { event: 'task.completed' } }, OPTS);
|
|
85
|
+
const msg = r.errors.find((e) => e.code === 'trigger_event_unwired').message;
|
|
86
|
+
assert.ok(msg.includes('task.completed'), `message must name the offender: ${msg}`);
|
|
87
|
+
assert.ok(msg.includes('task.shipped'), `message must name what IS wired: ${msg}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('an event trigger with no event name is refused', () => {
|
|
91
|
+
for (const spec of [{}, { event: '' }, { event: ' ' }, { event: null }, { event: 42 }]) {
|
|
92
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: spec }, OPTS);
|
|
93
|
+
assert.equal(r.ok, false, `${JSON.stringify(spec)} must be refused`);
|
|
94
|
+
assert.ok(
|
|
95
|
+
hasCode(r, 'trigger_event_missing') || hasCode(r, 'trigger_event_unwired'),
|
|
96
|
+
`${JSON.stringify(spec)} → ${JSON.stringify(codes(r))}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test('an on-demand definition needs no event at all', () => {
|
|
102
|
+
const r = validateAgentDefinition(
|
|
103
|
+
{ ...OK_DEF, trigger_type: 'on-demand', trigger_spec: {} }, OPTS,
|
|
104
|
+
);
|
|
105
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test('an on-demand definition carrying an UNWIRED event is still refused', () => {
|
|
109
|
+
// Fails closed on the event NAME wherever it appears. An inert `event:` key on
|
|
110
|
+
// an on-demand agent is either a typo'd trigger_type or dead config; both are
|
|
111
|
+
// bugs the author should be shown rather than have silently accepted.
|
|
112
|
+
const r = validateAgentDefinition(
|
|
113
|
+
{ ...OK_DEF, trigger_type: 'on-demand', trigger_spec: { event: 'task.completed' } }, OPTS,
|
|
114
|
+
);
|
|
115
|
+
assert.equal(r.ok, false);
|
|
116
|
+
assert.ok(hasCode(r, 'trigger_event_unwired'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await test('an on-demand definition carrying a WIRED event is accepted', () => {
|
|
120
|
+
const r = validateAgentDefinition(
|
|
121
|
+
{ ...OK_DEF, trigger_type: 'on-demand', trigger_spec: { event: 'task.shipped' } }, OPTS,
|
|
122
|
+
);
|
|
123
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// --- THE TRIGGER TYPE -------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
await test('TRIGGER_TYPES is the v1 pair the DB CHECK also pins', () => {
|
|
129
|
+
assert.deepEqual(TRIGGER_TYPES, ['event', 'on-demand']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('an unknown trigger type is refused', () => {
|
|
133
|
+
for (const bad of ['cron', 'webhook', 'Event', 'on demand', '', null, undefined, 7, {}]) {
|
|
134
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_type: bad }, OPTS);
|
|
135
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
136
|
+
assert.ok(hasCode(r, 'trigger_type_unknown'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await test('a non-object trigger_spec is refused rather than coerced', () => {
|
|
141
|
+
for (const bad of ['task.shipped', 42, [], true]) {
|
|
142
|
+
const r = validateAgentDefinition({ ...OK_DEF, trigger_spec: bad }, OPTS);
|
|
143
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
144
|
+
assert.ok(hasCode(r, 'trigger_spec_not_object'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// --- THE PERSONA ------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
await test('a missing or empty persona is refused', () => {
|
|
151
|
+
for (const bad of [undefined, null, '', ' ', 42, {}]) {
|
|
152
|
+
const r = validateAgentDefinition({ ...OK_DEF, persona: bad }, OPTS);
|
|
153
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
154
|
+
assert.ok(hasCode(r, 'persona_missing'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('an oversized persona is refused at the same bound the DB CHECK holds', () => {
|
|
159
|
+
const r = validateAgentDefinition({ ...OK_DEF, persona: 'x'.repeat(32769) }, OPTS);
|
|
160
|
+
assert.equal(r.ok, false);
|
|
161
|
+
assert.ok(hasCode(r, 'persona_too_long'));
|
|
162
|
+
// ...and exactly at the bound it is fine.
|
|
163
|
+
assert.equal(validateAgentDefinition({ ...OK_DEF, persona: 'x'.repeat(32768) }, OPTS).ok, true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// --- THE NAME ---------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
await test('a name that could not be a filename / URL segment / CLI arg is refused', () => {
|
|
169
|
+
for (const bad of ['Historian', '1historian', 'the historian', 'his/torian', '-historian', '', 'a'.repeat(65)]) {
|
|
170
|
+
const r = validateAgentDefinition({ ...OK_DEF, name: bad }, OPTS);
|
|
171
|
+
assert.equal(r.ok, false, `"${bad}" must be refused`);
|
|
172
|
+
assert.ok(hasCode(r, 'name_invalid'), `"${bad}" → ${JSON.stringify(codes(r))}`);
|
|
173
|
+
}
|
|
174
|
+
assert.equal(validateAgentDefinition({ ...OK_DEF, name: 'a'.repeat(64) }, OPTS).ok, true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- THE MODEL TIER ---------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
await test('model_tier defaults to `default` when absent, and rejects an unknown tier', () => {
|
|
180
|
+
assert.deepEqual(MODEL_TIERS, ['default', 'routine']);
|
|
181
|
+
const { model_tier: _omit, ...noTier } = OK_DEF;
|
|
182
|
+
const r = validateAgentDefinition(noTier, OPTS);
|
|
183
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
184
|
+
assert.equal(r.value.model_tier, 'default');
|
|
185
|
+
|
|
186
|
+
// spawn.js's resolveModel() deliberately DEGRADES an unknown tier to the default
|
|
187
|
+
// tier at fire time; that is a runtime safety net, not a licence to author one.
|
|
188
|
+
const bad = validateAgentDefinition({ ...OK_DEF, model_tier: 'main' }, OPTS);
|
|
189
|
+
assert.equal(bad.ok, false);
|
|
190
|
+
assert.ok(hasCode(bad, 'model_tier_unknown'));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('there is no `main` tier — an agent is a subagent, never the main session', () => {
|
|
194
|
+
assert.equal(MODEL_TIERS.includes('main'), false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// --- THE SCOPE WALL ---------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
await test('scope exceeding the allowed modules is refused, naming the offenders', () => {
|
|
200
|
+
const r = validateAgentDefinition(
|
|
201
|
+
{ ...OK_DEF, scope_modules: ['agents', 'government', 'lifecycle'] },
|
|
202
|
+
{ authorRank: 'archon', allowedModules: ['agents'] },
|
|
203
|
+
);
|
|
204
|
+
assert.equal(r.ok, false);
|
|
205
|
+
assert.ok(hasCode(r, 'scope_out_of_bounds'));
|
|
206
|
+
const msg = r.errors.find((e) => e.code === 'scope_out_of_bounds').message;
|
|
207
|
+
assert.ok(msg.includes('government') && msg.includes('lifecycle'), `must name the offenders: ${msg}`);
|
|
208
|
+
assert.ok(!msg.includes("'agents'"), 'must not accuse the module that WAS allowed');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await test('scope FAILS CLOSED when the caller supplies no allowed-module set', () => {
|
|
212
|
+
// The dangerous default. An empty allowlist must mean "nothing is in scope",
|
|
213
|
+
// never "everything is" — the same rule goal-scope-check's empty_scope holds.
|
|
214
|
+
for (const opts of [{ authorRank: 'archon' }, { authorRank: 'archon', allowedModules: [] }, undefined]) {
|
|
215
|
+
const r = validateAgentDefinition(OK_DEF, opts);
|
|
216
|
+
assert.equal(r.ok, false, `${JSON.stringify(opts)} must fail closed`);
|
|
217
|
+
assert.ok(hasCode(r, 'scope_out_of_bounds'), `${JSON.stringify(opts)} → ${JSON.stringify(codes(r))}`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await test('an empty scope_modules is allowed — it grants nothing', () => {
|
|
222
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: [] }, { authorRank: 'archon', allowedModules: [] });
|
|
223
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
224
|
+
assert.deepEqual(r.value.scope_modules, []);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('a non-array / non-string scope is refused rather than coerced', () => {
|
|
228
|
+
for (const bad of ['agents', 42, {}, ['agents', 7], ['agents', '']]) {
|
|
229
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: bad }, OPTS);
|
|
230
|
+
assert.equal(r.ok, false, `${JSON.stringify(bad)} must be refused`);
|
|
231
|
+
assert.ok(hasCode(r, 'scope_modules_invalid'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await test('a scope_path that escapes the tree is refused', () => {
|
|
236
|
+
for (const bad of ['../../etc/passwd', '/etc/passwd', 'modules/../../..', 'C:\\Windows']) {
|
|
237
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_paths: [bad] }, OPTS);
|
|
238
|
+
assert.equal(r.ok, false, `"${bad}" must be refused`);
|
|
239
|
+
assert.ok(hasCode(r, 'scope_paths_invalid'), `"${bad}" → ${JSON.stringify(codes(r))}`);
|
|
240
|
+
}
|
|
241
|
+
assert.equal(validateAgentDefinition({ ...OK_DEF, scope_paths: ['modules/agents/'] }, OPTS).ok, true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// --- THE RANK WALL (ADR 0016 — authority is the author's LIVE rank) ---------
|
|
245
|
+
|
|
246
|
+
await test('RANK_ORDER is the ladder low→high, and the scope floor is Metic', () => {
|
|
247
|
+
assert.deepEqual(RANK_ORDER, ['xenos', 'thetes', 'metic', 'archon']);
|
|
248
|
+
assert.equal(SCOPE_RANK_FLOOR, 'metic');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await test('a sub-Metic author may not scope an agent onto a PROTECTED module', () => {
|
|
252
|
+
const opts = { allowedModules: ['agents', 'government'], protectedModules: ['government'] };
|
|
253
|
+
for (const rank of ['xenos', 'thetes']) {
|
|
254
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: ['government'] }, { ...opts, authorRank: rank });
|
|
255
|
+
assert.equal(r.ok, false, `${rank} must not reach a protected module`);
|
|
256
|
+
assert.ok(hasCode(r, 'scope_protected_rank'), `${rank} → ${JSON.stringify(codes(r))}`);
|
|
257
|
+
}
|
|
258
|
+
// Metic+ clear the floor.
|
|
259
|
+
for (const rank of ['metic', 'archon']) {
|
|
260
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: ['government'] }, { ...opts, authorRank: rank });
|
|
261
|
+
assert.equal(r.ok, true, `${rank} should clear the floor: ${JSON.stringify(r.errors)}`);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await test('an UNKNOWN author rank fails CLOSED against a protected module', () => {
|
|
266
|
+
// A file cannot grant authority (ADR 0016). If the caller could not name a live
|
|
267
|
+
// rank, the definition has no authority to reach a protected module.
|
|
268
|
+
const opts = { allowedModules: ['government'], protectedModules: ['government'] };
|
|
269
|
+
for (const rank of [undefined, null, '', 'admin', 'ARCHON']) {
|
|
270
|
+
const r = validateAgentDefinition({ ...OK_DEF, scope_modules: ['government'] }, { ...opts, authorRank: rank });
|
|
271
|
+
assert.equal(r.ok, false, `${JSON.stringify(rank)} must fail closed`);
|
|
272
|
+
assert.ok(hasCode(r, 'scope_protected_rank'), `${JSON.stringify(rank)} → ${JSON.stringify(codes(r))}`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test('an unknown rank is NOT fatal when no protected module is in scope', () => {
|
|
277
|
+
// The rank wall guards protected scope only; an ordinary agent does not need a
|
|
278
|
+
// resolved rank to be structurally valid.
|
|
279
|
+
const r = validateAgentDefinition(OK_DEF, { authorRank: 'nonsense', allowedModules: ['agents'] });
|
|
280
|
+
assert.equal(r.ok, true, JSON.stringify(r.errors));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// --- NEVER CRASH THE BOOT ---------------------------------------------------
|
|
284
|
+
|
|
285
|
+
await test('a non-object definition is refused, not thrown on', () => {
|
|
286
|
+
for (const bad of [null, undefined, 'historian', 42, [], true]) {
|
|
287
|
+
let r;
|
|
288
|
+
assert.doesNotThrow(() => { r = validateAgentDefinition(bad, OPTS); }, `${JSON.stringify(bad)} threw`);
|
|
289
|
+
assert.equal(r.ok, false);
|
|
290
|
+
assert.ok(hasCode(r, 'not_an_object'), `${JSON.stringify(bad)} → ${JSON.stringify(codes(r))}`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await test('a CIRCULAR trigger_spec is refused, not thrown on', () => {
|
|
295
|
+
// The size check is over the serialized spec, and JSON.stringify THROWS on a
|
|
296
|
+
// cycle. A definition file cannot express one, but a db-sourced object can —
|
|
297
|
+
// and this validator is the thing standing between that and the boot.
|
|
298
|
+
const spec = { event: 'task.shipped' };
|
|
299
|
+
spec.self = spec;
|
|
300
|
+
let r;
|
|
301
|
+
assert.doesNotThrow(() => { r = validateAgentDefinition({ ...OK_DEF, trigger_spec: spec }, OPTS); });
|
|
302
|
+
assert.equal(r.ok, false);
|
|
303
|
+
assert.ok(hasCode(r, 'trigger_spec_unserializable'), JSON.stringify(codes(r)));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await test('an oversized trigger_spec is refused at the DB CHECK bound', () => {
|
|
307
|
+
const r = validateAgentDefinition(
|
|
308
|
+
{ ...OK_DEF, trigger_spec: { event: 'task.shipped', pad: 'x'.repeat(9000) } }, OPTS,
|
|
309
|
+
);
|
|
310
|
+
assert.equal(r.ok, false);
|
|
311
|
+
assert.ok(hasCode(r, 'trigger_spec_too_long'));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await test('a hostile getter cannot escape the validator', () => {
|
|
315
|
+
// trigger_spec is PARAMETERS, never payload — and a throwing accessor must
|
|
316
|
+
// become a refusal, not an exception into the loader.
|
|
317
|
+
const hostile = { name: 'historian', persona: 'x', trigger_type: 'event' };
|
|
318
|
+
Object.defineProperty(hostile, 'trigger_spec', { get() { throw new Error('boom'); }, enumerable: true });
|
|
319
|
+
let r;
|
|
320
|
+
assert.doesNotThrow(() => { r = validateAgentDefinition(hostile, OPTS); });
|
|
321
|
+
assert.equal(r.ok, false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await test('EVERY error carries a code, a field and a human message', () => {
|
|
325
|
+
const r = validateAgentDefinition(
|
|
326
|
+
{ name: 'BAD NAME', trigger_type: 'cron', trigger_spec: 'nope', scope_modules: ['government'] },
|
|
327
|
+
{ authorRank: 'xenos', allowedModules: ['agents'] },
|
|
328
|
+
);
|
|
329
|
+
assert.equal(r.ok, false);
|
|
330
|
+
assert.ok(r.errors.length >= 4, `expected the whole list, got ${JSON.stringify(codes(r))}`);
|
|
331
|
+
for (const e of r.errors) {
|
|
332
|
+
assert.equal(typeof e.code, 'string', JSON.stringify(e));
|
|
333
|
+
assert.equal(typeof e.field, 'string', JSON.stringify(e));
|
|
334
|
+
assert.ok(e.message && e.message.length > 10, `message too thin: ${JSON.stringify(e)}`);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await test('validation reports ALL faults at once, not just the first', () => {
|
|
339
|
+
// An author fixing a definition file should not have to re-run four times.
|
|
340
|
+
const r = validateAgentDefinition({ name: 'X', persona: '', trigger_type: 'cron' }, OPTS);
|
|
341
|
+
for (const code of ['name_invalid', 'persona_missing', 'trigger_type_unknown']) {
|
|
342
|
+
assert.ok(hasCode(r, code), `${code} missing from ${JSON.stringify(codes(r))}`);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
await test('the validator never MUTATES the definition it was handed', () => {
|
|
347
|
+
const def = { ...OK_DEF, trigger_spec: { event: 'task.shipped' } };
|
|
348
|
+
const before = JSON.stringify(def);
|
|
349
|
+
validateAgentDefinition(def, OPTS);
|
|
350
|
+
assert.equal(JSON.stringify(def), before);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
console.log(`\nagents_validate: ${passed} passed, ${failed} failed`);
|
|
354
|
+
if (failed > 0) process.exit(1);
|
package/tests/mingle.mjs
CHANGED
|
@@ -283,6 +283,32 @@ test('a declined pair may be introduced again — a decline is not a permanent s
|
|
|
283
283
|
assert.equal(mingle.planPairs(eligible, { excludePairs: new Set() }).length, 1);
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
test('the roster COUNT sees both sides of a pair, and only open ones', async () => {
|
|
287
|
+
// task 1003178 (C08) added countOpenPairsFor for the needs roster, and the
|
|
288
|
+
// grader was right that it shipped untested while every other DB function here
|
|
289
|
+
// is driven against the recording pool. A count is exactly the kind of query
|
|
290
|
+
// that goes wrong quietly: miss a side of the pair and half the hall never
|
|
291
|
+
// learns it has an introduction waiting.
|
|
292
|
+
const pool = {
|
|
293
|
+
queries: [],
|
|
294
|
+
async query(sql, params) {
|
|
295
|
+
this.queries.push({ sql, params });
|
|
296
|
+
return { rows: [{ n: 3 }] };
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
const n = await mingle.countOpenPairsFor(42, { pool });
|
|
300
|
+
assert.equal(n, 3);
|
|
301
|
+
const { sql, params } = pool.queries[0];
|
|
302
|
+
assert.match(sql, /builder_lo = \$1 OR builder_hi = \$1/, 'either side of the pair counts');
|
|
303
|
+
assert.match(sql, /status = 'proposed'/, 'a settled introduction is not waiting on anyone');
|
|
304
|
+
assert.deepEqual(params, [42]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('the count answers 0 rather than undefined on an empty read', async () => {
|
|
308
|
+
const pool = { async query() { return { rows: [] }; } };
|
|
309
|
+
assert.equal(await mingle.countOpenPairsFor(42, { pool }), 0);
|
|
310
|
+
});
|
|
311
|
+
|
|
286
312
|
test('the exclude set is built from OPEN pairs only, never from declines', async () => {
|
|
287
313
|
const pool = recordingPool({ openPairs: [['1', '2']] });
|
|
288
314
|
await mingle.openPairKeys({ pool });
|
|
@@ -116,6 +116,17 @@ const STATIC_LADDER_FILES = [
|
|
|
116
116
|
// which holds it against catalog.RANK_ORDER and holds the whole browser parser
|
|
117
117
|
// equal to the server one.
|
|
118
118
|
'modules/hall-ui/public/government.js',
|
|
119
|
+
// task 1002487: the pure agent-definition validator applies the ADR 0043
|
|
120
|
+
// sub-Metic floor to an agent's declared scope, so it needs the ladder to
|
|
121
|
+
// compare against. It is one of the "dependency-free" cases this array exists
|
|
122
|
+
// for, and for a sharper reason than the browser statics above: its whole
|
|
123
|
+
// contract is to be checkable with NO database, and the doorway it would
|
|
124
|
+
// otherwise import (src/module-api.js) eagerly requires ./bongos/pool at load.
|
|
125
|
+
// Deriving the ladder from api.LIVE_RANK_LADDER would therefore drag a
|
|
126
|
+
// Postgres pool into a validator that must run in a bare unit test — so the
|
|
127
|
+
// copy is pinned here instead, which is what makes it provable rather than
|
|
128
|
+
// merely allowed.
|
|
129
|
+
'modules/agents/lib/validate.js',
|
|
119
130
|
];
|
|
120
131
|
test('static ladder copies carry the exact canonical ladder', () => {
|
|
121
132
|
for (const rel of STATIC_LADDER_FILES) {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// tests/social_mode.mjs — with social on, the HALL changes; it does not gain a
|
|
2
|
+
// tab (task 1003178 / C08, goal 1000075; criterion collab-hall-becomes-a-mode).
|
|
3
|
+
//
|
|
4
|
+
// The criterion is unusual in that it states its own DISQUALIFIER: "a /social
|
|
5
|
+
// route with the same hall around it does NOT satisfy it". So the first block
|
|
6
|
+
// below tests the absence — no route, no nav item, no page file — and the rest
|
|
7
|
+
// tests that existing surfaces read differently instead.
|
|
8
|
+
//
|
|
9
|
+
// The owner's directive, verbatim in the goal: social "is certainly something
|
|
10
|
+
// that should be scoped into the ENTIRE builders hall, not a single social tab".
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
|
|
21
|
+
|
|
22
|
+
const SHELL = read('modules', 'hall-ui', 'public', 'shell.js');
|
|
23
|
+
const HALL_RENDER = read('modules', 'hall-ui', 'public', 'hall-render.js');
|
|
24
|
+
const COLLAB_JS = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
25
|
+
const COLLAB_HTML = read('modules', 'hall-ui', 'public', 'collab.html');
|
|
26
|
+
const ME = read('src', 'bongos', 'routes', 'me.js');
|
|
27
|
+
const NEEDS = read('modules', 'builder-settings', 'builder-needs.js');
|
|
28
|
+
const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
|
|
29
|
+
|
|
30
|
+
// ── 1. THE DISQUALIFIER: social is not a destination ────────────────────────
|
|
31
|
+
|
|
32
|
+
test('THE DISQUALIFIER: there is no /social page in the hall', () => {
|
|
33
|
+
const hall = fs.readdirSync(path.join(ROOT, 'modules', 'hall-ui', 'public'));
|
|
34
|
+
for (const f of hall) {
|
|
35
|
+
assert.ok(!/^social\.(html|js|css)$/.test(f),
|
|
36
|
+
`${f} exists — a social PAGE is exactly what this criterion refuses`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('THE DISQUALIFIER: no /social route is served, and no page gate names one', () => {
|
|
41
|
+
const serve = read('src', 'bongos', 'serve-internal.js');
|
|
42
|
+
assert.ok(!/\/social\b/.test(serve), 'serve-internal routes nothing at /social');
|
|
43
|
+
const oracle = read('tests', 'hall_page_gate_map.mjs');
|
|
44
|
+
assert.ok(!/'\/builders\/social'/.test(oracle), 'the page-gate oracle has no /social row');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('THE DISQUALIFIER: social adds no nav item', () => {
|
|
48
|
+
// A nav entry would BE the tab. The whole mechanism is an attribute on the
|
|
49
|
+
// document, so there is nothing to link to.
|
|
50
|
+
//
|
|
51
|
+
// The end marker is NAV_COLLAPSE_KEY, which follows the model. An earlier
|
|
52
|
+
// draft sliced to `function icon(` — which is DEFINED ABOVE NAV, so the slice
|
|
53
|
+
// returned an empty string and the assertion passed over nothing. It only
|
|
54
|
+
// surfaced because adding a real Social tab as a mutation did not red it.
|
|
55
|
+
const start = SHELL.indexOf('var NAV = [');
|
|
56
|
+
const end = SHELL.indexOf('var NAV_COLLAPSE_KEY');
|
|
57
|
+
assert.ok(start > -1 && end > start, 'the NAV model was actually located');
|
|
58
|
+
const nav = SHELL.slice(start, end);
|
|
59
|
+
assert.ok(nav.includes("id: 'roster'"), 'and the slice really holds the nav items');
|
|
60
|
+
assert.ok(!/social/i.test(nav), 'the nav model must not mention social');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── 2. SOCIAL IS A MODE, STAMPED ON THE DOCUMENT ────────────────────────────
|
|
64
|
+
|
|
65
|
+
test('the shell stamps the mode on the document root, beside data-theme', () => {
|
|
66
|
+
// An attribute on <html> is what lets ANY surface — a stylesheet, a page
|
|
67
|
+
// script, a widget — read the mode without a route or a second fetch. That is
|
|
68
|
+
// the structural difference between a mode and a page.
|
|
69
|
+
assert.match(SHELL, /function applySocial\(social\)/);
|
|
70
|
+
assert.match(SHELL, /root\.dataset\.social = 'on'/);
|
|
71
|
+
assert.match(SHELL, /delete root\.dataset\.social/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('OFF is the default and the fallback — null is not on', () => {
|
|
75
|
+
// /me.social is null when lifecycle is disabled or the read failed. A mode
|
|
76
|
+
// that cannot be read is not on.
|
|
77
|
+
const fn = SHELL.slice(SHELL.indexOf('function applySocial'));
|
|
78
|
+
const body = fn.slice(0, fn.indexOf('\n }'));
|
|
79
|
+
assert.match(body, /social && social\.enabled === true/,
|
|
80
|
+
'only an explicit enabled:true turns the mode on');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('BOTH switches decide it, and the client never re-derives them', () => {
|
|
84
|
+
// C07 resolved the pair (project enabled AND builder opted in) server-side.
|
|
85
|
+
// A client that recomputed the mode could disagree with the rest of the hall.
|
|
86
|
+
assert.ok(!/opted_in|project_enabled/.test(SHELL),
|
|
87
|
+
'the shell reads `enabled`, never the two switches it came from');
|
|
88
|
+
assert.match(ME, /socialModeFor/);
|
|
89
|
+
assert.match(ME, /social: socialMode/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('the mode rides /me, which every hall page already fetches', () => {
|
|
93
|
+
// This is why no new route is needed — and why one would be the wrong shape.
|
|
94
|
+
assert.match(SHELL, /social: me\.social \|\| null/);
|
|
95
|
+
assert.match(HALL_RENDER, /social: \(ctx\.lastMeState && ctx\.lastMeState\.social\) \|\| null/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the index passes the mode too — the busiest page is not the one it misses', () => {
|
|
99
|
+
// renderNav takes (builder, onboarding) and is called from five sites, so the
|
|
100
|
+
// value must come off the live ctx getter rather than a local `me`.
|
|
101
|
+
const fn = HALL_RENDER.slice(HALL_RENDER.indexOf('function renderNav('));
|
|
102
|
+
const body = fn.slice(0, fn.indexOf('\n }'));
|
|
103
|
+
assert.ok(!/\bme\?\./.test(body), 'renderNav has no `me` in scope — reading one would throw');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a social read can never break a profile', () => {
|
|
107
|
+
// /me is the session-open hot path. Same fail-open posture as the C01/C03
|
|
108
|
+
// reads it sits beside.
|
|
109
|
+
const slot = ME.slice(ME.indexOf('socialModeFor') - 600, ME.indexOf('socialModeFor') + 200);
|
|
110
|
+
assert.match(slot, /catch \(_\) \{ return null; \}/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── 3. EXISTING SURFACES READ DIFFERENTLY ───────────────────────────────────
|
|
114
|
+
|
|
115
|
+
test('HOME changes: an introduction becomes a need in the roster', () => {
|
|
116
|
+
// The needs roster is how a collab feature reaches the whole hall — it is
|
|
117
|
+
// where C01 and C03 already live, and it renders on Home's Standing card and
|
|
118
|
+
// in the CLI nudge without either learning about mingle.
|
|
119
|
+
const roster = needs.computeNeeds({ openMingleIntroductions: 2 }).items;
|
|
120
|
+
const row = roster.find((n) => n.id === 'mingle_introductions');
|
|
121
|
+
assert.ok(row, 'the need is produced');
|
|
122
|
+
assert.equal(row.state, 'action_needed');
|
|
123
|
+
assert.match(row.action.href, /\/#\/collab/, 'it points at an EXISTING surface, not a new one');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('no introductions means no row — the roster is not padded', () => {
|
|
127
|
+
assert.equal(needs.computeNeeds({ openMingleIntroductions: 0 }).items.find((n) => n.id === 'mingle_introductions'), undefined);
|
|
128
|
+
assert.equal(needs.computeNeeds({}).items.find((n) => n.id === 'mingle_introductions'), undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the invitation never pressures — declining is free, so the copy says so', () => {
|
|
132
|
+
// Mingle is declinable without penalty (C07, ADR 0187 §5). Wording that
|
|
133
|
+
// pressured the reader would BE the penalty the decision forbids — the same
|
|
134
|
+
// argument the recommendation need makes, and it matters more for a social
|
|
135
|
+
// invitation than for a task.
|
|
136
|
+
const row = needs.computeNeeds({ openMingleIntroductions: 1 }).items;
|
|
137
|
+
const n = row.find((x) => x.id === 'mingle_introductions');
|
|
138
|
+
assert.equal(n.severity, 'info', 'info, never a demand');
|
|
139
|
+
assert.match(n.message, /costs nothing/);
|
|
140
|
+
assert.match(n.message, /does not change how often you are paired/,
|
|
141
|
+
'and it names the no-future-weight half, which is the part a reader would doubt');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('COLLAB changes: the introductions panel is revealed BY THE MODE', () => {
|
|
145
|
+
// Not by a rank, not by a route, not by a second fetch — by the document
|
|
146
|
+
// attribute the shell stamped. That is what "the surface reads differently"
|
|
147
|
+
// means in practice.
|
|
148
|
+
assert.match(COLLAB_JS, /document\.documentElement\.dataset\.social === 'on'/);
|
|
149
|
+
assert.match(COLLAB_HTML, /id="cb-mingle-section"[^>]*hidden/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('with social OFF the page is exactly what it was, and costs nothing extra', () => {
|
|
153
|
+
// A page that fetched anyway would make "social is off" cost a request on
|
|
154
|
+
// every load, and a panel that rendered empty would still be a visible change.
|
|
155
|
+
const fn = COLLAB_JS.slice(COLLAB_JS.indexOf('async function loadMingle'));
|
|
156
|
+
const body = fn.slice(0, fn.indexOf('\n }'));
|
|
157
|
+
const guard = body.indexOf('if (!socialOn())');
|
|
158
|
+
assert.ok(guard > -1, 'the mode is checked');
|
|
159
|
+
assert.ok(guard < body.indexOf("api.request('GET', '/mingle')"),
|
|
160
|
+
'and it is checked BEFORE anything is fetched');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('the introductions read is best-effort — it cannot take the page down', () => {
|
|
164
|
+
// Help requests ARE the Collab page; the social half is additive.
|
|
165
|
+
assert.match(COLLAB_JS, /try \{ await loadMingle\(\); \} catch \(_\)/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('the row is written from the VIEWER\'s side, without a second /me', () => {
|
|
169
|
+
// A pair carries builder_lo/builder_hi with no marker for "you", so the route
|
|
170
|
+
// says which side the caller is rather than making the page fetch to find out.
|
|
171
|
+
const routes = read('modules', 'lifecycle', 'routes', 'mingle.js');
|
|
172
|
+
assert.match(routes, /you: String\(req\.builder\.id\)/);
|
|
173
|
+
assert.match(COLLAB_JS, /r\.data\.you/);
|
|
174
|
+
assert.match(COLLAB_JS, /function otherOf\(pair, meId\)/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('answering an introduction reads neutrally either way', () => {
|
|
178
|
+
// "Declined" must not read as a failure. A disappointed toast would be the
|
|
179
|
+
// penalty C07 forbids, delivered by the UI instead of the schema.
|
|
180
|
+
assert.match(COLLAB_JS, /Not now\. Nothing changes for you\./);
|
|
181
|
+
});
|