@bongos/core 1.19.1081 → 1.20.2
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 +528 -193
- 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 +8 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +1666 -1558
- 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/goal-inbox.js +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 +169 -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/rot_inbox.mjs +8 -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,295 @@
|
|
|
1
|
+
// tests/projects_hub_render_connect.mjs — Connect Render from an existing project's page,
|
|
2
|
+
// EXECUTED (task 1004184, ADR 0345).
|
|
3
|
+
//
|
|
4
|
+
// A project whose app has no platform yet (made before the wizard's app step, or whose
|
|
5
|
+
// owner chose "Decide later") gets Connect Render on its "Where its app runs" card. It is
|
|
6
|
+
// the wizard's own flow — its markup cloned, its logic renderConnect() — so this slices the
|
|
7
|
+
// page's region and the shared flow out of projects.html by their sentinels and runs them
|
|
8
|
+
// in `vm` over a small fake DOM built from the wizard's real Render markup.
|
|
9
|
+
//
|
|
10
|
+
// The load-bearing assertions:
|
|
11
|
+
// • it is offered only on the owner's own "no platform" answer, and only for a project
|
|
12
|
+
// the server would take it for; a project that never connects is left as it was
|
|
13
|
+
// • it is the wizard's flow, not a second one: the same ids, prefixed, and the same checks
|
|
14
|
+
// • the key is sent once, in a POST body to THIS project, and forgotten at once — on a
|
|
15
|
+
// refusal too — and the card then shows the app's own progress
|
|
16
|
+
// • moving to another project forgets every answer
|
|
17
|
+
//
|
|
18
|
+
// Run: node --test --test-reporter=tap tests/projects_hub_render_connect.mjs
|
|
19
|
+
|
|
20
|
+
import test from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import vm from 'node:vm';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
29
|
+
|
|
30
|
+
function fence(start, end) {
|
|
31
|
+
const from = HUB.indexOf(start), to = HUB.indexOf(end, from);
|
|
32
|
+
assert.ok(from > -1 && to > from, `fenced: ${start.trim()}`);
|
|
33
|
+
return HUB.slice(from, to);
|
|
34
|
+
}
|
|
35
|
+
const CONNECT = fence(' /* ── connecting Render (task 1004352', ' /* end of connecting Render */');
|
|
36
|
+
const PAGE = fence(' var stopAppWatch = null;', " /* end of Connect Render from the project's page */");
|
|
37
|
+
const APPEL = (HUB.match(/function appEl\(tag, cls, text\) \{[\s\S]*?\n \}/) || [''])[0];
|
|
38
|
+
assert.ok(APPEL, 'the page\'s own appEl() is lifted');
|
|
39
|
+
const SETUP = (HUB.match(/<div class="renderSetup" id="renderSetup" hidden>[\s\S]*?<div class="hint" id="h-render" role="status"> <\/div>/) || [''])[0];
|
|
40
|
+
assert.ok(SETUP, 'the wizard\'s Render markup is found');
|
|
41
|
+
const CARD = (HUB.match(/<div class="card mSec" id="mAppCard" hidden>[\s\S]*?\n <\/div>\n/) || [''])[0];
|
|
42
|
+
|
|
43
|
+
const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
|
|
44
|
+
const TEA_A = 'tea-aaaaaaaa1111';
|
|
45
|
+
const LOOKUP = '/api/bongos/provisioning/render/lookup';
|
|
46
|
+
const RENDER = '/api/bongos/provisioning/instances/7/render';
|
|
47
|
+
const SERVICES = { services: [{ id: 'srv-aaaaaaaa1111', name: 'shop', repo: 'https://github.com/example-owner/shop', url: null }] };
|
|
48
|
+
const INST = { id: 7, slug: 'shop', status: 'active', target_ref: 'example-owner/shop' };
|
|
49
|
+
const flush = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); };
|
|
50
|
+
|
|
51
|
+
// ── a fake DOM: what the region and the flow use ──
|
|
52
|
+
const escText = (x) => String(x).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
53
|
+
class Text { constructor(t) { this.text = String(t); } html() { return escText(this.text); } get textContent() { return this.text; } }
|
|
54
|
+
class El {
|
|
55
|
+
constructor(tag, id) { this.tagName = tag; this.id = id || ''; this.children = []; this.attrs = {}; this.listeners = {};
|
|
56
|
+
this.hidden = false; this.value = ''; this.className = ''; this.checked = false; this.disabled = false; this.type = ''; this.name = ''; this.focused = false; }
|
|
57
|
+
appendChild(c) { this.children.push(c); return c; }
|
|
58
|
+
insertBefore(c, ref) { const i = this.children.indexOf(ref); if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
|
|
59
|
+
get firstChild() { return this.children[0] || null; }
|
|
60
|
+
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
61
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
62
|
+
set textContent(v) { this.children = v === '' || v == null ? [] : [new Text(v)]; }
|
|
63
|
+
get textContent() { return this.children.map((c) => c.textContent).join(''); }
|
|
64
|
+
addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); }
|
|
65
|
+
fire(ev, event) { (this.listeners[ev] || []).forEach((fn) => fn.call(this, event || { target: this })); }
|
|
66
|
+
focus() { this.focused = true; }
|
|
67
|
+
all() { return [this, ...this.children.filter((c) => c instanceof El).flatMap((c) => c.all())]; }
|
|
68
|
+
querySelectorAll(sel) {
|
|
69
|
+
const kids = this.all().slice(1);
|
|
70
|
+
if (sel === '[id]') return kids.filter((e) => e.id);
|
|
71
|
+
if (sel === '[aria-describedby]') return kids.filter((e) => e.getAttribute('aria-describedby') != null);
|
|
72
|
+
throw new Error('unexpected selector ' + sel);
|
|
73
|
+
}
|
|
74
|
+
cloneNode() {
|
|
75
|
+
const c = new El(this.tagName, this.id);
|
|
76
|
+
Object.assign(c.attrs, this.attrs); c.className = this.className; c.hidden = this.hidden; c.value = this.value; c.type = this.type;
|
|
77
|
+
this.children.forEach((k) => c.appendChild(k instanceof El ? k.cloneNode(true) : new Text(k.text)));
|
|
78
|
+
return c;
|
|
79
|
+
}
|
|
80
|
+
html() {
|
|
81
|
+
const a = [this.id ? ` id="${this.id}"` : '', this.className ? ` class="${this.className}"` : '', this.type ? ` type="${this.type}"` : '', this.name ? ` name="${this.name}"` : '',
|
|
82
|
+
...Object.entries(this.attrs).map(([k, v]) => ` ${k}="${v}"`), this.checked ? ' checked' : '', this.disabled ? ' disabled' : ''].join('');
|
|
83
|
+
return `<${this.tagName}${a}>${this.children.map((c) => c.html()).join('')}</${this.tagName}>`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The wizard's #renderSetup, one node per id in the REAL markup (flat: the flow only ever
|
|
88
|
+
// finds its elements by id), so a field added to the step is here the day it lands.
|
|
89
|
+
function wizardSetup() {
|
|
90
|
+
const root = new El('div', 'renderSetup');
|
|
91
|
+
root.className = 'renderSetup'; root.hidden = true;
|
|
92
|
+
for (const m of SETUP.matchAll(/<(\w+)[^>]*\sid="([^"]+)"([^>]*)>/g)) {
|
|
93
|
+
if (m[2] === 'renderSetup') continue;
|
|
94
|
+
const e = new El(m[1], m[2]);
|
|
95
|
+
const d = (m[0].match(/aria-describedby="([^"]+)"/) || [])[1];
|
|
96
|
+
if (d) e.setAttribute('aria-describedby', d);
|
|
97
|
+
if (m[1] === 'input') e.type = (m[0].match(/type="([^"]+)"/) || [])[1] || 'text';
|
|
98
|
+
root.appendChild(e);
|
|
99
|
+
}
|
|
100
|
+
return root;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function load({ answers = {} } = {}) {
|
|
104
|
+
const roots = [wizardSetup()];
|
|
105
|
+
const card = new El('div', 'mAppCard'); card.hidden = true;
|
|
106
|
+
['mApp', 'mAppConnect', 'mAppConnectOpen', 'mAppConnectForm', 'mAppConnectGo', 'mAppConnectCancel'].forEach((id) => {
|
|
107
|
+
const e = new El(id.endsWith('Open') || id.endsWith('Go') || id.endsWith('Cancel') ? 'button' : 'div', id);
|
|
108
|
+
if (id === 'mAppConnect' || id === 'mAppConnectForm') e.hidden = true;
|
|
109
|
+
card.appendChild(e);
|
|
110
|
+
});
|
|
111
|
+
roots.push(card);
|
|
112
|
+
const byId = (id) => { for (const r of roots) { const e = r.all().find((x) => x.id === id); if (e) return e; } return null; };
|
|
113
|
+
const document = { getElementById: byId, createElement: (t) => new El(t), createTextNode: (t) => new Text(t) };
|
|
114
|
+
const calls = [], watches = [];
|
|
115
|
+
const fetch = async (url, init) => {
|
|
116
|
+
const body = init && init.body ? JSON.parse(init.body) : null;
|
|
117
|
+
calls.push({ url, method: (init && init.method) || 'GET', body });
|
|
118
|
+
const k = url + (body && body.owner_id && url === LOOKUP ? '#' + body.owner_id : '');
|
|
119
|
+
const a = answers[k] !== undefined ? answers[k] : answers[url];
|
|
120
|
+
if (a === undefined) return { ok: false, status: 404, json: async () => ({ error: { message: 'not found' } }) };
|
|
121
|
+
if (a.status) return { ok: false, status: a.status, json: async () => ({ error: { message: a.message } }) };
|
|
122
|
+
return { ok: true, status: 200, json: async () => a };
|
|
123
|
+
};
|
|
124
|
+
// the status watch lives in the task-1004353 block; record it, and let a test drive its offer
|
|
125
|
+
const watchApp = (id, box, alive, everyMs, cardEl, offer) => { watches.push({ id, box, alive, everyMs, card: cardEl, offer }); return () => {}; };
|
|
126
|
+
const ctx = { document, fetch, watchApp, API: '/api/bongos', manageId: 7, JSON, String, Number, Object, encodeURIComponent };
|
|
127
|
+
ctx.$ = byId;
|
|
128
|
+
vm.createContext(ctx);
|
|
129
|
+
vm.runInContext(APPEL + '\n' + CONNECT + '\n' + PAGE + '\nthis.api = { loadAppStatus, offerAppConnect };', ctx);
|
|
130
|
+
const q = (id) => byId('m-' + id);
|
|
131
|
+
// What the watch does with an answer: show the card when the status view or the offer has something.
|
|
132
|
+
const paint = (data) => { const w = watches[watches.length - 1]; const offered = w.offer(data); card.hidden = !(data && data.app) && !offered; return offered; };
|
|
133
|
+
return { ...ctx.api, ctx, card, byId, q, calls, watches, paint, wizard: roots[0],
|
|
134
|
+
open: () => byId('mAppConnectOpen').fire('click'),
|
|
135
|
+
typeKey: (v) => { q('f-renderKey').value = v; q('f-renderKey').fire('input'); },
|
|
136
|
+
check: async () => { q('renderCheck').fire('click'); await flush(); },
|
|
137
|
+
pickWorkspace: async (i) => { q('renderWsList').fire('change', { target: { getAttribute: () => String(i) } }); await flush(); },
|
|
138
|
+
pickNew: () => q('renderSvcList').fire('change', { target: { name: 'm-renderSvc', value: 'new', disabled: false } }),
|
|
139
|
+
go: async () => { byId('mAppConnectGo').fire('click'); await flush(); },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const NONE = { ok: true, app: null, env_vars: [], values_held: false };
|
|
143
|
+
const ANSWERS = { [LOOKUP]: { workspaces: [{ id: TEA_A, name: 'Mine', type: 'team' }] }, [LOOKUP + '#' + TEA_A]: SERVICES };
|
|
144
|
+
|
|
145
|
+
async function readyToSend(w) {
|
|
146
|
+
w.loadAppStatus(7, INST); w.paint(NONE); w.open();
|
|
147
|
+
w.typeKey(KEY); await w.check(); await w.pickWorkspace(0); w.pickNew();
|
|
148
|
+
w.q('f-renderStart').value = 'npm start';
|
|
149
|
+
w.q('renderNew').fire('input', { target: w.q('f-renderStart') });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── the markup and the one-flow rule ────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
test('the card carries Connect Render, and the page builds nodes, never an HTML string', () => {
|
|
155
|
+
assert.ok(CARD, '#mAppCard exists');
|
|
156
|
+
assert.match(CARD, /<div id="mApp"><\/div>\n\s*<div class="appConnect" id="mAppConnect" hidden>/, 'under the status, hidden until offered');
|
|
157
|
+
assert.match(CARD, /<button type="button" class="btn small" id="mAppConnectOpen">Connect Render<\/button>/);
|
|
158
|
+
assert.match(CARD, /We keep hosting this project’s Builders' hall either way/, 'it says the hall stays with us (ADR 0345 D1)');
|
|
159
|
+
for (const [name, src] of [['the page region', PAGE], ['the shared flow', CONNECT]]) {
|
|
160
|
+
assert.doesNotMatch(src, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${name} builds nodes`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('the wizard and the page run the same flow — renderConnect() is called from both, and defined once', () => {
|
|
165
|
+
assert.equal((HUB.match(/function renderConnect\(/g) || []).length, 1);
|
|
166
|
+
assert.match(HUB, /var rc = renderConnect\(\$, state, \{/, 'the wizard');
|
|
167
|
+
assert.match(PAGE, /appConnect = renderConnect\(appConnectQ, appConnectSt, \{/, 'the project page');
|
|
168
|
+
assert.match(PAGE, /\$\('renderSetup'\)\.cloneNode\(true\)/, 'the page borrows the wizard\'s markup, not a copy of it');
|
|
169
|
+
assert.equal((HUB.match(/function renderLookup\(|function paintWorkspaces\(\) \{\n var list = renderWorkspaces/g) || []).length, 0, 'the wizard\'s old private copy is gone');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('the added styles keep links on the link ink', () => {
|
|
173
|
+
const css = (HUB.match(/\/\* the same step on a project's page \(task 1004184\)[\s\S]*?\n(?!\.appConnect)/) || [''])[0];
|
|
174
|
+
assert.ok(css.includes('.appConnect'), 'the block is found');
|
|
175
|
+
assert.doesNotMatch(css, /--accent-deep/);
|
|
176
|
+
assert.match(HUB, /\.renderWhy a\{[^}]*color:var\(--link-ink\)/, 'the key-help link the page borrows is on --link-ink');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── when it is offered ──────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
test('offered on the owner\'s own "no platform" answer, for a project the server would take', () => {
|
|
182
|
+
const w = load();
|
|
183
|
+
w.loadAppStatus(7, INST);
|
|
184
|
+
assert.equal(w.watches.length, 1);
|
|
185
|
+
assert.equal(w.watches[0].card, w.byId('mAppCard'), 'the watch is handed the card');
|
|
186
|
+
assert.equal(w.paint(NONE), true);
|
|
187
|
+
assert.equal(w.byId('mAppConnect').hidden, false);
|
|
188
|
+
assert.equal(w.card.hidden, false);
|
|
189
|
+
assert.equal(w.byId('mAppConnectForm').hidden, true, 'the form waits for the owner to open it');
|
|
190
|
+
assert.equal(w.calls.length, 0, 'nothing is asked of Render until then');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('not offered — and the card stays hidden — when the project could not take it or the answer is not the owner\'s', () => {
|
|
194
|
+
for (const [why, inst, data] of [
|
|
195
|
+
['someone else\'s project: the owner-only read answered nothing', INST, null],
|
|
196
|
+
['a project already on Render', INST, { ok: true, app: { platform: 'render', state: 'live', stage: 'live' }, env_vars: [] }],
|
|
197
|
+
['a removed project', { ...INST, status: 'torn_down' }, NONE],
|
|
198
|
+
['a project being removed', { ...INST, status: 'tearing_down' }, NONE],
|
|
199
|
+
['no repository to build', { ...INST, target_ref: null }, NONE],
|
|
200
|
+
]) {
|
|
201
|
+
const w = load();
|
|
202
|
+
w.loadAppStatus(7, inst);
|
|
203
|
+
assert.equal(w.paint(data), false, why);
|
|
204
|
+
assert.equal(w.byId('mAppConnect').hidden, true, why);
|
|
205
|
+
if (!(data && data.app)) assert.equal(w.card.hidden, true, why + ': the card hides');
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── the flow ────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
test('opening it borrows the wizard\'s step under prefixed ids, and leaves the wizard\'s own alone', () => {
|
|
212
|
+
const w = load();
|
|
213
|
+
w.loadAppStatus(7, INST); w.paint(NONE);
|
|
214
|
+
w.open();
|
|
215
|
+
const copy = w.byId('m-renderSetup');
|
|
216
|
+
assert.ok(copy, 'the step is cloned in');
|
|
217
|
+
assert.equal(w.byId('mAppConnectForm').firstChild, copy, 'above the Connect / Cancel buttons');
|
|
218
|
+
assert.equal(copy.hidden, false);
|
|
219
|
+
assert.equal(w.byId('mAppConnectForm').hidden, false);
|
|
220
|
+
assert.equal(w.byId('mAppConnectOpen').hidden, true);
|
|
221
|
+
assert.equal(w.q('f-renderKey').focused, true, 'focus lands on the key');
|
|
222
|
+
assert.equal(w.q('f-renderKey').getAttribute('aria-describedby'), 'm-h-renderKey', 'its description points at its own hint');
|
|
223
|
+
assert.ok(w.wizard.all().every((e) => !e.id.startsWith('m-')), 'the wizard keeps its ids');
|
|
224
|
+
w.byId('mAppConnectCancel').fire('click'); w.open();
|
|
225
|
+
assert.equal(w.byId('mAppConnectForm').children.filter((c) => c.id === 'm-renderSetup').length, 1, 'cloned once, however often it is opened');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('the same checks as the wizard, with the page\'s own words for a missing key', async () => {
|
|
229
|
+
const w = load({ answers: ANSWERS });
|
|
230
|
+
w.loadAppStatus(7, INST); w.paint(NONE); w.open();
|
|
231
|
+
await w.go();
|
|
232
|
+
assert.match(w.q('h-render').textContent, /^Check your Render key first\.$/, 'no "Decide later" here — there is no such choice on this card');
|
|
233
|
+
w.typeKey('ghp_notrender'); await w.check();
|
|
234
|
+
assert.match(w.q('h-renderKey').textContent, /starts with “rnd_”/);
|
|
235
|
+
assert.equal(w.calls.length, 0, 'a malformed key never leaves the page');
|
|
236
|
+
w.typeKey(KEY); await w.check(); await w.pickWorkspace(0);
|
|
237
|
+
const list = w.q('renderSvcList').html();
|
|
238
|
+
assert.match(list, /name="m-renderSvc" value="new"/, 'its own radio group, so it never joins the wizard\'s');
|
|
239
|
+
assert.match(list, /Render builds it from <code>example-owner\/shop<\/code>/, 'the project\'s own repository');
|
|
240
|
+
assert.match(list, /data-i="0">[\s\S]*Link shop/, 'its own app can be linked');
|
|
241
|
+
await w.go();
|
|
242
|
+
assert.match(w.q('h-render').textContent, /new app or link/);
|
|
243
|
+
assert.equal(w.calls.filter((c) => c.url === RENDER).length, 0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('Connect sends the key once, to this project, forgets it, and the card shows the app\'s progress', async () => {
|
|
247
|
+
const w = load({ answers: { ...ANSWERS, [RENDER]: { ok: true, queued: true } } });
|
|
248
|
+
await readyToSend(w);
|
|
249
|
+
await w.go();
|
|
250
|
+
const sent = w.calls.filter((c) => c.url === RENDER);
|
|
251
|
+
assert.equal(sent.length, 1);
|
|
252
|
+
assert.equal(sent[0].method, 'POST');
|
|
253
|
+
assert.deepEqual(sent[0].body, { key: KEY, owner_id: TEA_A, mode: 'greenfield', runtime: 'node', start_command: 'npm start', web_plan: 'starter', database: false });
|
|
254
|
+
for (const c of w.calls) assert.ok(!c.url.includes(KEY), 'no URL carries the key');
|
|
255
|
+
assert.equal(w.q('f-renderKey').value, '', 'the field is emptied');
|
|
256
|
+
assert.equal(w.byId('mAppConnectForm').hidden, true, 'the form closes');
|
|
257
|
+
assert.equal(w.watches.length, 2, 'the status is read again');
|
|
258
|
+
assert.equal(String(w.watches[1].id), '7');
|
|
259
|
+
assert.equal(w.paint({ ok: true, app: { platform: 'render', state: 'provisioning', stage: 'creating', waiting: true }, env_vars: [] }), false, 'and once the app has a platform, the offer steps aside');
|
|
260
|
+
assert.equal(w.byId('mAppConnect').hidden, true);
|
|
261
|
+
assert.equal(w.card.hidden, false, 'for the progress panel');
|
|
262
|
+
await w.go();
|
|
263
|
+
assert.equal(w.calls.filter((c) => c.url === RENDER).length, 1, 'a second press has no key to send');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('a refusal says so in the server\'s words, and the key is gone all the same', async () => {
|
|
267
|
+
const w = load({ answers: { ...ANSWERS, [RENDER]: { status: 409, message: 'A \'provision\' run is running for this project, so your key was not kept. Try again once it has finished.' } } });
|
|
268
|
+
await readyToSend(w);
|
|
269
|
+
await w.go();
|
|
270
|
+
assert.match(w.q('h-render').textContent, /A 'provision' run is running for this project/);
|
|
271
|
+
assert.match(w.q('h-render').className, /err/);
|
|
272
|
+
assert.equal(w.q('f-renderKey').value, '');
|
|
273
|
+
assert.equal(w.byId('mAppConnectGo').disabled, false, 'the owner can try again');
|
|
274
|
+
assert.equal(w.watches.length, 1, 'no progress to watch');
|
|
275
|
+
await w.go();
|
|
276
|
+
assert.match(w.q('h-render').textContent, /Check your Render key first/, 'a retry starts with the key');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('Cancel forgets the key; a reload of the same project keeps the open form; another project forgets every answer', async () => {
|
|
280
|
+
const w = load({ answers: ANSWERS });
|
|
281
|
+
w.loadAppStatus(7, INST); w.paint(NONE); w.open();
|
|
282
|
+
w.typeKey(KEY); await w.check(); await w.pickWorkspace(0);
|
|
283
|
+
w.loadAppStatus(7, INST); w.paint(NONE);
|
|
284
|
+
assert.equal(w.byId('mAppConnectForm').hidden, false, 'the page\'s reload of this project leaves the form open');
|
|
285
|
+
assert.equal(w.q('f-renderKey').value, KEY, 'with what the owner typed');
|
|
286
|
+
w.byId('mAppConnectCancel').fire('click');
|
|
287
|
+
assert.equal(w.q('f-renderKey').value, '');
|
|
288
|
+
assert.equal(w.byId('mAppConnectForm').hidden, true);
|
|
289
|
+
assert.equal(w.byId('mAppConnectOpen').hidden, false);
|
|
290
|
+
w.ctx.manageId = 8;
|
|
291
|
+
w.loadAppStatus(8, { ...INST, id: 8, target_ref: 'example-owner/other' });
|
|
292
|
+
w.paint(NONE); w.open();
|
|
293
|
+
assert.equal(w.q('renderWs').hidden, true, 'nothing Render said about project 7 is shown for project 8');
|
|
294
|
+
assert.match(w.q('h-renderKey').textContent, /^\s*$/, 'and it is not asked to re-enter a key it never gave');
|
|
295
|
+
});
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
// on a mismatched workspace, on a Render error and on a decrypt failure
|
|
10
10
|
// • the ownerId guard refuses a mismatched response, and the run stops there — nothing
|
|
11
11
|
// is created after it
|
|
12
|
-
// • ADOPT makes no write call to Render at all
|
|
13
|
-
//
|
|
12
|
+
// • ADOPT makes no write call to Render at all, and links only a service that builds
|
|
13
|
+
// THIS project's repository; GREENFIELD creates the database only when asked for, then
|
|
14
|
+
// the app's service — built from the project's own repository the way its owner said,
|
|
15
|
+
// never the Bongos core (task 1004350, ADR 0345)
|
|
14
16
|
// • the plan is read from the project's row, not a constant
|
|
15
17
|
// • a failure is TERMINAL: the runner retires it instead of re-queuing it, because the
|
|
16
18
|
// key is gone and the owner retries by entering it again
|
|
@@ -47,7 +49,7 @@ function fakeRender(over = {}) {
|
|
|
47
49
|
'GET /postgres/dpg-new/connection-info': { internalConnectionString: 'postgres://internal' },
|
|
48
50
|
'POST /services': { deployId: 'dep-1', service: { id: 'srv-new', ownerId: OURS, serviceDetails: { url: SVC_URL } } },
|
|
49
51
|
'GET /services/srv-new': { id: 'srv-new', ownerId: OURS, serviceDetails: { url: SVC_URL } },
|
|
50
|
-
'GET /services/srv-old': { id: 'srv-old', ownerId: OURS, serviceDetails: { url: 'https://old.onrender.com' } },
|
|
52
|
+
'GET /services/srv-old': { id: 'srv-old', ownerId: OURS, repo: 'https://github.com/Example-Owner/demo', serviceDetails: { url: 'https://old.onrender.com' } },
|
|
51
53
|
'POST /services/srv-new/deploys': { id: 'dep-2' },
|
|
52
54
|
...over,
|
|
53
55
|
};
|
|
@@ -81,7 +83,7 @@ function harness({ host = {}, intent = {}, render = {}, decryptThrows = false }
|
|
|
81
83
|
renderApiFactory: ({ token, ownerId }) => new RenderApi({ token, ownerId, dryRun: false, fetchImpl: r.fetchImpl }),
|
|
82
84
|
};
|
|
83
85
|
const inst = { id: 18, slug: 'demo', owner_builder_id: 7, target_ref: 'example-owner/demo', status: 'active',
|
|
84
|
-
app_host: { platform: 'render', mode: 'greenfield', ownerId: OURS, ...host } };
|
|
86
|
+
app_host: { platform: 'render', mode: 'greenfield', ownerId: OURS, runtime: 'node', buildCommand: 'npm ci', startCommand: 'npm start', database: true, ...host } };
|
|
85
87
|
const it = { id: 44, action: 'render-standup', borrowed_key_enc: 'v1.enc', created_at: new Date().toISOString(), ...intent };
|
|
86
88
|
const cleared = () => writes.filter((w) => /UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = \$1/.test(w.sql) && w.params[0] === 44).length;
|
|
87
89
|
const appHost = () => writes.filter((w) => /SET app_host/.test(w.sql)).map((w) => JSON.parse(w.params[1]));
|
|
@@ -91,45 +93,62 @@ function harness({ host = {}, intent = {}, render = {}, decryptThrows = false }
|
|
|
91
93
|
|
|
92
94
|
const writesTo = (calls) => calls.filter((c) => c.method !== 'GET');
|
|
93
95
|
|
|
94
|
-
console.log('
|
|
95
|
-
await ta(
|
|
96
|
+
console.log("greenfield — the owner's app:");
|
|
97
|
+
await ta("with a database: the database, then the app's service — and the key cleared", async () => {
|
|
96
98
|
const h = harness();
|
|
97
99
|
const out = await h.run();
|
|
98
100
|
assert.equal(out.ok, true, out.error);
|
|
99
|
-
assert.deepEqual(writesTo(h.r.calls).map((c) => c.method + ' ' + c.path
|
|
100
|
-
'
|
|
101
|
-
'PUT /services/srv-new/env-vars/*', 'PUT /services/srv-new/env-vars/*', 'PUT /services/srv-new/env-vars/*', 'PUT /services/srv-new/env-vars/*',
|
|
102
|
-
'POST /services/srv-new/deploys',
|
|
103
|
-
]);
|
|
104
|
-
const origins = h.r.calls.filter((c) => c.method === 'PUT').map((c) => [c.path.split('/').pop(), c.body.value]);
|
|
105
|
-
assert.deepEqual(origins, [['BONGOS_PUBLIC_ORIGIN', SVC_URL], ['BONGOS_BUILDERS_ORIGIN', SVC_URL], ['BONGOS_STATUS_ORIGIN', SVC_URL], ['BONGOS_OAUTH_ORIGIN', SVC_URL]]);
|
|
101
|
+
assert.deepEqual(writesTo(h.r.calls).map((c) => c.method + ' ' + c.path), ['POST /postgres', 'POST /services'],
|
|
102
|
+
'no env var PUTs and no extra deploy: Render deploys a new service on its own');
|
|
106
103
|
assert.equal(h.cleared(), 1, 'the key column is cleared');
|
|
107
104
|
const host = h.lastHost();
|
|
108
|
-
assert.deepEqual([host.postgresId, host.serviceId, host.state, host.serviceUrl, host.error], ['dpg-new', 'srv-new', 'deploying', SVC_URL, null], 'the row records
|
|
105
|
+
assert.deepEqual([host.postgresId, host.serviceId, host.state, host.serviceUrl, host.webPlan, host.error], ['dpg-new', 'srv-new', 'deploying', SVC_URL, 'starter', null], 'the row records the service, its URL and the plan');
|
|
109
106
|
});
|
|
110
107
|
|
|
111
|
-
await ta('
|
|
108
|
+
await ta('without a database: no Postgres is created and the app gets no DATABASE_URL', async () => {
|
|
109
|
+
const h = harness({ host: { database: false } });
|
|
110
|
+
const out = await h.run();
|
|
111
|
+
assert.equal(out.ok, true, out.error);
|
|
112
|
+
assert.ok(!h.r.calls.some((c) => c.path.startsWith('/postgres')), 'nothing about a database is called');
|
|
113
|
+
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
114
|
+
assert.deepEqual(svc.envVars, []);
|
|
115
|
+
assert.equal(h.lastHost().postgresId, undefined);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await ta("the service is the owner's app: their repository, runtime and commands — not the Bongos core", async () => {
|
|
112
119
|
const h = harness();
|
|
113
120
|
await h.run();
|
|
114
121
|
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
115
122
|
assert.equal(svc.ownerId, OURS);
|
|
116
123
|
assert.equal(svc.repo, 'https://github.com/example-owner/demo');
|
|
117
124
|
assert.equal(svc.serviceDetails.runtime, 'node');
|
|
118
|
-
assert.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
assert.
|
|
122
|
-
assert.equal(env.DATABASE_URL, 'postgres://internal');
|
|
123
|
-
assert.equal(env.PGDATABASE, 'postgres://internal');
|
|
124
|
-
assert.match(env.BUILDER_SECRET_KEY, /^[0-9a-f]{64}$/);
|
|
125
|
-
assert.equal(env.BONGOS_FIRST_ADMIN, 'owner-login');
|
|
126
|
-
assert.ok(!('PORT' in env), 'Render injects PORT');
|
|
125
|
+
assert.deepEqual(svc.serviceDetails.envSpecificDetails, { buildCommand: 'npm ci', startCommand: 'npm start' });
|
|
126
|
+
const src = JSON.stringify(svc);
|
|
127
|
+
assert.doesNotMatch(src, /bongos|platform-server|migrate[.]sh|FIRST_ADMIN|BUILDER_SECRET_KEY|_ORIGIN/i, 'nothing of the hall rides along');
|
|
128
|
+
assert.deepEqual(svc.envVars, [{ key: 'DATABASE_URL', value: 'postgres://internal' }], 'the only value the platform sets');
|
|
127
129
|
const stored = JSON.stringify(h.writes.map((w) => w.params));
|
|
128
|
-
assert.ok(!stored.includes(
|
|
129
|
-
|
|
130
|
+
assert.ok(!stored.includes('postgres://internal') && !stored.includes('rnd_borrowed'), 'no env var value and no key is ever written to our database');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await ta("docker builds the repository's own Dockerfile, with no commands", async () => {
|
|
134
|
+
const h = harness({ host: { runtime: 'docker', buildCommand: undefined, startCommand: undefined } });
|
|
135
|
+
const out = await h.run();
|
|
136
|
+
assert.equal(out.ok, true, out.error);
|
|
137
|
+
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
138
|
+
assert.equal(svc.serviceDetails.runtime, 'docker');
|
|
139
|
+
assert.deepEqual(svc.serviceDetails.envSpecificDetails, {});
|
|
130
140
|
});
|
|
131
141
|
|
|
132
|
-
await ta('
|
|
142
|
+
await ta('a native runtime with no start command is refused before anything is created', async () => {
|
|
143
|
+
const h = harness({ host: { startCommand: undefined } });
|
|
144
|
+
const out = await h.run();
|
|
145
|
+
assert.equal(out.ok, false);
|
|
146
|
+
assert.match(out.error, /start command/);
|
|
147
|
+
assert.deepEqual(writesTo(h.r.calls), []);
|
|
148
|
+
assert.equal(h.cleared(), 1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await ta("the plan is the project's own setting, not a constant", async () => {
|
|
133
152
|
const h = harness({ host: { webPlan: 'standard', dbPlan: 'basic_1gb', region: 'frankfurt' } });
|
|
134
153
|
await h.run();
|
|
135
154
|
const pg = h.r.calls.find((c) => c.path === '/postgres').body;
|
|
@@ -147,7 +166,7 @@ await ta('a retry resumes onto what an earlier run created — no second databas
|
|
|
147
166
|
const h = harness({ host: { postgresId: 'dpg-new', serviceId: 'srv-new' } });
|
|
148
167
|
const out = await h.run();
|
|
149
168
|
assert.equal(out.ok, true, out.error);
|
|
150
|
-
assert.
|
|
169
|
+
assert.deepEqual(writesTo(h.r.calls), []);
|
|
151
170
|
});
|
|
152
171
|
|
|
153
172
|
console.log('\nadopt:');
|
|
@@ -160,6 +179,15 @@ await ta('links an existing service and makes NO write call to Render', async ()
|
|
|
160
179
|
assert.equal(h.lastHost().serviceUrl, 'https://old.onrender.com');
|
|
161
180
|
assert.equal(h.cleared(), 1);
|
|
162
181
|
});
|
|
182
|
+
await ta('a service that builds a DIFFERENT repository is refused — its URL would pose as this app', async () => {
|
|
183
|
+
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' }, render: { 'GET /services/srv-old': { id: 'srv-old', ownerId: OURS, repo: 'https://github.com/example-owner/other-app' } } });
|
|
184
|
+
const out = await h.run();
|
|
185
|
+
assert.equal(out.ok, false);
|
|
186
|
+
assert.ok(out.error.includes('other-app, not example-owner/demo'), out.error);
|
|
187
|
+
assert.deepEqual(writesTo(h.r.calls), []);
|
|
188
|
+
assert.equal(h.lastHost().state, 'failed');
|
|
189
|
+
assert.equal(h.cleared(), 1);
|
|
190
|
+
});
|
|
163
191
|
await ta('adopting a service from another workspace is refused, and the key still cleared', async () => {
|
|
164
192
|
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' }, render: { 'GET /services/srv-old': { id: 'srv-old', ownerId: THEIRS } } });
|
|
165
193
|
const out = await h.run();
|
|
@@ -249,6 +277,71 @@ await ta('a failure is retired on its FIRST attempt, never re-queued', async ()
|
|
|
249
277
|
assert.match(d.resolved[0].err, /cannot reach workspace/);
|
|
250
278
|
});
|
|
251
279
|
|
|
280
|
+
console.log('\nthe wizard path — the app straight after the project, in the same run (task 1004352):');
|
|
281
|
+
const { afterProvision } = require('../scripts/gds/provision-render.js');
|
|
282
|
+
function afterHarness({ keyOnRow = 'v1.enc', host = {} } = {}) {
|
|
283
|
+
const h = harness({ host });
|
|
284
|
+
const base = h.deps.db.query;
|
|
285
|
+
h.deps.db = { query: async (sql, params) => (/SELECT borrowed_key_enc FROM provisioning_intents/.test(sql) ? { rows: [{ borrowed_key_enc: keyOnRow }] } : base(sql, params)) };
|
|
286
|
+
h.deps.provisioning = { getInstanceById: async () => ({ ...h.inst }) };
|
|
287
|
+
return h;
|
|
288
|
+
}
|
|
289
|
+
await ta('the project is up and a key rode the run → the app is set up, the key cleared, the run a success', async () => {
|
|
290
|
+
const h = afterHarness();
|
|
291
|
+
const out = await afterProvision(h.inst, h.deps, { id: 44, created_at: new Date().toISOString() }, { ok: true, port: 3100 });
|
|
292
|
+
assert.deepEqual(out, { ok: true, port: 3100 });
|
|
293
|
+
assert.ok(h.r.calls.some((c) => c.method === 'POST' && c.path === '/services'));
|
|
294
|
+
assert.equal(h.cleared(), 1);
|
|
295
|
+
});
|
|
296
|
+
await ta('the key is re-read from the row — one attached after the run was claimed is still spent', async () => {
|
|
297
|
+
const h = afterHarness();
|
|
298
|
+
await afterProvision(h.inst, h.deps, { id: 44, created_at: new Date().toISOString(), borrowed_key_enc: null }, { ok: true });
|
|
299
|
+
assert.ok(h.r.calls.length > 0);
|
|
300
|
+
assert.equal(h.cleared(), 1);
|
|
301
|
+
});
|
|
302
|
+
await ta('the app fails but the project is up → the run stays a success, with a note, and the key cleared', async () => {
|
|
303
|
+
const h = afterHarness();
|
|
304
|
+
h.r.calls.length = 0;
|
|
305
|
+
const hh = afterHarness({ host: { ownerId: 'tea-unreachable1' } });
|
|
306
|
+
const out = await afterProvision(hh.inst, hh.deps, { id: 44, created_at: new Date().toISOString() }, { ok: true });
|
|
307
|
+
assert.equal(out.ok, true);
|
|
308
|
+
assert.match(out.doneNote, /not set up on Render: this Render key cannot reach workspace/);
|
|
309
|
+
assert.equal(hh.lastHost().state, 'failed');
|
|
310
|
+
assert.equal(hh.cleared(), 1);
|
|
311
|
+
});
|
|
312
|
+
await ta('the project failed → Render is never called, the key cleared, the app marked failed', async () => {
|
|
313
|
+
const h = afterHarness();
|
|
314
|
+
const out = await afterProvision(h.inst, h.deps, { id: 44 }, { ok: false, error: 'DNS preflight failed' });
|
|
315
|
+
assert.deepEqual(out, { ok: false, error: 'DNS preflight failed' }, 'the provision result is passed through, so its retry rules are unchanged');
|
|
316
|
+
assert.equal(h.r.calls.length, 0);
|
|
317
|
+
assert.equal(h.cleared(), 1, 'a provision retry never carries a key into a later run');
|
|
318
|
+
assert.match(h.lastHost().error, /connect Render again/);
|
|
319
|
+
});
|
|
320
|
+
await ta('no key on the run → the provision result is untouched and Render is never called', async () => {
|
|
321
|
+
const h = afterHarness({ keyOnRow: null });
|
|
322
|
+
const r = { ok: true, already: true };
|
|
323
|
+
assert.equal(await afterProvision(h.inst, h.deps, { id: 44 }, r), r);
|
|
324
|
+
assert.equal(h.r.calls.length, 0);
|
|
325
|
+
assert.equal(h.cleared(), 0);
|
|
326
|
+
});
|
|
327
|
+
await ta('the runner reaches afterProvision on every provision run', () => {
|
|
328
|
+
const src = readFileSync(new URL('../scripts/gds/provision.js', import.meta.url), 'utf8');
|
|
329
|
+
assert.match(src, /intent\.action === 'provision'\) result = await require\('\.\/provision-render\.js'\)\.afterProvision\(inst, deps, intent, await provisionInstance\(inst, deps\)\)/);
|
|
330
|
+
});
|
|
331
|
+
await ta('a link needs a repository to check the service against (1004350 review)', async () => {
|
|
332
|
+
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' } });
|
|
333
|
+
h.inst.target_ref = null;
|
|
334
|
+
const out = await h.run();
|
|
335
|
+
assert.equal(out.ok, false);
|
|
336
|
+
assert.equal(h.cleared(), 1);
|
|
337
|
+
});
|
|
338
|
+
await ta('an app with no build step sends no build command at all (1004350 review)', async () => {
|
|
339
|
+
const h = harness({ host: { buildCommand: undefined } });
|
|
340
|
+
await h.run();
|
|
341
|
+
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
342
|
+
assert.deepEqual(svc.serviceDetails.envSpecificDetails, { startCommand: 'npm start' });
|
|
343
|
+
});
|
|
344
|
+
|
|
252
345
|
console.log('\nteardown forgets, never deletes:');
|
|
253
346
|
await ta('forgetRenderApp records the disconnect and calls nothing', async () => {
|
|
254
347
|
const writes = [], logs = [];
|
|
@@ -567,5 +567,67 @@ await ta('cmdRunIntents (--apply): a dedicated refusal RETIRES at the cap with t
|
|
|
567
567
|
assert.match(statusWrites[0].patch.error_note, /dedicated/);
|
|
568
568
|
});
|
|
569
569
|
|
|
570
|
+
console.log('\nrenamedEnvCarry — the prelaunch password carried on the box (task 1004395):');
|
|
571
|
+
|
|
572
|
+
// A value that could never appear anywhere by accident, so "not in the text" means something.
|
|
573
|
+
const SECRET = 'zq7-Wall-Pass-$&-9x';
|
|
574
|
+
|
|
575
|
+
t('carries the old name to the new one when ONLY the old name is set, copying the right-hand side verbatim', () => {
|
|
576
|
+
const r = P.renamedEnvCarry(`PORT=3002\nSTEALTH_PASSWORD=${SECRET}\n`);
|
|
577
|
+
assert.deepEqual(r.vars, { PRELAUNCH_PASSWORD: SECRET });
|
|
578
|
+
assert.deepEqual(r.carried, ['STEALTH_PASSWORD -> PRELAUNCH_PASSWORD']);
|
|
579
|
+
assert.deepEqual(P.renamedEnvCarry('STEALTH_PASSWORD="quoted pw"\n').vars, { PRELAUNCH_PASSWORD: '"quoted pw"' }, 'quotes kept, so systemd reads the same value');
|
|
580
|
+
assert.deepEqual(P.renamedEnvCarry(`A=1\r\nSTEALTH_PASSWORD=${SECRET}\r\n`).vars, { PRELAUNCH_PASSWORD: SECRET }, 'a CRLF file carries no stray \\r');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
t('no-op when the new name is already set — even to a different value', () => {
|
|
584
|
+
for (const body of [`STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=${SECRET}\n`, `STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=other\n`, `PRELAUNCH_PASSWORD=${SECRET}\n`]) {
|
|
585
|
+
assert.deepEqual(P.renamedEnvCarry(body), { vars: {}, carried: [] }, body);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
t('no-op when the old name is absent, empty or commented out; an EMPTY new line is still filled', () => {
|
|
590
|
+
for (const body of ['PORT=3002\n', 'STEALTH_PASSWORD=\n', 'STEALTH_PASSWORD=""\n', `# STEALTH_PASSWORD=${SECRET}\n`, 'X_STEALTH_PASSWORD=abc\n', '', null]) {
|
|
591
|
+
assert.deepEqual(P.renamedEnvCarry(body), { vars: {}, carried: [] }, String(body));
|
|
592
|
+
}
|
|
593
|
+
assert.deepEqual(P.renamedEnvCarry(`STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=\n`).vars, { PRELAUNCH_PASSWORD: SECRET });
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
t('the last line of a key wins, as systemd reads an EnvironmentFile', () => {
|
|
597
|
+
assert.deepEqual(P.renamedEnvCarry('STEALTH_PASSWORD=first\nSTEALTH_PASSWORD=second\n').vars, { PRELAUNCH_PASSWORD: 'second' });
|
|
598
|
+
assert.deepEqual(P.renamedEnvCarry('STEALTH_PASSWORD=pw\nPRELAUNCH_PASSWORD=set\nPRELAUNCH_PASSWORD=\n').vars, { PRELAUNCH_PASSWORD: 'pw' }, 'a later blank line unsets it');
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await ta('the leg carries it in the SAME in-place patch: every other line untouched, the old name kept, the value in NO log, event or result', async () => {
|
|
602
|
+
const envBody = `${LIVE_ENV}STEALTH_PASSWORD=${SECRET}\n`;
|
|
603
|
+
const { deps, writes, events, logs } = recorder({ apply: true, envBody });
|
|
604
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
605
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
606
|
+
const out = writes[0].body;
|
|
607
|
+
assert.ok(out.endsWith(`STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=${SECRET}\n`), 'appended after the old line, which stays for task 1003830 to retire');
|
|
608
|
+
assert.equal(out.split('\n').length, envBody.split('\n').length + 1, 'exactly one line added');
|
|
609
|
+
assert.match(out, /_GITHUB_CLIENT_SECRET=0123456789abcdef0123456789abcdef01234567/, 'creds untouched');
|
|
610
|
+
assert.match(events[0].detail, /carried STEALTH_PASSWORD -> PRELAUNCH_PASSWORD/, 'the event names the carry — how the owner verifies it without reading the file');
|
|
611
|
+
assert.ok(logs.some((l) => /carry the renamed env forward: STEALTH_PASSWORD -> PRELAUNCH_PASSWORD/.test(l)));
|
|
612
|
+
const said = [...logs, ...events.map((e) => e.detail), JSON.stringify(r)].join('\n');
|
|
613
|
+
assert.ok(!said.includes(SECRET) && !said.includes('zq7-Wall'), 'the password never leaves the file');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
await ta('nothing to carry → the leg is byte-identical to before: no extra line, no carry wording', async () => {
|
|
617
|
+
const { deps, writes, events, logs } = recorder({ apply: true });
|
|
618
|
+
await P.settingsApplyInstance(activeStandalone, deps);
|
|
619
|
+
assert.equal(writes[0].body.split('\n').length, LIVE_ENV.split('\n').length);
|
|
620
|
+
assert.doesNotMatch(writes[0].body, /PRELAUNCH_PASSWORD/);
|
|
621
|
+
assert.doesNotMatch(events[0].detail, /carried/);
|
|
622
|
+
assert.ok(!logs.some((l) => /carry the renamed env/.test(l)));
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
await ta('a failed write reports the error without the value', async () => {
|
|
626
|
+
const { deps } = recorder({ apply: true, writeThrows: true, envBody: `${LIVE_ENV}STEALTH_PASSWORD=${SECRET}\n` });
|
|
627
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
628
|
+
assert.equal(r.ok, false);
|
|
629
|
+
assert.ok(!r.error.includes(SECRET), r.error);
|
|
630
|
+
});
|
|
631
|
+
|
|
570
632
|
console.log(`\nprovision_settings_apply: ${passed} passed, ${failed} failed`);
|
|
571
633
|
process.exit(failed ? 1 : 0);
|