@bongos/core 1.19.1081 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +526 -191
- package/.claude/skills/planning-session/SKILL.md +6 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +32 -0
- package/clients/bongos-client/index.cjs +32 -0
- package/clients/bongos-client/index.d.ts +51 -6
- package/clients/bongos-client/index.mjs +32 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
- package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +851 -19
- package/docs/api-reference.md +33 -9
- package/docs/architecture.md +6 -2
- package/docs/copy-inventory.md +620 -553
- package/docs/copy-registry.json +1467 -820
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +6 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +1674 -1566
- package/migrations/core_260_goals_working_area.sql +63 -0
- package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
- package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
- package/modules/agents/lib/validate.js +43 -0
- package/modules/autonomy/gauge.js +38 -2
- package/modules/grading/grader-subagent.js +37 -2
- package/modules/grading/grader-workers/reader.js +65 -5
- package/modules/hall-ui/public/approval-queue.css +33 -7
- package/modules/hall-ui/public/approval-queue.js +9 -2
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/brand-holes.js +121 -0
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +6 -1
- package/modules/hall-ui/public/deploy.js +22 -8
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/dom-utils.js +20 -0
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/idea.js +28 -0
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/ideas.js +19 -3
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile-nudge.js +21 -4
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +19 -30
- package/modules/hall-ui/public/settings.js +24 -177
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/shell.js +4 -2
- package/modules/hall-ui/public/studio.css +14 -8
- package/modules/hall-ui/public/studio.html +5 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
- package/modules/hall-ui/public/tweak-editor.css +142 -11
- package/modules/hall-ui/public/tweak-editor.html +25 -9
- package/modules/hall-ui/public/tweak-editor.js +243 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/approval-queue.md +6 -0
- package/modules/hall-ui/records/tweak-editor.md +6 -0
- package/modules/ideas/ratify.js +36 -8
- package/modules/ideas/routes/ratify-goal.js +6 -0
- package/modules/lifecycle/db-analytics.js +79 -10
- package/modules/lifecycle/db-claims.js +48 -1
- package/modules/lifecycle/db-deps-criteria.js +94 -40
- package/modules/lifecycle/db-goals.js +4 -1
- package/modules/lifecycle/db-grade.js +9 -1
- package/modules/lifecycle/db-relevance-flags.js +183 -0
- package/modules/lifecycle/db-versions.js +4 -3
- package/modules/lifecycle/db.js +19 -0
- package/modules/lifecycle/done-when.js +5 -3
- package/modules/lifecycle/est-advisory.js +31 -4
- package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
- package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
- package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +8 -1
- package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
- package/modules/lifecycle/routes/goals.js +1 -1
- package/modules/lifecycle/routes/tasks.js +27 -24
- package/modules/lifecycle/routes/visuals.js +9 -0
- package/modules/lifecycle/ship-preflight.js +7 -11
- package/modules/lifecycle/task-visuals.js +109 -5
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/preview/commands.js +81 -0
- package/modules/npm-release/preview/divert.js +95 -0
- package/modules/npm-release/preview/env.js +78 -0
- package/modules/npm-release/preview/proxy.js +107 -0
- package/modules/npm-release/preview/runtime.js +83 -0
- package/modules/npm-release/preview/supervisor.js +175 -0
- package/modules/npm-release/public/work.js +65 -1
- package/modules/npm-release/routes/preview.js +133 -0
- package/modules/provisioning/app-status.js +83 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/pollers/app-liveness.js +110 -0
- package/modules/provisioning/render-lookup.js +68 -0
- package/modules/provisioning/render-standup.js +44 -1
- package/modules/provisioning/routes/render-standup.js +81 -6
- package/modules/provisioning/starter-bundles.js +4 -1
- package/modules/public-landing/public/account-private.states.json +14 -0
- package/modules/public-landing/public/account.html +513 -0
- package/modules/public-landing/public/account.probes.json +37 -0
- package/modules/public-landing/public/account.states.json +16 -0
- package/modules/public-landing/public/index.html +5 -2
- package/modules/public-landing/public/projects.html +661 -18
- package/modules/public-landing/public/projects.states.json +8 -1
- package/modules/render-deploy/deploys.js +203 -0
- package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
- package/modules/render-deploy/module.json +25 -0
- package/modules/render-deploy/public/deploy.css +10 -0
- package/modules/render-deploy/public/deploy.js +249 -0
- package/modules/render-deploy/render.js +56 -0
- package/modules/render-deploy/routes/act.js +88 -0
- package/modules/render-deploy/routes/app.js +65 -0
- package/modules/render-deploy/routes/history.js +38 -0
- package/modules/specialities/db.js +27 -8
- package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
- package/modules/specialities/routes/specialities.js +51 -3
- package/modules/specialities/skills.js +94 -0
- package/modules/specialities/specialities.js +56 -9
- package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
- package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
- package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
- package/modules/ui-design/kit/lib.js +3 -1
- package/modules/ui-design/kit/serve.js +96 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +163 -0
- package/scripts/gds/agents-sync.js +13 -3
- package/scripts/gds/autobongos-run.js +80 -2
- package/scripts/gds/autobongos-service.cmd +12 -0
- package/scripts/gds/copy-apply.js +14 -0
- package/scripts/gds/dev-box-guard.js +3 -2
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/grade-correlation-audit.js +42 -12
- package/scripts/gds/grade-replay.js +1 -1
- package/scripts/gds/provision-core-upgrade.js +38 -7
- package/scripts/gds/provision-pin-key.js +176 -0
- package/scripts/gds/provision-render.js +62 -31
- package/scripts/gds/provision-teardown.js +7 -2
- package/scripts/gds/provision-units.js +31 -0
- package/scripts/gds/provision.js +10 -10
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/render-api.js +19 -0
- package/scripts/gds/render-payload.js +39 -65
- package/scripts/gds/run-unit-tests.js +10 -0
- package/scripts/gds/ship-finish.js +19 -20
- package/scripts/gds/smoke-dependencies.sh +37 -8
- package/scripts/gds/status.js +12 -4
- package/scripts/gds/upgrade.js +2 -2
- package/scripts/public-mirror-export.js +7 -1
- package/src/bongos/module-scope-map.js +13 -0
- package/src/bongos/serve-internal.js +3 -1
- package/src/module-api.js +10 -1
- package/src/platform-server.js +39 -0
- package/tests/account_privacy_flags.mjs +10 -6
- package/tests/agents_authoring.mjs +15 -1
- package/tests/agents_sync.mjs +106 -5
- package/tests/agents_validate.mjs +45 -0
- package/tests/api_path_404.mjs +6 -0
- package/tests/autobongos_cadence.mjs +7 -0
- package/tests/autobongos_loop.mjs +136 -1
- package/tests/autonomy_gauge.mjs +62 -0
- package/tests/blocker_hall_live_proof.mjs +275 -0
- package/tests/claim_gate_ci_unblock.mjs +116 -0
- package/tests/claim_gate_rebase.mjs +15 -3
- package/tests/collab_page.mjs +10 -0
- package/tests/conductor_main_e2e.mjs +97 -0
- package/tests/core_262_drop_box_blocked_db.mjs +143 -0
- package/tests/core_upgrade_runner.mjs +1 -0
- package/tests/dependency_writes_atomic.mjs +191 -0
- package/tests/deploy_page_projects.mjs +2 -1
- package/tests/effective_visibility_predicate.mjs +15 -0
- package/tests/est_advisory.mjs +27 -0
- package/tests/goal_map_page.mjs +26 -0
- package/tests/goal_task_relevance.mjs +518 -0
- package/tests/goal_working_area.mjs +146 -0
- package/tests/grade_attempts.mjs +61 -0
- package/tests/grade_attribution.mjs +23 -0
- package/tests/grade_correlation_audit.mjs +54 -1
- package/tests/grader_reader_lens.mjs +55 -2
- package/tests/grader_root_outage.mjs +6 -1
- package/tests/grader_subagent_tools_arg.mjs +102 -0
- package/tests/hall_approval_queue.mjs +54 -2
- package/tests/hall_settings_world.mjs +3 -1
- package/tests/hall_tweak_editor.mjs +316 -11
- package/tests/hub_account_page.mjs +394 -0
- package/tests/idea_detail_page.mjs +64 -0
- package/tests/idea_goal_ratification.mjs +64 -0
- package/tests/idea_spark_hall.mjs +60 -9
- package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
- package/tests/landing_page.mjs +6 -3
- package/tests/module-scope-map.mjs +31 -8
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/no_phantom_mirror_workflow.mjs +24 -0
- package/tests/npm_release_preview_commands.mjs +89 -0
- package/tests/npm_release_preview_divert.mjs +164 -0
- package/tests/npm_release_preview_env.mjs +100 -0
- package/tests/npm_release_preview_proxy.mjs +162 -0
- package/tests/npm_release_preview_routes.mjs +208 -0
- package/tests/npm_release_preview_supervisor.mjs +203 -0
- package/tests/pin_write_key.mjs +383 -0
- package/tests/planning_session_skill.mjs +27 -0
- package/tests/platform_boot.mjs +71 -0
- package/tests/profile_nudge_links.mjs +86 -0
- package/tests/profile_ui_cross_project.mjs +1 -1
- package/tests/projects_hub.mjs +2 -2
- package/tests/projects_hub_app_status.mjs +249 -0
- package/tests/projects_hub_app_step.mjs +282 -52
- package/tests/projects_hub_render_connect.mjs +295 -0
- package/tests/provision_render.mjs +121 -28
- package/tests/provision_settings_apply.mjs +62 -0
- package/tests/provisioning_app_status.mjs +204 -0
- package/tests/provisioning_render_route.mjs +126 -6
- package/tests/render_check.mjs +2 -1
- package/tests/render_deploy.mjs +495 -0
- package/tests/ship_preflight.mjs +61 -8
- package/tests/smoke_dependencies_witness.mjs +46 -0
- package/tests/speciality_skills.mjs +214 -0
- package/tests/studio_room_height.mjs +70 -0
- package/tests/task_detail_includes.mjs +10 -0
- package/tests/task_visuals_instance_root.mjs +150 -0
- package/tests/tweak_batch_apply.mjs +21 -0
- package/tests/ui_design_kit.mjs +36 -4
- package/tests/upgrade.mjs +1 -1
- package/tests/upgrade_persist_pin.mjs +24 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// tests/hub_account_page.mjs — the hub's account page (task 1004259, ADR 0337 D4.3;
|
|
2
|
+
// spec docs/specs/hub-account-settings-inventory.md).
|
|
3
|
+
//
|
|
4
|
+
// The hub account is the person and a hall account is their seat in one project,
|
|
5
|
+
// so what belongs to the person — handle, name, bio, links, who can find them — is
|
|
6
|
+
// edited on ONE page at the apex, and every hall links out to it. These pin:
|
|
7
|
+
// 1. the page as a file: portable, dark only, no page :root, no innerHTML, the
|
|
8
|
+
// sign-in door returns here, and it calls only the four own-scoped routes;
|
|
9
|
+
// 2. its limits mirror the module's (bio cap, the link vocabulary and host pins);
|
|
10
|
+
// 3. its script, EXECUTED in a node:vm under a DOM stub: the door on a 401, the
|
|
11
|
+
// paint from the server's answer, and each save sending only what changed —
|
|
12
|
+
// and a bad link or an empty name refused at the field, before any request;
|
|
13
|
+
// 4. the route: served on the apex only where platform-identity runs, never on
|
|
14
|
+
// the builders/status hosts, and never on a child instance;
|
|
15
|
+
// 5. the hall's side: the two privacy sections are gone, one link-out row
|
|
16
|
+
// remains, and its hub origin is a bare https origin or nothing.
|
|
17
|
+
//
|
|
18
|
+
// DB-free. Run: node --test tests/hub_account_page.mjs
|
|
19
|
+
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { test, before, after } from 'node:test';
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import vm from 'node:vm';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
32
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
33
|
+
const PAGE = fs.readFileSync(path.join(LANDING, 'account.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
34
|
+
const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
|
|
35
|
+
|
|
36
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
37
|
+
const { applyBrandTokens } = require('../src/bongos/serve-internal.js');
|
|
38
|
+
|
|
39
|
+
// ── 1. the page as a file ────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
test('portable and dark only: stamped title, balanced sentinels, no :root, no baked apex', () => {
|
|
42
|
+
assert.match(PAGE, /^<html lang="en" data-mode="dark">/m, 'dark is fixed on <html> (ADR 0204)');
|
|
43
|
+
assert.match(PAGE, /<title>\{\{productName\}\} — Your account<\/title>/);
|
|
44
|
+
assert.match(PAGE, /<meta name="robots" content="noindex">/, 'a private edit surface is not for a crawler');
|
|
45
|
+
const styles = [...PAGE.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
46
|
+
assert.doesNotMatch(styles, /:root\s*\{/, 'a page-local :root outranks the injected pack');
|
|
47
|
+
assert.doesNotMatch(styles, /#[0-9a-f]{3,8}\b/i, 'every colour is a token, never a literal');
|
|
48
|
+
assert.doesNotMatch(PAGE, /cloudbongos\.com/, 'no hardcoded apex — the file ships to every instance');
|
|
49
|
+
assert.doesNotMatch(PAGE, /gtag|plausible|googletagmanager/);
|
|
50
|
+
const starts = (PAGE.match(/<!--apex-only:start-->/g) || []).length;
|
|
51
|
+
assert.equal(starts, (PAGE.match(/<!--apex-only:end-->/g) || []).length, 'balanced sentinels');
|
|
52
|
+
const stamped = applyBrandTokens(PAGE, { identity: { productName: 'Spike Harbor' } }, 'https://spike.example', 'https://builders.spike.example', '');
|
|
53
|
+
assert.doesNotMatch(stamped, /\{\{[a-zA-Z]+\}\}/, 'every token stamps');
|
|
54
|
+
assert.match(stamped, /<title>Spike Harbor — Your account<\/title>/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the script builds no markup from strings, and the controls are real markup', () => {
|
|
58
|
+
assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML/, 'live DOM is textContent / createElement only');
|
|
59
|
+
for (const id of ['fName', 'fBio', 'fHandle', 'fLinkWebsite', 'fLinkX', 'fLinkLinkedin', 'fLinkDiscord', 'profileSave', 'handleSave', 'privacySave', 'recordSave']) {
|
|
60
|
+
assert.match(PAGE, new RegExp(`id="${id}"`), `#${id} ships in the markup`);
|
|
61
|
+
}
|
|
62
|
+
for (const [name, values] of [['visibility', ['public', 'private']], ['recruiters', ['on', 'off']], ['record', ['show', 'hide']]]) {
|
|
63
|
+
for (const v of values) assert.match(PAGE, new RegExp(`<input type="radio" name="${name}" value="${v}">`));
|
|
64
|
+
}
|
|
65
|
+
assert.match(PAGE, /<div id="acctBody" hidden>/, 'the signed-in body ships hidden');
|
|
66
|
+
assert.match(PAGE, /<section class="aDoor" id="door" hidden/, 'and so does the door');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the door returns here, and the page calls only its own-scoped routes', () => {
|
|
70
|
+
assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account"/, 'sign-in lands back on /account');
|
|
71
|
+
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
72
|
+
assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
|
|
73
|
+
assert.match(SCRIPT, /fetch\(API \+ '\/auth\/logout', \{ method: 'POST'/, 'sign out is the hub logout POST');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── 2. the mirrors hold ──────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
test('the page’s limits are the module’s own (boundary-forced copies)', () => {
|
|
79
|
+
const grab = (name) => {
|
|
80
|
+
const m = SCRIPT.match(new RegExp(`var ${name} = ([^;]+);`));
|
|
81
|
+
assert.ok(m, `${name} is still declared`);
|
|
82
|
+
// JSON round-trip: a value built in another vm context has other prototypes
|
|
83
|
+
return JSON.parse(JSON.stringify(vm.runInNewContext(`(${m[1]})`)));
|
|
84
|
+
};
|
|
85
|
+
assert.equal(grab('BIO_MAX'), pi.BIO_MAX_CHARS);
|
|
86
|
+
assert.deepEqual(grab('LINK_TYPES'), [...pi.PROFILE_LINK_TYPES]);
|
|
87
|
+
assert.deepEqual(grab('LINK_HOSTS'), JSON.parse(JSON.stringify(pi.PROFILE_LINK_HOSTS)));
|
|
88
|
+
assert.match(PAGE, new RegExp(`id="fName" maxlength="${pi.DISPLAY_NAME_MAX_CHARS}"`));
|
|
89
|
+
assert.match(PAGE, /id="fHandle" maxlength="39"/, 'the handle charset caps at 39, like github_login');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── 3. the script, executed ──────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const ACCOUNT = () => ({
|
|
95
|
+
hide_stats: false, profile_state: 'active', account_visibility: 'public',
|
|
96
|
+
recruiter_discoverable: null, recruiter_discoverable_effective: true,
|
|
97
|
+
handle: 'octo', display_name: 'Octo', bio: 'Old bio.',
|
|
98
|
+
links: { website: 'https://octo.example/' },
|
|
99
|
+
cross_project: { projects: [], totals: { projects: 2, credits: 40, works_shipped: 5, karma: 3 } },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// A DOM stub just wide enough for this page: every id in the markup is an
|
|
103
|
+
// element (hidden where the markup says so), every radio and link input is
|
|
104
|
+
// addressable by the selectors the script uses, and fetch answers from a handler.
|
|
105
|
+
function boot(answer) {
|
|
106
|
+
const els = new Map();
|
|
107
|
+
const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
|
|
108
|
+
const el = (id, hidden) => listeners({
|
|
109
|
+
id, hidden, value: '', textContent: '', className: '', disabled: false, style: {},
|
|
110
|
+
appendChild(c) { this.textContent += c.textContent; return c; },
|
|
111
|
+
getAttribute() { return null; },
|
|
112
|
+
focus() { dom.focused = id; },
|
|
113
|
+
});
|
|
114
|
+
for (const m of PAGE.matchAll(/<(\w+)([^>]*?)\sid="([\w-]+)"([^>]*)>/g)) {
|
|
115
|
+
els.set(m[3], el(m[3], /\shidden\b/.test(m[2] + m[4])));
|
|
116
|
+
}
|
|
117
|
+
const radios = [...PAGE.matchAll(/<input type="radio" name="(\w+)" value="(\w+)">/g)]
|
|
118
|
+
.map((m) => listeners({ name: m[1], value: m[2], checked: false }));
|
|
119
|
+
const links = [...PAGE.matchAll(/id="(fLink\w+)" data-link="(\w+)"/g)].map((m) => {
|
|
120
|
+
const e = els.get(m[1]);
|
|
121
|
+
e.getAttribute = (n) => (n === 'data-link' ? m[2] : null);
|
|
122
|
+
return e;
|
|
123
|
+
});
|
|
124
|
+
const calls = [];
|
|
125
|
+
const dom = {
|
|
126
|
+
els, radios, calls, focused: null,
|
|
127
|
+
pick(name, value) {
|
|
128
|
+
for (const r of radios.filter((x) => x.name === name)) r.checked = r.value === value;
|
|
129
|
+
for (const f of radios.find((x) => x.name === name && x.value === value)._on.change || []) f({});
|
|
130
|
+
},
|
|
131
|
+
submit(id) { for (const f of els.get(id)._on.submit || []) f({ preventDefault() {} }); },
|
|
132
|
+
text: (id) => els.get(id).textContent,
|
|
133
|
+
};
|
|
134
|
+
const document = {
|
|
135
|
+
cookie: '',
|
|
136
|
+
getElementById: (id) => els.get(id) || null,
|
|
137
|
+
querySelector(sel) {
|
|
138
|
+
const m = sel.match(/^input\[name="(\w+)"\]:checked$/);
|
|
139
|
+
if (!m) throw new Error(`unstubbed selector: ${sel}`);
|
|
140
|
+
return radios.find((r) => r.name === m[1] && r.checked) || null;
|
|
141
|
+
},
|
|
142
|
+
querySelectorAll(sel) {
|
|
143
|
+
if (sel === '#profileForm [data-link]') return links;
|
|
144
|
+
const names = [...sel.matchAll(/input\[name="(\w+)"\]/g)].map((m) => m[1]);
|
|
145
|
+
if (!names.length) throw new Error(`unstubbed selector: ${sel}`);
|
|
146
|
+
return radios.filter((r) => names.includes(r.name));
|
|
147
|
+
},
|
|
148
|
+
createElement: () => ({ className: '', textContent: '' }),
|
|
149
|
+
};
|
|
150
|
+
const fetch = (url, init = {}) => {
|
|
151
|
+
const method = init.method || 'GET';
|
|
152
|
+
const body = init.body ? JSON.parse(init.body) : undefined;
|
|
153
|
+
const p = url.replace(/^\/api\/bongos/, '');
|
|
154
|
+
calls.push({ method, path: p, body });
|
|
155
|
+
const { status, data } = answer(method, p, body);
|
|
156
|
+
return Promise.resolve({ status, ok: status >= 200 && status < 300, json: () => Promise.resolve(data) });
|
|
157
|
+
};
|
|
158
|
+
const location = { hostname: 'cloudbongos.com', pathname: '/account' };
|
|
159
|
+
vm.runInNewContext(SCRIPT, { document, fetch, location, window: { location: { assign() {} } }, URL, Array, Object, JSON, Promise });
|
|
160
|
+
return dom;
|
|
161
|
+
}
|
|
162
|
+
const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r)); };
|
|
163
|
+
|
|
164
|
+
test('signed out: a 401 opens the door and shows nothing else', async () => {
|
|
165
|
+
const dom = boot(() => ({ status: 401, data: { error: { code: 'unauthenticated' } } }));
|
|
166
|
+
await settle();
|
|
167
|
+
assert.equal(dom.els.get('door').hidden, false);
|
|
168
|
+
assert.equal(dom.els.get('acctBody').hidden, true);
|
|
169
|
+
assert.equal(dom.els.get('aWho').hidden, true);
|
|
170
|
+
assert.equal(dom.els.get('loading').hidden, true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('signed in: every control is painted from the server’s answer', async () => {
|
|
174
|
+
const dom = boot((m, p) => (m === 'GET' && p === '/me/cross-project' ? { status: 200, data: ACCOUNT() } : { status: 500, data: null }));
|
|
175
|
+
await settle();
|
|
176
|
+
assert.equal(dom.els.get('acctBody').hidden, false);
|
|
177
|
+
assert.equal(dom.els.get('door').hidden, true);
|
|
178
|
+
assert.equal(dom.text('whoName'), 'octo');
|
|
179
|
+
assert.equal(dom.els.get('fName').value, 'Octo');
|
|
180
|
+
assert.equal(dom.els.get('fBio').value, 'Old bio.');
|
|
181
|
+
assert.equal(dom.els.get('fLinkWebsite').value, 'https://octo.example/');
|
|
182
|
+
assert.equal(dom.els.get('fHandle').value, 'octo');
|
|
183
|
+
assert.equal(dom.text('bioCount'), '8 of 280 characters');
|
|
184
|
+
const checked = (n) => dom.radios.find((r) => r.name === n && r.checked).value;
|
|
185
|
+
assert.equal(checked('visibility'), 'public');
|
|
186
|
+
assert.equal(checked('recruiters'), 'on', 'the EFFECTIVE answer, since the stored one is null');
|
|
187
|
+
assert.equal(checked('record'), 'show');
|
|
188
|
+
assert.equal(dom.text('recordLine'), 'Your record: 2 projects · 40 credits · 5 works shipped · 3 karma.');
|
|
189
|
+
assert.match(dom.text('recWhat'), /^They will see your name, your picture, your roles/);
|
|
190
|
+
assert.match(dom.text('recWhat'), /For now this follows your profile page setting\.$/, 'a null answer says it follows');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('the recruiter copy follows the choices on the page: private adds the exception, hidden shrinks the grant', async () => {
|
|
194
|
+
const dom = boot(() => ({ status: 200, data: ACCOUNT() }));
|
|
195
|
+
await settle();
|
|
196
|
+
assert.doesNotMatch(dom.text('recWhat'), /while your profile page is private/);
|
|
197
|
+
dom.pick('visibility', 'private');
|
|
198
|
+
assert.match(dom.text('recWhat'), /This is the one thing a recruiter can see while your profile page is private\./);
|
|
199
|
+
dom.pick('record', 'hide');
|
|
200
|
+
assert.match(dom.text('recWhat'), /^Because your cross-project record is hidden, they will see your name and your picture, and nothing else\./);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('your profile: only what changed is sent, and nothing changed sends nothing', async () => {
|
|
204
|
+
const dom = boot((m, p, body) => {
|
|
205
|
+
if (m === 'GET') return { status: 200, data: ACCOUNT() };
|
|
206
|
+
if (m === 'PATCH' && p === '/me/profile') return { status: 200, data: { display_name: 'Octo', bio: body.bio, links: body.links } };
|
|
207
|
+
return { status: 500, data: null };
|
|
208
|
+
});
|
|
209
|
+
await settle();
|
|
210
|
+
dom.submit('profileForm');
|
|
211
|
+
await settle();
|
|
212
|
+
assert.equal(dom.text('profileMsg'), 'Nothing has changed.');
|
|
213
|
+
assert.equal(dom.calls.filter((c) => c.method === 'PATCH').length, 0);
|
|
214
|
+
|
|
215
|
+
dom.els.get('fBio').value = ' A new bio. ';
|
|
216
|
+
dom.els.get('fLinkLinkedin').value = 'https://www.linkedin.com/in/octo';
|
|
217
|
+
dom.submit('profileForm');
|
|
218
|
+
await settle();
|
|
219
|
+
const patch = dom.calls.find((c) => c.method === 'PATCH');
|
|
220
|
+
assert.deepEqual(patch, { method: 'PATCH', path: '/me/profile', body: {
|
|
221
|
+
bio: 'A new bio.',
|
|
222
|
+
links: { website: 'https://octo.example/', linkedin: 'https://www.linkedin.com/in/octo' },
|
|
223
|
+
} }, 'no display_name — it did not change; the link set is sent whole, as the route replaces it');
|
|
224
|
+
assert.equal(dom.text('profileMsg'), 'Saved — your profile shows this everywhere now.');
|
|
225
|
+
assert.equal(dom.els.get('profileMsg').className, 'projMsg ok');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('your profile: a link off its site, a plain-http link and an empty name are named at the field, before any request', async () => {
|
|
229
|
+
const dom = boot(() => ({ status: 200, data: ACCOUNT() }));
|
|
230
|
+
await settle();
|
|
231
|
+
dom.els.get('fLinkLinkedin').value = 'https://linkedin.com.evil.example/in/octo';
|
|
232
|
+
dom.submit('profileForm');
|
|
233
|
+
assert.equal(dom.text('profileMsg'), 'Your LinkedIn link has to point at linkedin.com.');
|
|
234
|
+
assert.equal(dom.focused, 'fLinkLinkedin');
|
|
235
|
+
dom.els.get('fLinkLinkedin').value = 'http://linkedin.com/in/octo';
|
|
236
|
+
dom.submit('profileForm');
|
|
237
|
+
assert.equal(dom.text('profileMsg'), 'Your LinkedIn link has to start with https://.');
|
|
238
|
+
dom.els.get('fLinkLinkedin').value = '';
|
|
239
|
+
dom.els.get('fName').value = ' ';
|
|
240
|
+
dom.submit('profileForm');
|
|
241
|
+
assert.equal(dom.text('profileMsg'), 'Your display name can’t be empty.');
|
|
242
|
+
assert.equal(dom.calls.filter((c) => c.method === 'PATCH').length, 0, 'nothing was sent');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('your profile: the server’s refusal is shown in its own words', async () => {
|
|
246
|
+
const dom = boot((m) => (m === 'GET' ? { status: 200, data: ACCOUNT() }
|
|
247
|
+
: { status: 400, data: { error: { code: 'bio_too_long', message: 'a bio is at most 280 characters of plain text' } } }));
|
|
248
|
+
await settle();
|
|
249
|
+
dom.els.get('fBio').value = 'short';
|
|
250
|
+
dom.submit('profileForm');
|
|
251
|
+
await settle();
|
|
252
|
+
assert.equal(dom.text('profileMsg'), 'A bio is at most 280 characters of plain text.');
|
|
253
|
+
assert.equal(dom.els.get('profileMsg').className, 'projMsg err');
|
|
254
|
+
assert.equal(dom.els.get('profileSave').disabled, false, 'the button comes back for another try');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('your handle: lowercased, the same handle is not re-sent, and each refusal is said plainly', async () => {
|
|
258
|
+
let reply = { status: 409, data: { error: { code: 'handle_taken', message: 'that handle is already in use' } } };
|
|
259
|
+
const dom = boot((m) => (m === 'GET' ? { status: 200, data: ACCOUNT() } : reply));
|
|
260
|
+
await settle();
|
|
261
|
+
dom.els.get('fHandle').value = 'OCTO';
|
|
262
|
+
dom.submit('handleForm');
|
|
263
|
+
assert.equal(dom.text('handleMsg'), 'That is already your handle.');
|
|
264
|
+
dom.els.get('fHandle').value = ' Taken ';
|
|
265
|
+
dom.submit('handleForm');
|
|
266
|
+
await settle();
|
|
267
|
+
assert.deepEqual(dom.calls.at(-1), { method: 'PATCH', path: '/me/handle', body: { handle: 'taken' } });
|
|
268
|
+
assert.equal(dom.text('handleMsg'), 'Someone already has that handle — pick another.');
|
|
269
|
+
reply = { status: 429, data: { error: { code: 'handle_rate_limited', message: 'handles change at most once per 7 days — try again after 2026-10-06T00:00:00.000Z' } } };
|
|
270
|
+
dom.submit('handleForm');
|
|
271
|
+
await settle();
|
|
272
|
+
assert.equal(dom.text('handleMsg'), 'You can change your handle once a week, and you changed it recently — try again in a few days.');
|
|
273
|
+
reply = { status: 200, data: { handle: 'taken', previous_handle: 'octo' } };
|
|
274
|
+
dom.submit('handleForm');
|
|
275
|
+
await settle();
|
|
276
|
+
assert.equal(dom.text('handleMsg'), 'Saved — your handle is now taken.');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('who can find you: sends only the flag that moved, then re-reads the server’s view', async () => {
|
|
280
|
+
let acct = ACCOUNT();
|
|
281
|
+
const dom = boot((m, p, body) => {
|
|
282
|
+
if (m === 'GET') return { status: 200, data: acct };
|
|
283
|
+
acct = { ...acct, ...body, recruiter_discoverable: null, recruiter_discoverable_effective: false };
|
|
284
|
+
return { status: 200, data: {} };
|
|
285
|
+
});
|
|
286
|
+
await settle();
|
|
287
|
+
dom.submit('privacyForm');
|
|
288
|
+
assert.equal(dom.text('privacyMsg'), 'Nothing has changed.');
|
|
289
|
+
dom.pick('visibility', 'private');
|
|
290
|
+
dom.submit('privacyForm');
|
|
291
|
+
await settle();
|
|
292
|
+
const patches = dom.calls.filter((c) => c.method === 'PATCH');
|
|
293
|
+
assert.deepEqual(patches, [{ method: 'PATCH', path: '/me/cross-project', body: { account_visibility: 'private' } }],
|
|
294
|
+
'reach was left alone, so it is not sent — the route resets it to follow a now-private page');
|
|
295
|
+
assert.equal(dom.calls.at(-1).method, 'GET', 'and the page re-reads rather than trusting its own guess');
|
|
296
|
+
assert.equal(dom.text('privacyMsg'), 'Saved — your profile page is set to private.');
|
|
297
|
+
assert.equal(dom.radios.find((r) => r.name === 'recruiters' && r.checked).value, 'off', 'the effective answer repaints from the server');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('your record: hiding sends hide_stats alone', async () => {
|
|
301
|
+
const dom = boot((m) => (m === 'GET' ? { status: 200, data: ACCOUNT() } : { status: 200, data: {} }));
|
|
302
|
+
await settle();
|
|
303
|
+
dom.pick('record', 'hide');
|
|
304
|
+
dom.submit('recordForm');
|
|
305
|
+
await settle();
|
|
306
|
+
assert.deepEqual(dom.calls.find((c) => c.method === 'PATCH').body, { hide_stats: true });
|
|
307
|
+
assert.equal(dom.text('recordMsg'), 'Cross-project record hidden — removed from your profile and directories immediately.');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('a session that expired mid-edit reopens the door instead of failing silently', async () => {
|
|
311
|
+
const dom = boot((m) => (m === 'GET' ? { status: 200, data: ACCOUNT() } : { status: 401, data: null }));
|
|
312
|
+
await settle();
|
|
313
|
+
dom.els.get('fBio').value = 'changed';
|
|
314
|
+
dom.submit('profileForm');
|
|
315
|
+
await settle();
|
|
316
|
+
assert.equal(dom.text('profileMsg'), 'Your session expired — sign in again to save.');
|
|
317
|
+
assert.equal(dom.els.get('door').hidden, false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ── 4. the route ─────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
const { buildPlatformApp } = require('../src/platform-server.js');
|
|
323
|
+
const modules = require('../src/modules.js');
|
|
324
|
+
let server;
|
|
325
|
+
let port;
|
|
326
|
+
before(async () => {
|
|
327
|
+
server = http.createServer(buildPlatformApp());
|
|
328
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
329
|
+
port = server.address().port;
|
|
330
|
+
});
|
|
331
|
+
after(async () => {
|
|
332
|
+
modules.setModuleOverrides({});
|
|
333
|
+
await new Promise((resolve) => server.close(resolve));
|
|
334
|
+
});
|
|
335
|
+
function get(pathname, host) {
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
http.get({ host: '127.0.0.1', port, path: pathname, headers: { Host: host } }, (res) => {
|
|
338
|
+
let body = '';
|
|
339
|
+
res.on('data', (c) => (body += c));
|
|
340
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
341
|
+
}).on('error', reject);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
test('GET /account: served on the hub’s apex, stamped; never on a child, the builders host or the status host', async () => {
|
|
346
|
+
modules.setModuleOverrides({ 'platform-identity': false });
|
|
347
|
+
const child = await get('/account', 'game.test.local');
|
|
348
|
+
assert.equal(child.status, 404, 'a child instance has no platform account to edit, so no page');
|
|
349
|
+
modules.setModuleOverrides({ 'platform-identity': true });
|
|
350
|
+
const hub = await get('/account', 'game.test.local');
|
|
351
|
+
assert.equal(hub.status, 200);
|
|
352
|
+
assert.match(hub.body, /— Your account<\/title>/);
|
|
353
|
+
assert.match(hub.body, /window\.__BRANDING__=/);
|
|
354
|
+
assert.doesNotMatch(hub.body, /\{\{[a-zA-Z]+\}\}/);
|
|
355
|
+
for (const host of ['builders.test.local', 'status.test.local']) {
|
|
356
|
+
const other = await get('/account', host);
|
|
357
|
+
assert.doesNotMatch(other.body, /— Your account<\/title>/, `${host} must not serve /account`);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ── 5. the hall's side: one link-out row ─────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
const HALL_HTML = fs.readFileSync(path.join(HALL, 'settings.html'), 'utf8');
|
|
364
|
+
const HALL_JS = fs.readFileSync(path.join(HALL, 'settings.js'), 'utf8');
|
|
365
|
+
|
|
366
|
+
test('the hall keeps no copy of the privacy controls — one hidden row links out to the hub', () => {
|
|
367
|
+
for (const gone of ['cross-project-scroll', 'privacy-scroll', 'cross-project-rows', 'privacy-rows']) {
|
|
368
|
+
assert.ok(!HALL_HTML.includes(`id="${gone}"`), `settings.html still carries #${gone}`);
|
|
369
|
+
}
|
|
370
|
+
assert.doesNotMatch(HALL_JS, /\/me\/cross-project/, 'the hall no longer reads or writes the account flags');
|
|
371
|
+
assert.match(HALL_HTML, /<section class="scroll" id="account-hub-scroll"[^>]*data-group="account" hidden>/);
|
|
372
|
+
assert.match(HALL_HTML, /<meta name="hub-origin" content="\{\{hubOrigin\}\}">/, 'the hub origin is stamped server-side');
|
|
373
|
+
assert.match(HALL_JS, /const origin = window\.OTB\.hubOrigin\(\);/, 'the one shared hub-origin reader (dom-utils.js), not a private copy');
|
|
374
|
+
assert.match(HALL_JS, /link\.href = `\$\{origin\}\/account`;/);
|
|
375
|
+
for (const anchor of ['cross-project', 'privacy', 'profile-privacy']) {
|
|
376
|
+
assert.match(HALL_JS, new RegExp(`'${anchor}': 'account-hub-scroll'`), `old deep link #${anchor} lands on the row`);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('the hall trusts only a bare https hub origin (OTB.hubOrigin, shared by Settings and the profile nudge)', () => {
|
|
381
|
+
const DOM_UTILS = fs.readFileSync(path.join(HALL, 'dom-utils.js'), 'utf8');
|
|
382
|
+
const fn = DOM_UTILS.match(/ function hubOrigin\(\) \{[\s\S]*?\n \}\n/);
|
|
383
|
+
assert.ok(fn, 'hubOrigin() is a whole function in dom-utils.js');
|
|
384
|
+
assert.match(DOM_UTILS, /\n hubOrigin, \/\/ task 1003485/, 'and it is exported on window.OTB');
|
|
385
|
+
const origin = (content) => vm.runInNewContext(`${fn[0]}; hubOrigin()`, {
|
|
386
|
+
URL, document: { querySelector: () => ({ getAttribute: () => content }) },
|
|
387
|
+
});
|
|
388
|
+
assert.equal(origin('https://cloudbongos.com'), 'https://cloudbongos.com');
|
|
389
|
+
assert.equal(origin('https://cloudbongos.com/'), 'https://cloudbongos.com');
|
|
390
|
+
assert.equal(origin(''), null, 'no hub (a vanilla instance) → no row');
|
|
391
|
+
assert.equal(origin('http://cloudbongos.com'), null);
|
|
392
|
+
assert.equal(origin('https://cloudbongos.com/account'), null, 'a value with a path is not an origin');
|
|
393
|
+
assert.equal(origin('https://user@cloudbongos.com'), null);
|
|
394
|
+
});
|
|
@@ -400,5 +400,69 @@ await test('a failed mark re-enables the button rather than dead-ending', async
|
|
|
400
400
|
assert.equal(r.markBtn().disabled, false);
|
|
401
401
|
});
|
|
402
402
|
|
|
403
|
+
// --- task 1004134: the author's scope recommendation is shown --------------
|
|
404
|
+
//
|
|
405
|
+
// A Full Idea carries its author's reading of how big it turned out (BV2.R13):
|
|
406
|
+
// a goal of its own or a criterion inside an existing goal. The filing form
|
|
407
|
+
// asked it and the idea read returned it, but this page never showed it, so
|
|
408
|
+
// the board read a Full Idea without the one line written for them.
|
|
409
|
+
// The question and the option labels come from the server (GET /inbox/template),
|
|
410
|
+
// never from this page.
|
|
411
|
+
const REC_SPEC = {
|
|
412
|
+
label: 'What do you think this is',
|
|
413
|
+
hint: 'Your reading of how big it turned out.',
|
|
414
|
+
required: false,
|
|
415
|
+
options: [{ value: 'goal', label: 'A goal of its own' }, { value: 'criterion', label: 'A criterion inside an existing goal' }],
|
|
416
|
+
};
|
|
417
|
+
const TEMPLATE_WITH_REC = { ...TEMPLATE, recommendation: REC_SPEC };
|
|
418
|
+
|
|
419
|
+
await test('a Full Idea shows its author\'s recommendation, in the server\'s words (task 1004134)', async () => {
|
|
420
|
+
const r = render({ ...fullIdea, recommendation: 'criterion' }, { template: TEMPLATE_WITH_REC });
|
|
421
|
+
await settle();
|
|
422
|
+
const html = r.html();
|
|
423
|
+
assert.ok(html.includes('What do you think this is'), 'the question is on the page');
|
|
424
|
+
assert.ok(html.includes('a criterion inside an existing goal'), 'the chosen option, by its label');
|
|
425
|
+
assert.ok(!html.includes('a goal of its own'), 'and only the chosen one');
|
|
426
|
+
// the Board Room's own sentence for the same field (board-room.js), so the
|
|
427
|
+
// two pages cannot drift into two vocabularies
|
|
428
|
+
assert.ok(html.includes('Its author recommends this becomes <strong>a criterion inside an existing goal</strong>. The board decides.'));
|
|
429
|
+
// It is the author's reading, not the board's decision (spec decision 9).
|
|
430
|
+
assert.match(html, /author/i, 'the page says whose reading it is');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await test('a Full Idea filed with no recommendation says so, and invents none', async () => {
|
|
434
|
+
const r = render({ ...fullIdea, recommendation: null }, { template: TEMPLATE_WITH_REC });
|
|
435
|
+
await settle();
|
|
436
|
+
const html = r.html();
|
|
437
|
+
assert.ok(html.includes('What do you think this is'));
|
|
438
|
+
assert.ok(html.includes('Its author gave no scope recommendation.'), 'the absence is rendered honestly, in the Board Room\'s words');
|
|
439
|
+
assert.ok(!html.includes('a goal of its own') && !html.includes('a criterion inside'), 'no option is shown as picked');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await test('a value the served options do not name is printed, not dropped', async () => {
|
|
443
|
+
const r = render({ ...fullIdea, recommendation: 'epic' }, { template: TEMPLATE_WITH_REC });
|
|
444
|
+
await settle();
|
|
445
|
+
assert.ok(r.html().includes('epic'));
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
await test('the recommendation survives a template outage, as its stored value', async () => {
|
|
449
|
+
const r = render({ ...fullIdea, recommendation: 'goal' }, { templateFails: true });
|
|
450
|
+
await settle();
|
|
451
|
+
assert.ok(r.html().includes('goal'), 'the stored value is shown');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await test('a quick idea shows no recommendation line: it was never asked', async () => {
|
|
455
|
+
const r = render({ ...fullIdea, grade: 'quick', recommendation: null }, { template: TEMPLATE_WITH_REC });
|
|
456
|
+
await settle();
|
|
457
|
+
assert.ok(!r.html().includes('What do you think this is'));
|
|
458
|
+
assert.ok(!r.html().includes('Its author gave no scope recommendation.'));
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
await test('a recommendation cannot become markup', async () => {
|
|
462
|
+
const r = render({ ...fullIdea, recommendation: '<img src=x onerror=alert(1)>' }, { template: TEMPLATE_WITH_REC });
|
|
463
|
+
await settle();
|
|
464
|
+
assert.ok(!r.html().includes('<img src=x'), 'escaped');
|
|
465
|
+
});
|
|
466
|
+
|
|
403
467
|
console.log(`\nidea_detail_page: ${passed} passed, ${failed} failed`);
|
|
404
468
|
if (failed > 0) process.exit(1);
|
|
@@ -447,6 +447,70 @@ await test('a base-v1 Full Idea is refused, naming its vintage', async () => {
|
|
|
447
447
|
assert.ok(!goalInsert());
|
|
448
448
|
});
|
|
449
449
|
|
|
450
|
+
// task 1004226 — the owner's decision (2026-09-29): when a Full Idea's template
|
|
451
|
+
// asks no "what does success look like" question, the RATIFIER writes the
|
|
452
|
+
// success criterion in the ratification request. Never derived from a v1 field.
|
|
453
|
+
const V1_ANSWERS = Object.freeze({
|
|
454
|
+
purpose: 'the purpose, as the author wrote it',
|
|
455
|
+
big_picture: 'the big picture, as the author wrote it',
|
|
456
|
+
why_had: 'why they had it',
|
|
457
|
+
why_matters: 'why it matters',
|
|
458
|
+
fit: 'how it fits',
|
|
459
|
+
});
|
|
460
|
+
const RATIFIER_CRITERION = 'A builder can open the new page and see every idea they filed, newest first.';
|
|
461
|
+
|
|
462
|
+
await test('a base-v1 Full Idea is ratified when the RATIFIER writes the success criterion (task 1004226)', async () => {
|
|
463
|
+
storedIdea = fullIdea({ template_key: 'base-v1', field_values: { ...V1_ANSWERS } });
|
|
464
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: { ...OK_BODY, success_criterion: RATIFIER_CRITERION } });
|
|
465
|
+
assert.equal(r.status, 201, JSON.stringify(r.payload));
|
|
466
|
+
assert.equal(criterionInsert().params[2], RATIFIER_CRITERION, 'the done-when is the ratifier\'s words, verbatim');
|
|
467
|
+
assert.equal(r.payload.done_when_from, 'ratifier', 'the response says whose words the criterion is');
|
|
468
|
+
assert.ok(ideaUpdate() && auditInsert() && ran(/^COMMIT$/), 'the whole act still commits as one');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await test('the author\'s v1 answers all still reach the description, and the criterion is marked as the ratifier\'s', async () => {
|
|
472
|
+
storedIdea = fullIdea({ template_key: 'base-v1', field_values: { ...V1_ANSWERS } });
|
|
473
|
+
await drive(postRatifyGoal, { params: { id: '77' }, body: { ...OK_BODY, success_criterion: RATIFIER_CRITERION } });
|
|
474
|
+
const description = goalInsert().params[2];
|
|
475
|
+
for (const v of Object.values(V1_ANSWERS)) assert.ok(description.includes(v), `the author's answer "${v}" is rendered`);
|
|
476
|
+
assert.match(description, /written by the ratifier/i, 'a reader is told the done-when is not an answer of the author\'s');
|
|
477
|
+
// It is never presented AS an author answer: no heading carries it.
|
|
478
|
+
assert.ok(!/###[^\n]*\n\n[^\n]*A builder can open the new page/.test(description), 'the criterion is not rendered under an author question');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await test('a base-v1 Full Idea with NO ratifier criterion is still refused exactly as before', async () => {
|
|
482
|
+
storedIdea = fullIdea({ template_key: 'base-v1', field_values: { ...V1_ANSWERS } });
|
|
483
|
+
for (const body of [OK_BODY, { ...OK_BODY, success_criterion: ' ' }]) {
|
|
484
|
+
queries = [];
|
|
485
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body });
|
|
486
|
+
assert.equal(r.status, 400, JSON.stringify(body));
|
|
487
|
+
assert.equal(r.payload.reason, 'template_has_no_goal_shape');
|
|
488
|
+
assert.ok(!goalInsert(), 'nothing is created');
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await test('a base-v2 Full Idea keeps the AUTHOR\'s success answer: a ratifier criterion is refused, not substituted', async () => {
|
|
493
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: { ...OK_BODY, success_criterion: RATIFIER_CRITERION } });
|
|
494
|
+
assert.equal(r.status, 400, JSON.stringify(r.payload));
|
|
495
|
+
assert.equal(r.payload.reason, 'template_supplies_done_when');
|
|
496
|
+
assert.ok(!goalInsert(), 'nothing is created');
|
|
497
|
+
// and without one, v2 is exactly as it was
|
|
498
|
+
queries = [];
|
|
499
|
+
const ok = await drive(postRatifyGoal, { params: { id: '77' }, body: OK_BODY });
|
|
500
|
+
assert.equal(ok.status, 201);
|
|
501
|
+
assert.equal(criterionInsert().params[2], ANSWERS.success);
|
|
502
|
+
assert.equal(ok.payload.done_when_from, 'success');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
await test('the ratifier criterion does not get past the board gate or the rank gates', async () => {
|
|
506
|
+
storedIdea = fullIdea({ template_key: 'base-v1', field_values: { ...V1_ANSWERS } });
|
|
507
|
+
boardPass = null;
|
|
508
|
+
const r = await drive(postRatifyGoal, { params: { id: '77' }, body: { ...OK_BODY, success_criterion: RATIFIER_CRITERION } });
|
|
509
|
+
assert.equal(r.status, 409);
|
|
510
|
+
assert.equal(r.payload.error, 'BOARD_HAS_NOT_PASSED');
|
|
511
|
+
assert.ok(!goalInsert());
|
|
512
|
+
});
|
|
513
|
+
|
|
450
514
|
await test('a blank success answer is refused rather than made an empty criterion', async () => {
|
|
451
515
|
// A goal whose only criterion is '' is one the close gate can never satisfy.
|
|
452
516
|
storedIdea = fullIdea({ field_values: { ...ANSWERS, success: ' ' } });
|
|
@@ -59,7 +59,12 @@ const GOALS = [
|
|
|
59
59
|
|
|
60
60
|
function boot({
|
|
61
61
|
search = '', sparks = QUEUE, count = null, idea = SPARK, ideaOk = true, store = {},
|
|
62
|
-
versions = VERSIONS, goals = GOALS,
|
|
62
|
+
versions = VERSIONS, goals = GOALS,
|
|
63
|
+
// The suggester's answer. An ARRAY is the same reply to every read; a
|
|
64
|
+
// FUNCTION of the request is how the out-of-order case below tells the two
|
|
65
|
+
// in-flight reads apart (task 1004187) — with one fixed array, a stale
|
|
66
|
+
// repaint and the right one render identically and nothing is pinned.
|
|
67
|
+
suggestions = [],
|
|
63
68
|
// GRAVITY (task 1004083 / spec R02). The landing verdict the composer draws
|
|
64
69
|
// its one line from. `null` means the read FAILED — distinct from a verdict of
|
|
65
70
|
// `{ outcome: null }`, which is the server saying "nothing to state yet"; the
|
|
@@ -177,7 +182,7 @@ function boot({
|
|
|
177
182
|
},
|
|
178
183
|
goals: {
|
|
179
184
|
getGoals: async () => { calls.push(['goals', null]); return { status: 200, ok: true, data: { goals } }; },
|
|
180
|
-
getGoalsSuggestions: gate('suggest', async (a) => { calls.push(['suggest', a]); return { status: 200, ok: true, data: { suggestions } }; }),
|
|
185
|
+
getGoalsSuggestions: gate('suggest', async (a) => { calls.push(['suggest', a]); return { status: 200, ok: true, data: { suggestions: typeof suggestions === 'function' ? suggestions(a) : suggestions } }; }),
|
|
181
186
|
},
|
|
182
187
|
};
|
|
183
188
|
|
|
@@ -781,12 +786,54 @@ await test('a slow preview answering after a faster one never repaints the stale
|
|
|
781
786
|
assert.equal(r.el('fq-gravity').dataset.tone, 'lands');
|
|
782
787
|
});
|
|
783
788
|
|
|
789
|
+
await test('a slow goal suggestion answering after a faster one never repaints the stale options', async () => {
|
|
790
|
+
// The suggester's half of the same race (task 1004187). `refreshGravity` has
|
|
791
|
+
// carried `gravitySeq` since task 1004083; `refreshGoalSuggestions` carried
|
|
792
|
+
// only `goalTouchedByUser`, which answers a different question (has the
|
|
793
|
+
// builder picked manually?) and says nothing about ordering. Both are fired
|
|
794
|
+
// from the one debounce in `onQuickTitleEdit`, so a builder who keeps typing
|
|
795
|
+
// can have a slow read for words they have already replaced land LAST.
|
|
796
|
+
//
|
|
797
|
+
// The two reads must answer differently or there is nothing to pin, hence the
|
|
798
|
+
// function form of `suggestions`. Only the "Suggested" optgroup carries a
|
|
799
|
+
// `matched:` term — both goals appear in the browse-all half either way — so
|
|
800
|
+
// that is what the assertions read.
|
|
801
|
+
const r = boot({
|
|
802
|
+
defer: ['suggest'],
|
|
803
|
+
suggestions: (a) => (a.query.title === 'the widget is broken'
|
|
804
|
+
? [{ id: '77', title: 'Goal Seventy Seven', score: 4, matched_terms: ['widget'] }]
|
|
805
|
+
: [{ id: '88', title: 'Another goal', score: 4, matched_terms: ['sprocket'] }]),
|
|
806
|
+
});
|
|
807
|
+
await settle(); await settle();
|
|
808
|
+
|
|
809
|
+
r.el('fq-title').value = 'the widget is broken';
|
|
810
|
+
r.el('fq-title')._handlers.input();
|
|
811
|
+
await new Promise((res) => setTimeout(res, 400));
|
|
812
|
+
r.el('fq-title').value = 'the sprocket is broken';
|
|
813
|
+
r.el('fq-title')._handlers.input();
|
|
814
|
+
await new Promise((res) => setTimeout(res, 400));
|
|
815
|
+
|
|
816
|
+
assert.equal(r.deferred.length, 2, 'both suggestion reads are in flight');
|
|
817
|
+
assert.equal(r.deferred[0].args[0].query.title, 'the widget is broken', 'the first asked about the title since replaced');
|
|
818
|
+
assert.equal(r.deferred[1].args[0].query.title, 'the sprocket is broken', 'the second asked about what is typed now');
|
|
819
|
+
|
|
820
|
+
r.deferred[1].release(); // the NEWER answer lands first
|
|
821
|
+
await settle(); await settle();
|
|
822
|
+
assert.match(r.el('fq-goal').innerHTML, /matched: sprocket/, "the current title's suggestion paints");
|
|
823
|
+
|
|
824
|
+
r.deferred[0].release(); // and now the older, slower one arrives
|
|
825
|
+
await settle(); await settle();
|
|
826
|
+
assert.match(r.el('fq-goal').innerHTML, /matched: sprocket/, 'the stale answer does not repaint the picker');
|
|
827
|
+
assert.doesNotMatch(r.el('fq-goal').innerHTML, /matched: widget/, 'suggestions for words already replaced never reach the builder');
|
|
828
|
+
});
|
|
829
|
+
|
|
784
830
|
// Every guard above was reverted and each redded its named case (RUN, and these
|
|
785
831
|
// are the measured counts, not the predicted ones):
|
|
786
832
|
// drop renderGravity from the goal-change handler -> 2 red (named-goal + filing-clears)
|
|
787
833
|
// infer from the select instead of the verdict -> 4 red (both lies, + backlog + refusal)
|
|
788
834
|
// drop the seq guard and paint every answer -> 1 red (the race case; task 1004108)
|
|
789
|
-
//
|
|
835
|
+
// drop goalSuggestSeq from refreshGoalSuggestions -> 1 red (the suggester race; task 1004187)
|
|
836
|
+
// make boot's `defer` never park (gate returns at once) -> 3 red (all three race cases)
|
|
790
837
|
// rename the id in ideas.html -> 1 red (the markup pin ONLY)
|
|
791
838
|
//
|
|
792
839
|
// THE FIRST LINE COST A TEST FIX, and it is the reason to run these rather than
|
|
@@ -802,16 +849,20 @@ await test('a slow preview answering after a faster one never repaints the stale
|
|
|
802
849
|
// previews could never overlap and deleting `if (seq !== gravitySeq) return;`
|
|
803
850
|
// redded nothing — a green suite over a branch no case could enter. `boot({
|
|
804
851
|
// defer: ['preview'] })` parks a named fake's answer until the test hands it
|
|
805
|
-
// back, which makes the overlap real and the guard a tested property. The
|
|
852
|
+
// back, which makes the overlap real and the guard a tested property. The FIFTH
|
|
806
853
|
// line above is the guard on the guard: if `defer` ever stops parking, the race
|
|
807
854
|
// case would quietly go back to testing two sequential reads, so a case asserts
|
|
808
855
|
// that both calls are in flight before the ordering is asserted at all.
|
|
809
856
|
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
// `
|
|
813
|
-
//
|
|
814
|
-
//
|
|
857
|
+
// THE FOURTH LINE CLOSED THE BUG 1004108 LEFT STANDING. `getGoalsSuggestions`
|
|
858
|
+
// was gated the same way and for the same reason but had NO case, because
|
|
859
|
+
// `refreshGoalSuggestions` carried no sequence guard — only `goalTouchedByUser`
|
|
860
|
+
// — so a test of its ordering would have redded against the page rather than
|
|
861
|
+
// pinned it. Task 1004187 gave it `goalSuggestSeq`, and the day the guard
|
|
862
|
+
// existed the harness could already reach it. The guard sits AFTER the catch
|
|
863
|
+
// rather than only before the success render, so a read that fails late cannot
|
|
864
|
+
// clear a newer read's options either; that is the shape `refreshGravity` has
|
|
865
|
+
// always had, and it keeps both exits on ONE branch a single case can red.
|
|
815
866
|
|
|
816
867
|
|
|
817
868
|
// --- the fold (task 1004087 / spec R04, decision 18) ----------------------
|