@bongos/core 1.25.10 → 1.25.12
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 +92 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +108 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +195 -3
- package/docs/api-reference.md +10 -1
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +142 -142
- package/modules/front-door-guide/CLAUDE.md +15 -0
- package/modules/front-door-guide/claude.js +51 -0
- package/modules/front-door-guide/db.js +72 -0
- package/modules/front-door-guide/guide.js +139 -0
- package/modules/front-door-guide/limits.js +87 -0
- package/modules/front-door-guide/migrations/front_door_guide_001_conversations.sql +48 -0
- package/modules/front-door-guide/module.json +27 -0
- package/modules/front-door-guide/routes/front-door-guide.js +206 -0
- package/modules/front-door-guide/settings.js +68 -0
- package/modules/government/docket-vocab.js +1 -1
- package/modules/hall-ui/public/street-card.js +4 -0
- package/modules/hall-ui/public/street-desk.js +3 -3
- package/modules/hall-ui/public/street-inside.js +2 -2
- package/modules/provisioning/starter-bundles.js +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/bongos/publish-manifest.js +1 -0
- package/src/bongos/db-kernel.js +6 -2
- package/src/bongos/module-scope-map.js +5 -0
- package/src/bongos/routes/builders.js +26 -0
- package/src/module-api.js +1 -1
- package/tests/builder_card_requirement.mjs +28 -0
- package/tests/front_door_guide.mjs +410 -0
- package/tests/hall_street_desk.mjs +1 -1
- package/tests/hall_street_inside.mjs +1 -1
- package/tests/module_loader.mjs +1 -1
|
@@ -82,7 +82,9 @@ const ALWAYS_ON_CORE = Object.freeze([
|
|
|
82
82
|
// bills AI generation through Cloud Bongos's own payment processor account, so it stays on
|
|
83
83
|
// the hub until a project can bring its own billing. `market-watch` sat here while it was
|
|
84
84
|
// empty; task 1004938 shipped the owner's on/off switch and moved it to OPTIONAL_MODULES.
|
|
85
|
-
|
|
85
|
+
// `front-door-guide` (task 1005272) is the hub's start-a-project chat on Cloud Bongos's own
|
|
86
|
+
// Anthropic key, so a new project gains nothing by switching it on.
|
|
87
|
+
const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise', 'ai-studio', 'front-door-guide']);
|
|
86
88
|
|
|
87
89
|
// The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
|
|
88
90
|
// every module an owner may actually choose, in the order the picker lists them,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.12",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.25.
|
|
9
|
+
"version": "1.25.12",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.12",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -11766,5 +11766,21 @@
|
|
|
11766
11766
|
"id": "1005267",
|
|
11767
11767
|
"text": "The hall's panels, menus, dialogs, toasts, top bar and city labels now share one smoked-glass look that softens whatever is behind them; phones keep solid panels."
|
|
11768
11768
|
}
|
|
11769
|
+
],
|
|
11770
|
+
"1.25.11": [
|
|
11771
|
+
{
|
|
11772
|
+
"id": "1005268",
|
|
11773
|
+
"text": "An Archon can now switch the card requirement on or off for a builder straight from the People roster."
|
|
11774
|
+
}
|
|
11775
|
+
],
|
|
11776
|
+
"1.25.12": [
|
|
11777
|
+
{
|
|
11778
|
+
"id": "1005272",
|
|
11779
|
+
"text": "Visitors who press start a project can now talk with an AI guide about what they want to build, with strict limits on messages and spending, and a plain form whenever the guide is unavailable."
|
|
11780
|
+
},
|
|
11781
|
+
{
|
|
11782
|
+
"id": "1005288",
|
|
11783
|
+
"text": "Decided how every project gets a sandbox: a free copy of the app that always shows the newest finished work, while the real site only changes when a person presses promote. Fake data and test keys by default. Costs the owner n"
|
|
11784
|
+
}
|
|
11769
11785
|
]
|
|
11770
11786
|
}
|
|
@@ -78,6 +78,7 @@ const PUBLISH_ALLOWLIST = [
|
|
|
78
78
|
'modules/render-deploy/', // the deploy page of a project whose app deploys through Render (task 1004270). default:false. MUST be here for the npm-release reason: modules/hall-ui/ ships as one subtree, so a released core without it carries deploy.js's loader for #rd-panel with no routes behind it.
|
|
79
79
|
'modules/market-watch/', // the weekly AI market watch (task 1004935, goal 1000137). default:false and empty for now, but MUST be here: shipped is not enabled, and a released core without it answers "unknown module" to an instance that turns it on; tests/market_watch_module.mjs (publishable) also reads its manifest.
|
|
80
80
|
'modules/ai-studio/', // AI generation for artists (task 1004916, ADR 0392): keys, prepaid balance, generate. default:false.
|
|
81
|
+
'modules/front-door-guide/', // the start-a-project guide (task 1005272): a capped AI chat for a signed-out visitor on the platform's key. default:false, hub first; shipped is not enabled.
|
|
81
82
|
'modules/raise/', // the /raise investor page (task 1004847, ADR 0384) — ships in core, enabled ONLY on the hub, like platform-identity. Owner decision 2026-10-25: the whole page, its copy and starting numbers included, lives in the repo so builders can contribute to it, accepting that it publishes with the core. Secrets never do: the investor password is RAISE_PASSWORD_HASH in the instance env. default:false.
|
|
82
83
|
'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
|
|
83
84
|
// --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -84,10 +84,14 @@ async function getFoundingBuilder() {
|
|
|
84
84
|
// Archon-only); id/login/name/avatar/rank is no more sensitive than what
|
|
85
85
|
// GET /leaderboard already exposes publicly, just without the credits cutoff.
|
|
86
86
|
// Accounts tagged is_tester (migration core_278) stay out unless the caller is a
|
|
87
|
-
// management read that has to see them to untag them (includeTesters).
|
|
88
|
-
|
|
87
|
+
// management read that has to see them to untag them (includeTesters). Card state
|
|
88
|
+
// (require_card, has_card) is an Archon-desk fact, never part of the any-builder
|
|
89
|
+
// directory: it says which accounts lack a card. Ask for it with includeCardState.
|
|
90
|
+
async function listActiveBuilders({ includeTesters = false, includeCardState = false } = {}) {
|
|
89
91
|
const { rows } = await pool.query(
|
|
90
92
|
`SELECT id, github_login, display_name, avatar_url, rank
|
|
93
|
+
${includeCardState ? `, require_card,
|
|
94
|
+
EXISTS (SELECT 1 FROM passkey_credentials c WHERE c.builder_id = builders.id) AS has_card` : ''}
|
|
91
95
|
FROM builders
|
|
92
96
|
WHERE status = 'active'
|
|
93
97
|
AND (is_tester = false OR $1::boolean)
|
|
@@ -186,6 +186,11 @@ const MODULE_GLOBS = {
|
|
|
186
186
|
'ai-studio': [
|
|
187
187
|
'modules/ai-studio/',
|
|
188
188
|
],
|
|
189
|
+
// The start-a-project guide (task 1005272, goal 1000106): a capped AI chat for a
|
|
190
|
+
// signed-out visitor. Its test sits at the repo root, listed by exact path.
|
|
191
|
+
'front-door-guide': [
|
|
192
|
+
'modules/front-door-guide/', 'tests/front_door_guide.mjs',
|
|
193
|
+
],
|
|
189
194
|
// The /raise investor page (task 1004847, ADR 0384): hub-only.
|
|
190
195
|
raise: [
|
|
191
196
|
'modules/raise/',
|
|
@@ -209,6 +209,30 @@ function contributeBuilderDesk() {
|
|
|
209
209
|
});
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
// The People Cards desk (task 1005268; ADR 0382 D4): one row per builder with a
|
|
213
|
+
// registered hardware card, and the one act the viewer holding builder.rank.set (the
|
|
214
|
+
// card-requirement route's own atom) may take — require a card, or stop requiring it.
|
|
215
|
+
// It is its own desk because a roster row holds at most two buttons. Never the
|
|
216
|
+
// viewer's own row: the route refuses switching your own requirement off. Pure;
|
|
217
|
+
// exported for tests.
|
|
218
|
+
function cardDeskRows(builders, { viewerId }) {
|
|
219
|
+
return (builders || []).filter((b) => b && String(b.id) !== String(viewerId) && (b.has_card === true || b.require_card === true)).map((b) => {
|
|
220
|
+
const on = b.require_card === true;
|
|
221
|
+
return {
|
|
222
|
+
id: b.id, name: b.display_name || b.github_login || `builder ${b.id}`, fact: on ? 'card required' : 'card optional',
|
|
223
|
+
facts: b.github_login ? [`@${b.github_login}`] : [], ref: { type: 'builder_card', id: b.id },
|
|
224
|
+
acts: [{ key: 'card', options: [on ? { key: 'off', label: 'stop requiring a card' } : { key: 'on', label: 'require a card' }] }],
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function contributeCardDesk() {
|
|
229
|
+
seams.contribute('governor.docket', {
|
|
230
|
+
desk: 'people.cards',
|
|
231
|
+
gate: { permission: 'builder.rank.set' },
|
|
232
|
+
read: async (ctx) => cardDeskRows(await db.listActiveBuilders({ includeTesters: true, includeCardState: true }), { viewerId: ctx.builderId }),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
212
236
|
// The Treasury desk's budget rows (task 1004561): one row per active builder,
|
|
213
237
|
// their monthly spending limit as the fact, and the limits a viewer holding
|
|
214
238
|
// builder.budget.set (PATCH /builders/:id/budget's own atom) may set in one tap.
|
|
@@ -264,6 +288,7 @@ function contributeTreasury() {
|
|
|
264
288
|
|
|
265
289
|
module.exports = function buildBuildersRouter() {
|
|
266
290
|
contributeBuilderDesk();
|
|
291
|
+
contributeCardDesk();
|
|
267
292
|
contributeTreasury();
|
|
268
293
|
const router = express.Router();
|
|
269
294
|
|
|
@@ -898,5 +923,6 @@ module.exports = function buildBuildersRouter() {
|
|
|
898
923
|
};
|
|
899
924
|
|
|
900
925
|
module.exports.builderDeskRows = builderDeskRows;
|
|
926
|
+
module.exports.cardDeskRows = cardDeskRows;
|
|
901
927
|
module.exports.fireDiscordBroadcast = fireDiscordBroadcast;
|
|
902
928
|
module.exports.budgetDeskRows = budgetDeskRows;
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.25.
|
|
84
|
+
const CORE_VERSION = '1.25.12'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -72,4 +72,32 @@ await test('route: rank atom + step-up gate, self-disable refused, 409 mapped, e
|
|
|
72
72
|
assert.equal(classify('cannot_disable_own_card_requirement'), 'forbidden');
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
await test('Cards desk: one row per builder with a card, one act, never the viewer or a cardless builder', () => {
|
|
76
|
+
const { cardDeskRows, builderDeskRows } = require('../src/bongos/routes/builders');
|
|
77
|
+
const rows = cardDeskRows([
|
|
78
|
+
{ id: 1, has_card: true },
|
|
79
|
+
{ id: 2 },
|
|
80
|
+
{ id: 3, has_card: true },
|
|
81
|
+
{ id: 4, has_card: true, require_card: true },
|
|
82
|
+
], { viewerId: '1' });
|
|
83
|
+
assert.deepEqual(rows.map((r) => r.id), [3, 4]);
|
|
84
|
+
assert.deepEqual(rows[0].acts, [{ key: 'card', options: [{ key: 'on', label: 'require a card' }] }]);
|
|
85
|
+
assert.deepEqual(rows[1].acts[0].options.map((o) => o.key), ['off']);
|
|
86
|
+
assert.equal(rows[1].fact, 'card required');
|
|
87
|
+
assert.equal(rows[0].ref.type, 'builder_card');
|
|
88
|
+
// the roster keeps its two buttons: tier + deactivate, no card act
|
|
89
|
+
const roster = builderDeskRows([{ id: 3, rank: 'xenos', has_card: true }], { viewerId: '1', mayRank: true, mayStop: true, ranks: ['xenos', 'thetes'] });
|
|
90
|
+
assert.deepEqual(roster[0].acts.map((a) => a.key), ['tier', 'deactivate']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await test('hall: the card act posts to the card-requirement route, under its own ref type', async () => {
|
|
94
|
+
const fs = await import('node:fs');
|
|
95
|
+
const src = fs.readFileSync(new URL('../modules/hall-ui/public/street-card.js', import.meta.url), 'utf8');
|
|
96
|
+
const at = src.indexOf('ACT_ROUTES.builder_card = {');
|
|
97
|
+
assert.ok(at > 0, 'the card act has its own ref type');
|
|
98
|
+
const block = src.slice(at, src.indexOf('};', at));
|
|
99
|
+
assert.ok(block.includes("'/builders/' + id + '/card-requirement'"));
|
|
100
|
+
assert.ok(block.includes("require_card: str(inp.option) === 'on'"));
|
|
101
|
+
});
|
|
102
|
+
|
|
75
103
|
summary();
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// tests/front_door_guide.mjs — the start-a-project guide's server (task 1005272): a
|
|
2
|
+
// signed-out visitor starts a chat, talks, and every wall holds — the message cap, the
|
|
3
|
+
// length limit, the per-visitor spend cap, the daily and monthly budget cut-off to the
|
|
4
|
+
// form, the missing-key fallback, a refused reply, and a scripted abuse run.
|
|
5
|
+
//
|
|
6
|
+
// Drives the REAL router over an in-memory db and a mocked Claude client, so it needs no
|
|
7
|
+
// Postgres, no network and no key, and spends no money. Settings come from the
|
|
8
|
+
// environment, re-read per request, so each test sets the knobs it is about.
|
|
9
|
+
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const db = require('../modules/front-door-guide/db.js');
|
|
17
|
+
const claude = require('../modules/front-door-guide/claude.js');
|
|
18
|
+
const G = require('../modules/front-door-guide/guide.js');
|
|
19
|
+
|
|
20
|
+
const KEY = 'sk-ant-api03-TEST-ONLY-NOT-A-REAL-KEY-0123456789';
|
|
21
|
+
const ENV = 'BONGOS_FRONT_DOOR_GUIDE_';
|
|
22
|
+
|
|
23
|
+
// ---- an in-memory db with the same rules as the SQL ----------------------------------
|
|
24
|
+
|
|
25
|
+
const convs = new Map();
|
|
26
|
+
let callRows = [];
|
|
27
|
+
let seq = 0;
|
|
28
|
+
let baselineDay = 0; // spend already on the books today, outside these tests
|
|
29
|
+
let baselineMonth = 0;
|
|
30
|
+
const copy = (c) => c && { ...c, messages: JSON.parse(JSON.stringify(c.messages)) };
|
|
31
|
+
Object.assign(db, {
|
|
32
|
+
createConversation: async (tokenHash, visitorHash) => {
|
|
33
|
+
const c = { id: String(++seq), token_hash: tokenHash, visitor_hash: visitorHash, messages: [], visitor_messages: 0, spend_micro: 0, status: 'open', end_reason: null, created_at: new Date(Date.now() - 10_000).toISOString() };
|
|
34
|
+
convs.set(c.id, c);
|
|
35
|
+
return copy(c);
|
|
36
|
+
},
|
|
37
|
+
getConversation: async (id, tokenHash) => { const c = convs.get(String(id)); return c && c.token_hash === tokenHash ? copy(c) : null; },
|
|
38
|
+
siteSpendMicro: async (period) => callRows.reduce((s, r) => s + r.cost_micro, period === 'day' ? baselineDay : baselineMonth),
|
|
39
|
+
visitorUsage: async (visitorHash) => {
|
|
40
|
+
const mine = callRows.filter((r) => r.visitor_hash === visitorHash);
|
|
41
|
+
return { messages: mine.length, spendMicro: mine.reduce((s, r) => s + r.cost_micro, 0) };
|
|
42
|
+
},
|
|
43
|
+
recordTurn: async (convId, { messages, status, endReason }, call) => {
|
|
44
|
+
callRows.push({ conversation_id: String(convId), visitor_hash: call.visitorHash, cost_micro: call.costMicro, model: call.model, input_tokens: call.inputTokens, output_tokens: call.outputTokens, stop_reason: call.stopReason });
|
|
45
|
+
const c = convs.get(String(convId));
|
|
46
|
+
Object.assign(c, { messages, status, end_reason: endReason, visitor_messages: c.visitor_messages + 1, spend_micro: c.spend_micro + call.costMicro });
|
|
47
|
+
return copy(c);
|
|
48
|
+
},
|
|
49
|
+
endConversation: async (convId, reason) => Object.assign(convs.get(String(convId)), { status: 'ended', end_reason: reason }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---- the mocked Claude client: records every request, answers from a script ----------
|
|
53
|
+
|
|
54
|
+
let requests = [];
|
|
55
|
+
let respond = null;
|
|
56
|
+
let delayMs = 0;
|
|
57
|
+
let clientKeys = [];
|
|
58
|
+
const normalReply = () => ({
|
|
59
|
+
model: 'claude-opus-5-5', stop_reason: 'end_turn',
|
|
60
|
+
content: [{ type: 'thinking', thinking: '' }, { type: 'text', text: 'Sounds useful. Who will use it first?' }],
|
|
61
|
+
usage: { input_tokens: 800, output_tokens: 200 },
|
|
62
|
+
});
|
|
63
|
+
claude.createClient = (apiKey) => {
|
|
64
|
+
clientKeys.push(apiKey);
|
|
65
|
+
return { messages: { create: async (req) => {
|
|
66
|
+
requests.push(req);
|
|
67
|
+
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
|
|
68
|
+
return respond ? respond(req) : normalReply();
|
|
69
|
+
} } };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ---- the real router over http ----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const express = require('express');
|
|
75
|
+
const app = express();
|
|
76
|
+
app.set('trust proxy', true); // so each test can be a different visitor via X-Forwarded-For
|
|
77
|
+
app.use(express.json({ limit: '64kb' }));
|
|
78
|
+
app.use('/api/bongos', require('../modules/front-door-guide/routes/front-door-guide.js')());
|
|
79
|
+
const server = app.listen(0);
|
|
80
|
+
await new Promise((r) => server.once('listening', r));
|
|
81
|
+
const PORT = server.address().port;
|
|
82
|
+
|
|
83
|
+
const UA = 'Mozilla/5.0 (test browser)';
|
|
84
|
+
function call(method, path, { body, ip = '10.0.0.1', ua = UA, headers = {} } = {}) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const data = body === undefined ? null : JSON.stringify(body);
|
|
87
|
+
const h = { 'content-type': 'application/json', 'x-forwarded-for': ip, ...headers };
|
|
88
|
+
if (ua) h['user-agent'] = ua;
|
|
89
|
+
const req = http.request({ host: '127.0.0.1', port: PORT, method, path: `/api/bongos${path}`, headers: h }, (res) => {
|
|
90
|
+
let buf = '';
|
|
91
|
+
res.on('data', (c) => { buf += c; });
|
|
92
|
+
res.on('end', () => resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null, raw: buf }));
|
|
93
|
+
});
|
|
94
|
+
req.on('error', reject);
|
|
95
|
+
if (data) req.write(data);
|
|
96
|
+
req.end();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let ipSeq = 0;
|
|
101
|
+
const freshIp = () => `10.1.${Math.floor(++ipSeq / 250)}.${ipSeq % 250}`;
|
|
102
|
+
|
|
103
|
+
function setEnv(over = {}) {
|
|
104
|
+
for (const k of Object.keys(process.env)) if (k.startsWith(ENV)) delete process.env[k];
|
|
105
|
+
const all = {
|
|
106
|
+
ANTHROPIC_API_KEY: KEY, MIN_FIRST_MESSAGE_MS: '1', STARTS_PER_HOUR: '1000', MESSAGES_PER_MINUTE: '1000',
|
|
107
|
+
...over,
|
|
108
|
+
};
|
|
109
|
+
for (const [k, v] of Object.entries(all)) if (v !== null) process.env[ENV + k] = String(v);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function reset(over) {
|
|
113
|
+
setEnv(over);
|
|
114
|
+
convs.clear(); callRows = []; requests = []; clientKeys = []; respond = null; delayMs = 0;
|
|
115
|
+
baselineDay = 0; baselineMonth = 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function start(ip) {
|
|
119
|
+
const r = await call('POST', '/front-door-guide/conversations', { body: {}, ip });
|
|
120
|
+
return r;
|
|
121
|
+
}
|
|
122
|
+
const say = (conv, text, ip, extra = {}) => call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { body: { token: conv.token, text, ...extra }, ip });
|
|
123
|
+
|
|
124
|
+
test.after(() => server.close());
|
|
125
|
+
|
|
126
|
+
// ---- the conversation itself -----------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test('a signed-out visitor starts a chat and gets an on-topic reply; the transcript reads back', async () => {
|
|
129
|
+
reset();
|
|
130
|
+
const ip = freshIp();
|
|
131
|
+
const s = await call('GET', '/front-door-guide/status', { ip });
|
|
132
|
+
assert.equal(s.status, 200);
|
|
133
|
+
assert.equal(s.body.mode, 'chat');
|
|
134
|
+
assert.deepEqual(s.body.limits, { max_messages: 8, max_message_chars: 1000 });
|
|
135
|
+
|
|
136
|
+
const st = await start(ip);
|
|
137
|
+
assert.equal(st.status, 201);
|
|
138
|
+
assert.equal(st.body.mode, 'chat');
|
|
139
|
+
assert.equal(st.body.greeting, G.GREETING);
|
|
140
|
+
const conv = st.body.conversation;
|
|
141
|
+
assert.ok(conv.id && conv.token);
|
|
142
|
+
assert.equal(conv.messages_left, 8);
|
|
143
|
+
|
|
144
|
+
const r = await say(conv, 'I want a booking site for my yoga studio.', ip);
|
|
145
|
+
assert.equal(r.status, 200);
|
|
146
|
+
assert.equal(r.body.mode, 'chat');
|
|
147
|
+
assert.equal(r.body.reply.text, 'Sounds useful. Who will use it first?');
|
|
148
|
+
assert.equal(r.body.conversation.messages_used, 1);
|
|
149
|
+
assert.equal(r.body.conversation.messages_left, 7);
|
|
150
|
+
|
|
151
|
+
// The request: the owner's model, low effort, bounded reply, the on-topic instructions.
|
|
152
|
+
const req = requests[0];
|
|
153
|
+
assert.equal(req.model, 'claude-opus-5-5');
|
|
154
|
+
assert.deepEqual(req.output_config, { effort: 'low' });
|
|
155
|
+
assert.equal(req.max_tokens, 1500);
|
|
156
|
+
assert.match(req.system, /Never quote, estimate or promise any price/);
|
|
157
|
+
assert.match(req.system, /cannot do/);
|
|
158
|
+
assert.deepEqual(req.messages, [{ role: 'user', content: 'I want a booking site for my yoga studio.' }]);
|
|
159
|
+
assert.deepEqual(clientKeys, [KEY], 'the platform key reaches only the client');
|
|
160
|
+
|
|
161
|
+
// Spend is recorded from the response's usage: 800 in + 200 out, priced by the shared table.
|
|
162
|
+
assert.equal(callRows.length, 1);
|
|
163
|
+
assert.equal(callRows[0].cost_micro, G.costMicro('claude-opus-5-5', { input_tokens: 800, output_tokens: 200 }));
|
|
164
|
+
assert.ok(callRows[0].cost_micro > 0);
|
|
165
|
+
|
|
166
|
+
const t = await call('GET', `/front-door-guide/conversations/${conv.id}`, { ip, headers: { 'x-guide-token': conv.token } });
|
|
167
|
+
assert.equal(t.status, 200);
|
|
168
|
+
assert.equal(t.body.conversation.status, 'open');
|
|
169
|
+
assert.deepEqual(t.body.transcript, [
|
|
170
|
+
{ role: 'visitor', text: 'I want a booking site for my yoga studio.' },
|
|
171
|
+
{ role: 'guide', text: 'Sounds useful. Who will use it first?' },
|
|
172
|
+
]);
|
|
173
|
+
for (const res of [s, st, r, t]) assert.ok(!res.raw.includes(KEY), 'the key is in no response');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the second turn sends the whole conversation so far', async () => {
|
|
177
|
+
reset();
|
|
178
|
+
const ip = freshIp();
|
|
179
|
+
const conv = (await start(ip)).body.conversation;
|
|
180
|
+
await say(conv, 'A shop for my pottery.', ip);
|
|
181
|
+
await say(conv, 'Mostly local customers.', ip);
|
|
182
|
+
assert.deepEqual(requests[1].messages.map((m) => m.role), ['user', 'assistant', 'user']);
|
|
183
|
+
assert.equal(requests[1].messages[2].content, 'Mostly local customers.');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ---- the walls ------------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
test('message cap: the last allowed message ends the chat, and nothing more is sent', async () => {
|
|
189
|
+
reset({ MAX_MESSAGES: 3 });
|
|
190
|
+
const ip = freshIp();
|
|
191
|
+
const conv = (await start(ip)).body.conversation;
|
|
192
|
+
assert.equal((await say(conv, 'one', ip)).body.mode, 'chat');
|
|
193
|
+
assert.equal((await say(conv, 'two', ip)).body.mode, 'chat');
|
|
194
|
+
const third = await say(conv, 'three', ip);
|
|
195
|
+
assert.equal(third.body.mode, 'ended');
|
|
196
|
+
assert.equal(third.body.reason, 'message_cap');
|
|
197
|
+
assert.ok(third.body.reply.text, 'the last message still gets its reply');
|
|
198
|
+
assert.equal(third.body.conversation.messages_left, 0);
|
|
199
|
+
|
|
200
|
+
const fourth = await say(conv, 'four', ip);
|
|
201
|
+
assert.equal(fourth.status, 200);
|
|
202
|
+
assert.equal(fourth.body.mode, 'ended');
|
|
203
|
+
assert.equal(requests.length, 3, 'no call past the cap');
|
|
204
|
+
|
|
205
|
+
// A new chat does not reset the visitor's cap.
|
|
206
|
+
const again = await start(ip);
|
|
207
|
+
assert.equal(again.body.mode, 'form');
|
|
208
|
+
assert.equal(again.body.reason, 'visitor_limit');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('length limit: one character over is refused before any call; exactly the limit is fine', async () => {
|
|
212
|
+
reset({ MAX_MESSAGE_CHARS: 40 });
|
|
213
|
+
const ip = freshIp();
|
|
214
|
+
const conv = (await start(ip)).body.conversation;
|
|
215
|
+
const over = await say(conv, 'x'.repeat(41), ip);
|
|
216
|
+
assert.equal(over.status, 400);
|
|
217
|
+
assert.equal(over.body.error, 'too_long');
|
|
218
|
+
assert.equal(over.body.max_message_chars, 40);
|
|
219
|
+
assert.equal(requests.length, 0);
|
|
220
|
+
assert.equal((await say(conv, 'x'.repeat(40), ip)).body.mode, 'chat');
|
|
221
|
+
assert.equal((await say(conv, ' ', ip)).body.error, 'empty');
|
|
222
|
+
assert.equal(requests.length, 1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('per-visitor spend cap: the visitor stops before passing it, and never passes it', async () => {
|
|
226
|
+
reset({ VISITOR_CAP_CENTS: 6 }); // 60,000 micro-dollars
|
|
227
|
+
const ip = freshIp();
|
|
228
|
+
const conv = (await start(ip)).body.conversation;
|
|
229
|
+
let last;
|
|
230
|
+
for (let i = 0; i < 8; i++) {
|
|
231
|
+
last = await say(conv, `message ${i}`, ip);
|
|
232
|
+
if (last.body.mode !== 'chat') break;
|
|
233
|
+
}
|
|
234
|
+
assert.equal(last.body.mode, 'ended');
|
|
235
|
+
assert.equal(last.body.reason, 'visitor_budget');
|
|
236
|
+
const spent = callRows.reduce((s, r) => s + r.cost_micro, 0);
|
|
237
|
+
assert.ok(spent <= 60_000, `spent ${spent} within the cap`);
|
|
238
|
+
assert.ok(callRows.length >= 1 && callRows.length < 8);
|
|
239
|
+
const after = await say(conv, 'one more', ip);
|
|
240
|
+
assert.equal(after.body.mode, 'ended');
|
|
241
|
+
assert.equal(requests.length, callRows.length, 'no call after the cap');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('daily budget cut-off: the guide answers the form, never an error, and makes no call', async () => {
|
|
245
|
+
reset({ DAILY_BUDGET_CENTS: 200 });
|
|
246
|
+
const ip = freshIp();
|
|
247
|
+
const conv = (await start(ip)).body.conversation;
|
|
248
|
+
baselineDay = 2_000_000 - 1_000; // a tenth of a cent left today: less than one reply could cost
|
|
249
|
+
const r = await say(conv, 'Hello there', ip);
|
|
250
|
+
assert.equal(r.status, 200);
|
|
251
|
+
assert.deepEqual(r.body, G.formAnswer('daily_budget'));
|
|
252
|
+
assert.equal(requests.length, 0);
|
|
253
|
+
const s = await call('GET', '/front-door-guide/status', { ip });
|
|
254
|
+
assert.equal(s.body.mode, 'chat', 'status still offers the chat while a cent is left');
|
|
255
|
+
baselineDay = 2_000_000;
|
|
256
|
+
assert.equal((await call('GET', '/front-door-guide/status', { ip })).body.reason, 'daily_budget');
|
|
257
|
+
const st = await start(freshIp());
|
|
258
|
+
assert.equal(st.status, 200);
|
|
259
|
+
assert.equal(st.body.mode, 'form');
|
|
260
|
+
assert.equal(st.body.reason, 'daily_budget');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('monthly budget cut-off: the form too', async () => {
|
|
264
|
+
reset({ MONTHLY_BUDGET_CENTS: 2500 });
|
|
265
|
+
baselineMonth = 25_000_000;
|
|
266
|
+
const st = await start(freshIp());
|
|
267
|
+
assert.deepEqual(st.body, G.formAnswer('monthly_budget'));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('no key configured: every route answers the form, never an error, and no client is made', async () => {
|
|
271
|
+
reset({ ANTHROPIC_API_KEY: null });
|
|
272
|
+
const ip = freshIp();
|
|
273
|
+
const s = await call('GET', '/front-door-guide/status', { ip });
|
|
274
|
+
assert.equal(s.status, 200);
|
|
275
|
+
assert.equal(s.body.mode, 'form');
|
|
276
|
+
assert.equal(s.body.reason, 'no_key');
|
|
277
|
+
const st = await start(ip);
|
|
278
|
+
assert.equal(st.status, 200);
|
|
279
|
+
assert.deepEqual(st.body, G.formAnswer('no_key'));
|
|
280
|
+
|
|
281
|
+
// A chat started while the key was set, then the key removed: the form, not a 500.
|
|
282
|
+
setEnv();
|
|
283
|
+
const conv = (await start(ip)).body.conversation;
|
|
284
|
+
setEnv({ ANTHROPIC_API_KEY: null });
|
|
285
|
+
const r = await say(conv, 'hello', ip);
|
|
286
|
+
assert.equal(r.status, 200);
|
|
287
|
+
assert.equal(r.body.reason, 'no_key');
|
|
288
|
+
assert.equal(clientKeys.length, 0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('a refusal stop reason gets a graceful reply, and the refused words are not sent again', async () => {
|
|
292
|
+
reset();
|
|
293
|
+
const ip = freshIp();
|
|
294
|
+
const conv = (await start(ip)).body.conversation;
|
|
295
|
+
respond = () => ({ model: 'claude-opus-5-5', stop_reason: 'refusal', stop_details: { type: 'refusal', category: null }, content: [], usage: { input_tokens: 500, output_tokens: 3 } });
|
|
296
|
+
const r = await say(conv, 'something off topic', ip);
|
|
297
|
+
assert.equal(r.status, 200);
|
|
298
|
+
assert.equal(r.body.mode, 'chat');
|
|
299
|
+
assert.equal(r.body.declined, true);
|
|
300
|
+
assert.equal(r.body.reply.text, G.REFUSAL_REPLY);
|
|
301
|
+
assert.equal(callRows[0].stop_reason, 'refusal');
|
|
302
|
+
assert.ok(callRows[0].cost_micro > 0, 'a refused call is still recorded');
|
|
303
|
+
|
|
304
|
+
respond = null;
|
|
305
|
+
await say(conv, 'OK, a recipe website.', ip);
|
|
306
|
+
const sent = JSON.stringify(requests[1].messages);
|
|
307
|
+
assert.ok(!sent.includes('something off topic'));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('a failed API call answers the form and records no spend', async () => {
|
|
311
|
+
reset();
|
|
312
|
+
const ip = freshIp();
|
|
313
|
+
const conv = (await start(ip)).body.conversation;
|
|
314
|
+
respond = () => { const e = new Error('overloaded'); e.status = 529; throw e; };
|
|
315
|
+
const r = await say(conv, 'hello', ip);
|
|
316
|
+
assert.equal(r.status, 200);
|
|
317
|
+
assert.deepEqual(r.body, G.formAnswer('unavailable'));
|
|
318
|
+
assert.equal(callRows.length, 0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ---- a scripted abuse run -------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
test('scripted abuse run: bots, stolen ids, floods and a budget stampede all hold', async () => {
|
|
324
|
+
reset({ DAILY_BUDGET_CENTS: 10, STARTS_PER_HOUR: 3, MESSAGES_PER_MINUTE: 4 });
|
|
325
|
+
|
|
326
|
+
// 1. The bot check: honeypot filled, no user agent, a first message faster than typing.
|
|
327
|
+
const botIp = freshIp();
|
|
328
|
+
assert.equal((await call('POST', '/front-door-guide/conversations', { body: { website: 'http://spam' }, ip: botIp })).body.reason, 'check_failed');
|
|
329
|
+
assert.equal((await call('POST', '/front-door-guide/conversations', { body: {}, ip: freshIp(), ua: null })).body.reason, 'check_failed');
|
|
330
|
+
const ip1 = freshIp();
|
|
331
|
+
const conv = (await start(ip1)).body.conversation;
|
|
332
|
+
assert.equal((await say(conv, 'hi', ip1, { website: 'x' })).body.reason, 'check_failed');
|
|
333
|
+
setEnv({ DAILY_BUDGET_CENTS: 10, STARTS_PER_HOUR: 3, MESSAGES_PER_MINUTE: 4, MIN_FIRST_MESSAGE_MS: 60_000 });
|
|
334
|
+
assert.equal((await say(conv, 'instant', ip1)).body.reason, 'check_failed');
|
|
335
|
+
setEnv({ DAILY_BUDGET_CENTS: 10, STARTS_PER_HOUR: 3, MESSAGES_PER_MINUTE: 4 });
|
|
336
|
+
assert.equal(requests.length, 0, 'no bot reached the API');
|
|
337
|
+
|
|
338
|
+
// 2. Someone else's chat: a wrong token or a made-up id is a 404; garbage is a 400.
|
|
339
|
+
assert.equal((await say({ id: conv.id, token: 'A'.repeat(32) }, 'hijack', freshIp())).status, 404);
|
|
340
|
+
assert.equal((await say({ id: '999999', token: conv.token }, 'hijack', freshIp())).status, 404);
|
|
341
|
+
assert.equal((await say({ id: 'abc', token: conv.token }, 'x', freshIp())).status, 400);
|
|
342
|
+
assert.equal((await call('GET', `/front-door-guide/conversations/${conv.id}`, { headers: { 'x-guide-token': 'B'.repeat(32) } })).status, 404);
|
|
343
|
+
|
|
344
|
+
// 3. Firing five messages at once: one call runs, the rest are told to wait.
|
|
345
|
+
delayMs = 60;
|
|
346
|
+
const burst = await Promise.all([0, 1, 2, 3].map((i) => say(conv, `parallel ${i}`, ip1)));
|
|
347
|
+
delayMs = 0;
|
|
348
|
+
const answered = burst.filter((b) => b.status === 200);
|
|
349
|
+
assert.equal(answered.length, 1, 'exactly one parallel message was answered');
|
|
350
|
+
assert.ok(burst.filter((b) => b.status === 429).every((b) => ['busy', 'rate_limited'].includes(b.body.error)));
|
|
351
|
+
assert.equal(requests.length, 1);
|
|
352
|
+
|
|
353
|
+
// 4. A message flood from one address hits the per-minute limit with Retry-After.
|
|
354
|
+
let flooded = null;
|
|
355
|
+
for (let i = 0; i < 6 && !flooded; i++) {
|
|
356
|
+
const r = await say(conv, `flood ${i}`, ip1);
|
|
357
|
+
if (r.status === 429 && r.body.error === 'rate_limited') flooded = r;
|
|
358
|
+
}
|
|
359
|
+
assert.ok(flooded, 'the per-minute limiter tripped');
|
|
360
|
+
assert.ok(flooded.body.retry_after_seconds >= 1);
|
|
361
|
+
|
|
362
|
+
// 5. A start flood from one address hits the per-hour limit.
|
|
363
|
+
const ip2 = freshIp();
|
|
364
|
+
const starts = [];
|
|
365
|
+
for (let i = 0; i < 5; i++) starts.push((await start(ip2)).status);
|
|
366
|
+
assert.deepEqual(starts.slice(0, 3), [201, 201, 201]);
|
|
367
|
+
assert.equal(starts[3], 429);
|
|
368
|
+
|
|
369
|
+
// 6. A stampede of fresh visitors cannot spend past the daily budget: once it is
|
|
370
|
+
// near, everyone gets the form, and the books never pass ten cents.
|
|
371
|
+
let forms = 0;
|
|
372
|
+
for (let v = 0; v < 25; v++) {
|
|
373
|
+
const ip = freshIp();
|
|
374
|
+
const st = await start(ip);
|
|
375
|
+
if (st.body.mode === 'form') { forms++; continue; }
|
|
376
|
+
for (let m = 0; m < 3; m++) {
|
|
377
|
+
const r = await say(st.body.conversation, `visitor ${v} message ${m}`, ip);
|
|
378
|
+
if (r.body.mode === 'form') { forms++; break; }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const spent = callRows.reduce((s, r) => s + r.cost_micro, 0);
|
|
382
|
+
assert.ok(spent <= 100_000, `site spend ${spent} stays within the daily budget`);
|
|
383
|
+
assert.ok(forms > 0, 'the budget cut-off was reached and answered with the form');
|
|
384
|
+
const s = await call('GET', '/front-door-guide/status', { ip: freshIp() });
|
|
385
|
+
assert.equal(s.status, 200);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ---- pure pieces --------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
test('settings: env knobs override, bad values fall back to the defaults, money in micro-dollars', () => {
|
|
391
|
+
const { readSettings, DEFAULTS } = require('../modules/front-door-guide/settings.js');
|
|
392
|
+
const s = readSettings({ resolve: (k) => ({ FRONT_DOOR_GUIDE_MAX_MESSAGES: '3', FRONT_DOOR_GUIDE_DAILY_BUDGET_CENTS: '-5', FRONT_DOOR_GUIDE_MODEL: 'gpt-4', FRONT_DOOR_GUIDE_EFFORT: 'max' })[k] });
|
|
393
|
+
assert.equal(s.maxMessages, 3);
|
|
394
|
+
assert.equal(s.dailyBudgetMicro, DEFAULTS.DAILY_BUDGET_CENTS * 10_000, 'a negative budget is not a budget');
|
|
395
|
+
assert.equal(s.model, 'claude-opus-5-5', 'only a Claude model id is accepted');
|
|
396
|
+
assert.equal(s.effort, 'max');
|
|
397
|
+
assert.equal(s.apiKey, null);
|
|
398
|
+
const d = readSettings({ resolve: () => undefined });
|
|
399
|
+
assert.equal(d.visitorCapMicro, 500_000);
|
|
400
|
+
assert.equal(d.monthlyBudgetMicro, 25_000_000);
|
|
401
|
+
assert.equal(d.maxMessageChars, 1000);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('the reservation covers a full-length reply, so it is never below the recorded cost', () => {
|
|
405
|
+
const system = G.systemPrompt('Cloud Bongos');
|
|
406
|
+
const messages = [{ role: 'user', content: 'x'.repeat(1000) }];
|
|
407
|
+
const est = G.estimateMicro({ model: 'claude-opus-5-5', system, messages, maxReplyTokens: 1500 });
|
|
408
|
+
const real = G.costMicro('claude-opus-5-5', { input_tokens: Math.ceil((system.length + 1000) / 4), output_tokens: 1500 });
|
|
409
|
+
assert.ok(est >= real);
|
|
410
|
+
});
|
|
@@ -190,7 +190,7 @@ test('a row\'s buttons are the card\'s acts under the board\'s words, and send t
|
|
|
190
190
|
assert.equal(ana.name, 'Ana Ruiz', 'a person is named by their name');
|
|
191
191
|
assert.deepEqual(ana.actions.map((a) => [a.key, a.label, a.tone]), [['fine', 'let in', 'primary'], ['refuse', 'not now', 'quiet']]);
|
|
192
192
|
assert.deepEqual(j(C.request(ana.card, 'fine')), { method: 'PATCH', url: '/api/bongos/access-requests/11', body: { status: 'invited' } });
|
|
193
|
-
assert.deepEqual(j(C.request(row('people',
|
|
193
|
+
assert.deepEqual(j(C.request(row('people', 7).card, 'refuse')), { method: 'POST', url: '/api/bongos/goals/42/requests/12/respond', body: { action: 'deny' } });
|
|
194
194
|
const rel = D.rowsFor('release', 0, dk, { now: NOW });
|
|
195
195
|
assert.deepEqual(rel[0].actions.map((a) => a.label), ['release', 'hold']);
|
|
196
196
|
assert.equal(C.request(rel[0].card, 'release').url, '/api/bongos/override-requests/13/decide');
|
|
@@ -587,7 +587,7 @@ test('a building address draws the interior in place of the street, from the doc
|
|
|
587
587
|
assert.equal(p.byId.get('streetInside').hidden, false);
|
|
588
588
|
assert.equal(p.byId.get('streetSizer').hidden, true, 'the street gives way');
|
|
589
589
|
assert.equal(p.byId.get('insideName').textContent, 'People');
|
|
590
|
-
assert.equal(p.byId.get('insideFloors').children.length,
|
|
590
|
+
assert.equal(p.byId.get('insideFloors').children.length, 8, 'a button per floor in use');
|
|
591
591
|
assert.equal(p.byId.get('insideFloors').children[4].attrs['aria-pressed'], 'true', 'the floor being decided is chosen');
|
|
592
592
|
assert.equal(p.byId.get('insideLift').style.transform, 'translateY(-208px)');
|
|
593
593
|
assert.equal(p.byId.get('insideTable'), undefined, 'no separate list of matters');
|
package/tests/module_loader.mjs
CHANGED
|
@@ -278,7 +278,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
|
|
|
278
278
|
// That is precisely how the agents regression reached an open PR (task 1002485).
|
|
279
279
|
loader._reset();
|
|
280
280
|
const snap = loader.loaderSnapshot();
|
|
281
|
-
assert.deepEqual(snap.loaded, ['agents', 'ai-studio', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'market-watch', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
|
|
281
|
+
assert.deepEqual(snap.loaded, ['agents', 'ai-studio', 'autonomy', 'builder-settings', 'copy-desk', 'design-styles', 'discord', 'economy', 'front-door-guide', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'market-watch', 'memory', 'npm-release', 'onboarding', 'page-notes', 'pixel-art', 'platform-identity', 'provisioning', 'public-landing', 'raise', 'render-deploy', 'security', 'sessions', 'specialities', 'status-ui', 'ui-design']);
|
|
282
282
|
assert.deepEqual(snap.errors, []);
|
|
283
283
|
assert.deepEqual(snap.skipped, []);
|
|
284
284
|
loader._reset();
|