@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,495 @@
|
|
|
1
|
+
// tests/render_deploy.mjs — the render-deploy module (task 1004270): the deploy page of a
|
|
2
|
+
// project whose app deploys through Render.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS AT RISK. A press on this page acts on the OWNER's Render account, so the failures
|
|
5
|
+
// that matter are quiet ones: a deploy or rollback that goes ahead without the second step, or
|
|
6
|
+
// against a picture that went stale; a rollback to a deploy that never ran; an act that reaches
|
|
7
|
+
// a service in someone else's workspace; the key ending up in a response; a module that is off
|
|
8
|
+
// still answering. Each case below pins one of them.
|
|
9
|
+
//
|
|
10
|
+
// NOTHING HERE TOUCHES RENDER. The client is a stub (or the real client over a recording fake
|
|
11
|
+
// fetch), and the pool is a stub. No key, no network.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/render_deploy.mjs
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import vm from 'node:vm';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const deploys = require('../modules/render-deploy/deploys.js');
|
|
25
|
+
const buildApp = require('../modules/render-deploy/routes/app.js');
|
|
26
|
+
const buildHistory = require('../modules/render-deploy/routes/history.js');
|
|
27
|
+
const buildAct = require('../modules/render-deploy/routes/act.js');
|
|
28
|
+
const { RenderApi } = require('../scripts/gds/render-api.js');
|
|
29
|
+
|
|
30
|
+
const KEY = 'rnd_TESTKEYTESTKEYTESTKEY1234';
|
|
31
|
+
const OWNER = 'tea-ownerownerowner';
|
|
32
|
+
const SERVICE = 'srv-serviceservice1';
|
|
33
|
+
const APP = { owner_id: OWNER, service_id: SERVICE, service_name: 'shop' };
|
|
34
|
+
|
|
35
|
+
// Newest first, as Render lists them.
|
|
36
|
+
const D = (id, status, commitId, message, at) => ({ id, status, trigger: 'new_commit', createdAt: at, finishedAt: at, commit: { id: commitId, message, createdAt: at } });
|
|
37
|
+
const DEPLOYS = [
|
|
38
|
+
D('dep-live00000001', 'live', 'c3c3c3c3c3', 'Merge pull request from x/task-1004057\n\nbody', '2026-09-29T12:00:00Z'),
|
|
39
|
+
D('dep-old000000002', 'deactivated', 'c2c2c2c2c2', 'a change with no id', '2026-09-28T12:00:00Z'),
|
|
40
|
+
D('dep-fail00000003', 'build_failed', 'c1c1c1c1c1', 'broken build', '2026-09-27T12:00:00Z'),
|
|
41
|
+
D('dep-first0000004', 'deactivated', 'c0c0c0c0c0', 'the first deploy shown', '2026-09-26T12:00:00Z'),
|
|
42
|
+
];
|
|
43
|
+
const SERVICE_OBJ = { id: SERVICE, name: 'shop', ownerId: OWNER, serviceDetails: { plan: 'starter', url: 'https://shop.onrender.com' }, previews: { generation: 'off' } };
|
|
44
|
+
|
|
45
|
+
function fakePool({ ledger = [], titles = {}, app = APP } = {}) {
|
|
46
|
+
const calls = [];
|
|
47
|
+
return {
|
|
48
|
+
calls,
|
|
49
|
+
async query(sql, params) {
|
|
50
|
+
calls.push({ sql, params });
|
|
51
|
+
if (/FROM render_deploy_app/.test(sql)) return { rows: app ? [app] : [] };
|
|
52
|
+
if (/INSERT INTO render_deploy_app/.test(sql)) return { rows: [{ owner_id: params[0], service_id: params[1], service_name: params[2], connected_at: 'now' }] };
|
|
53
|
+
if (/WHERE status = ANY/.test(sql)) return { rows: ledger };
|
|
54
|
+
if (/WHERE id = ANY/.test(sql)) return { rows: params[0].filter((id) => titles[id]).map((id) => ({ id, title: titles[id] })) };
|
|
55
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A stub Render client that records every call. `list` is what Render's deploy list says.
|
|
61
|
+
function stubClient({ list = DEPLOYS, owners = [{ id: OWNER }], service = SERVICE_OBJ } = {}) {
|
|
62
|
+
const calls = [];
|
|
63
|
+
return {
|
|
64
|
+
calls,
|
|
65
|
+
async listOwners() { calls.push('listOwners'); return owners; },
|
|
66
|
+
async getService(id) { calls.push(`getService ${id}`); return service; },
|
|
67
|
+
async listDeploys() { calls.push('listDeploys'); return list; },
|
|
68
|
+
async rollbackTo(sid, did) { calls.push(`rollbackTo ${did}`); return { id: 'dep-new000000009' }; },
|
|
69
|
+
async triggerDeploy(sid) { calls.push('triggerDeploy'); return { id: 'dep-new000000009' }; },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Drive a route's LAST handler (after its gates) with a fake req/res.
|
|
74
|
+
function handlerOf(router, method, p) {
|
|
75
|
+
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
76
|
+
return layer.route.stack.at(-1).handle;
|
|
77
|
+
}
|
|
78
|
+
async function call(handler, { body, headers = {}, builder = { id: 3 } } = {}) {
|
|
79
|
+
const out = { status: 200, body: null };
|
|
80
|
+
const res = { status(s) { out.status = s; return this; }, json(v) { out.body = v; return this; } };
|
|
81
|
+
await handler({ body, headers, builder, query: {} }, res);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
const withKey = { 'x-render-key': KEY };
|
|
85
|
+
|
|
86
|
+
// ---- the history: tasks in each deploy, and what is live ---------------------------------
|
|
87
|
+
|
|
88
|
+
test('a deploy carries the tasks its commit names, and the tasks the ledger shipped in its range', () => {
|
|
89
|
+
const h = deploys.shapeHistory({
|
|
90
|
+
service: SERVICE_OBJ,
|
|
91
|
+
deploys: DEPLOYS,
|
|
92
|
+
ledger: [
|
|
93
|
+
{ id: '2001', title: 'Shipped between the old and the live commit', at: '2026-09-29T06:00:00Z' },
|
|
94
|
+
{ id: '2002', title: 'Shipped before that range', at: '2026-09-28T06:00:00Z' },
|
|
95
|
+
],
|
|
96
|
+
titles: { 1004057: 'The named task' },
|
|
97
|
+
});
|
|
98
|
+
const live = h.deploys[0];
|
|
99
|
+
assert.deepEqual(live.tasks.map((t) => [t.id, t.via]), [['1004057', 'named'], ['2001', 'window']]);
|
|
100
|
+
assert.equal(live.tasks[0].title, 'The named task');
|
|
101
|
+
// 2002 falls in the range of the older deploy (26th, 27th and 28th), not the live one's.
|
|
102
|
+
assert.ok(!live.tasks.some((t) => t.id === '2002'));
|
|
103
|
+
assert.ok(h.deploys[1].tasks.some((t) => t.id === '2002'), 'and it lands in the range it belongs to');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the oldest deploy shown says its range starts there, and gets no window tasks', () => {
|
|
107
|
+
const h = deploys.shapeHistory({ service: SERVICE_OBJ, deploys: DEPLOYS, ledger: [{ id: '2003', title: 'x', at: '2026-09-20T00:00:00Z' }] });
|
|
108
|
+
const oldest = h.deploys.at(-1);
|
|
109
|
+
assert.equal(oldest.rangeStartsHere, true);
|
|
110
|
+
assert.deepEqual(oldest.tasks, []);
|
|
111
|
+
assert.equal(h.deploys[0].rangeStartsHere, false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('two deploys of one commit carry nothing new: the range runs back to the previous DIFFERENT commit', () => {
|
|
115
|
+
const list = [
|
|
116
|
+
D('dep-redeploy00001', 'live', 'cbcbcbcbcb', 'same commit', '2026-09-29T12:00:00Z'),
|
|
117
|
+
D('dep-firstdeploy02', 'deactivated', 'cbcbcbcbcb', 'same commit', '2026-09-29T12:00:00Z'),
|
|
118
|
+
D('dep-earlier000003', 'deactivated', 'caca000000', 'earlier', '2026-09-28T12:00:00Z'),
|
|
119
|
+
];
|
|
120
|
+
const h = deploys.shapeHistory({ service: SERVICE_OBJ, deploys: list, ledger: [{ id: '77', title: 't', at: '2026-09-29T00:00:00Z' }] });
|
|
121
|
+
assert.equal(h.deploys[0].tasks.some((t) => t.id === '77'), true, 'the redeploy spans back past its twin');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('exactly the live deploy is marked live, and the page is told which it is', () => {
|
|
125
|
+
const h = deploys.shapeHistory({ service: SERVICE_OBJ, deploys: DEPLOYS });
|
|
126
|
+
assert.equal(h.liveDeployId, 'dep-live00000001');
|
|
127
|
+
assert.deepEqual(h.deploys.filter((d) => d.live).map((d) => d.id), ['dep-live00000001']);
|
|
128
|
+
const none = deploys.shapeHistory({ service: SERVICE_OBJ, deploys: DEPLOYS.slice(1) });
|
|
129
|
+
assert.equal(none.liveDeployId, null, 'nothing live is said as nothing live, not guessed');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('only a deploy that ran and was replaced can be rolled back to', () => {
|
|
133
|
+
const h = deploys.shapeHistory({ service: SERVICE_OBJ, deploys: DEPLOYS });
|
|
134
|
+
const by = Object.fromEntries(h.deploys.map((d) => [d.id, d.canRollbackTo]));
|
|
135
|
+
assert.deepEqual(by, { 'dep-live00000001': false, 'dep-old000000002': true, 'dep-fail00000003': false, 'dep-first0000004': true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a status is shown in words, never as the slug', () => {
|
|
139
|
+
const h = deploys.shapeHistory({ service: SERVICE_OBJ, deploys: DEPLOYS });
|
|
140
|
+
assert.equal(h.deploys[2].statusWords, 'The build failed');
|
|
141
|
+
assert.doesNotMatch(JSON.stringify(h.deploys.map((d) => d.statusWords)), /build_failed|deactivated/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('every deploy says whether it can be previewed, and where it cannot it says so and why', () => {
|
|
145
|
+
const h = deploys.shapeHistory({ service: SERVICE_OBJ, deploys: DEPLOYS });
|
|
146
|
+
for (const d of h.deploys) {
|
|
147
|
+
assert.equal(d.preview.available, false);
|
|
148
|
+
assert.match(d.preview.reason, /roll back to it/);
|
|
149
|
+
}
|
|
150
|
+
const withPr = deploys.previewOf({ previews: { generation: 'automatic' } });
|
|
151
|
+
assert.equal(withPr.available, false, 'a plan with preview environments still has no per-deploy address');
|
|
152
|
+
assert.match(withPr.reason, /pull requests/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a press says what it costs, one sentence per action, and the plan it lands on', () => {
|
|
156
|
+
const c = deploys.costOf(SERVICE_OBJ);
|
|
157
|
+
assert.match(c.deploy, /starter/);
|
|
158
|
+
assert.match(c.deploy, /build minutes/);
|
|
159
|
+
assert.match(c.rollback, /no new build/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('the history reading goes through the guarded client and the ledger, and names the service', async () => {
|
|
163
|
+
const client = stubClient();
|
|
164
|
+
const pool = fakePool({ ledger: [{ id: '2001', title: 'Shipped in range', at: new Date('2026-09-29T06:00:00Z') }], titles: { 1004057: 'Named' } });
|
|
165
|
+
const h = await deploys.readHistory({ pool, client, app: APP });
|
|
166
|
+
assert.equal(h.app.serviceName, 'shop');
|
|
167
|
+
assert.equal(h.service.plan, 'starter');
|
|
168
|
+
assert.equal(h.deploys[0].tasks.length, 2);
|
|
169
|
+
assert.deepEqual(client.calls.slice(0, 2), [`getService ${SERVICE}`, 'listDeploys']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---- the routes ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
test('history: no connected app, then no key, then the reading', async () => {
|
|
175
|
+
const off = await call(handlerOf(buildHistory({ makeClient: () => stubClient(), pool: fakePool({ app: null }) }), 'get', '/render-deploy/history'), { headers: withKey });
|
|
176
|
+
assert.deepEqual(off.body, { connected: false });
|
|
177
|
+
const client = stubClient();
|
|
178
|
+
const h = handlerOf(buildHistory({ makeClient: () => client, pool: fakePool() }), 'get', '/render-deploy/history');
|
|
179
|
+
const nokey = await call(h, {});
|
|
180
|
+
assert.equal(nokey.body.needsKey, true);
|
|
181
|
+
assert.equal(client.calls.length, 0, 'no key, no call to Render');
|
|
182
|
+
const read = await call(h, { headers: withKey });
|
|
183
|
+
assert.equal(read.body.liveDeployId, 'dep-live00000001');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('history: the key is used for the client and never appears in an answer', async () => {
|
|
187
|
+
let seenKey = null;
|
|
188
|
+
const h = handlerOf(buildHistory({ makeClient: ({ key, ownerId }) => { seenKey = key; assert.equal(ownerId, OWNER, 'the client is bound to the connected workspace'); return stubClient(); }, pool: fakePool() }), 'get', '/render-deploy/history');
|
|
189
|
+
const r = await call(h, { headers: withKey });
|
|
190
|
+
assert.equal(seenKey, KEY);
|
|
191
|
+
assert.ok(!JSON.stringify(r.body).includes(KEY));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('a key of the wrong shape is treated as no key', async () => {
|
|
195
|
+
const client = stubClient();
|
|
196
|
+
const h = handlerOf(buildHistory({ makeClient: () => client, pool: fakePool() }), 'get', '/render-deploy/history');
|
|
197
|
+
const r = await call(h, { headers: { 'x-render-key': 'not-a-render-key' } });
|
|
198
|
+
assert.equal(r.body.needsKey, true);
|
|
199
|
+
assert.equal(client.calls.length, 0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
function actHandler(client, pool = fakePool()) { return handlerOf(buildAct({ makeClient: () => client, pool }), 'post', '/render-deploy/act'); }
|
|
203
|
+
const press = (over = {}) => ({ action: 'rollback', deployId: 'dep-old000000002', confirm: true, expectLiveDeployId: 'dep-live00000001', ...over });
|
|
204
|
+
|
|
205
|
+
test('step two: an act without confirm true does nothing, and never reaches Render', async () => {
|
|
206
|
+
for (const body of [press({ confirm: undefined }), press({ confirm: 'yes' }), press({ confirm: false })]) {
|
|
207
|
+
const client = stubClient();
|
|
208
|
+
const r = await call(actHandler(client), { body, headers: withKey });
|
|
209
|
+
assert.equal(r.status, 400);
|
|
210
|
+
assert.equal(r.body.error.code, 'confirm_needed');
|
|
211
|
+
assert.equal(client.calls.length, 0);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('step two: a confirm that names no live deploy is refused', async () => {
|
|
216
|
+
const client = stubClient();
|
|
217
|
+
const r = await call(actHandler(client), { body: press({ expectLiveDeployId: undefined }), headers: withKey });
|
|
218
|
+
assert.equal(r.body.error.code, 'expectation_needed');
|
|
219
|
+
assert.equal(client.calls.length, 0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('a stale confirm is refused: what was live when step one was drawn is not live now', async () => {
|
|
223
|
+
const client = stubClient();
|
|
224
|
+
const r = await call(actHandler(client), { body: press({ expectLiveDeployId: 'dep-someother0005' }), headers: withKey });
|
|
225
|
+
assert.equal(r.status, 409);
|
|
226
|
+
assert.equal(r.body.error.code, 'stale_history');
|
|
227
|
+
assert.ok(!client.calls.some((c) => /^rollbackTo|triggerDeploy/.test(c)), 'nothing was sent to Render');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('a rollback to a deploy that is the live one, never ran, or is not in this service is refused before any write', async () => {
|
|
231
|
+
for (const [deployId, code] of [['dep-live00000001', 'already_live'], ['dep-fail00000003', 'not_rollbackable'], ['dep-elsewhere0007', 'deploy_not_found']]) {
|
|
232
|
+
const client = stubClient();
|
|
233
|
+
const r = await call(actHandler(client), { body: press({ deployId }), headers: withKey });
|
|
234
|
+
assert.equal(r.body.error.code, code, deployId);
|
|
235
|
+
assert.ok(!client.calls.some((c) => /^rollbackTo|triggerDeploy/.test(c)), `${deployId}: no write`);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('a confirmed rollback sends exactly one rollback, and reads Render back', async () => {
|
|
240
|
+
const client = stubClient();
|
|
241
|
+
const r = await call(actHandler(client), { body: press(), headers: withKey });
|
|
242
|
+
assert.equal(r.status, 200);
|
|
243
|
+
assert.deepEqual(client.calls.filter((c) => /^rollbackTo|triggerDeploy/.test(c)), ['rollbackTo dep-old000000002']);
|
|
244
|
+
assert.equal(r.body.started.deployId, 'dep-new000000009');
|
|
245
|
+
assert.equal(r.body.render.deployId, 'dep-live00000001', 'the answer carries what Render lists now, not just its own 2xx');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('a confirmed deploy sends exactly one deploy trigger', async () => {
|
|
249
|
+
const client = stubClient();
|
|
250
|
+
const r = await call(actHandler(client), { body: { action: 'deploy', confirm: true, expectLiveDeployId: 'dep-live00000001' }, headers: withKey });
|
|
251
|
+
assert.equal(r.status, 200);
|
|
252
|
+
assert.deepEqual(client.calls.filter((c) => /^rollbackTo|triggerDeploy/.test(c)), ['triggerDeploy']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('an act with no key, no app, or an unknown action does nothing', async () => {
|
|
256
|
+
const c1 = stubClient();
|
|
257
|
+
assert.equal((await call(actHandler(c1), { body: press() })).body.error.code, 'render_key_needed');
|
|
258
|
+
assert.equal((await call(actHandler(c1, fakePool({ app: null })), { body: press(), headers: withKey })).body.error.code, 'not_connected');
|
|
259
|
+
assert.equal((await call(actHandler(c1), { body: press({ action: 'delete' }), headers: withKey })).body.error.code, 'bad_action');
|
|
260
|
+
assert.equal(c1.calls.length, 0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('a Render refusal is answered without the key, and a 401 from Render is not a Bongos sign-out', async () => {
|
|
264
|
+
const bad = stubClient();
|
|
265
|
+
bad.listDeploys = async () => { const e = new Error(`Render GET failed: bearer ${KEY}`); e.name = 'RenderApiError'; e.status = 401; throw e; };
|
|
266
|
+
const r = await call(actHandler(bad), { body: press(), headers: withKey });
|
|
267
|
+
assert.equal(r.status, 422, 'never 401: the page would send the browser to sign in');
|
|
268
|
+
assert.ok(!JSON.stringify(r.body).includes(KEY));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('an answer from another workspace stops the act', async () => {
|
|
272
|
+
const bad = stubClient();
|
|
273
|
+
bad.listDeploys = async () => { const e = new Error('mismatch'); e.name = 'RenderOwnerMismatch'; throw e; };
|
|
274
|
+
const r = await call(actHandler(bad), { body: press(), headers: withKey });
|
|
275
|
+
assert.equal(r.status, 403);
|
|
276
|
+
assert.equal(r.body.error.code, 'wrong_workspace');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('connect: the workspace must be one the key can act in, and the service one inside it', async () => {
|
|
280
|
+
const pool = fakePool({ app: null });
|
|
281
|
+
const put = (client) => handlerOf(buildApp({ makeClient: () => client, pool }), 'put', '/render-deploy/app');
|
|
282
|
+
const body = { ownerId: OWNER, serviceId: SERVICE };
|
|
283
|
+
|
|
284
|
+
const out = stubClient({ owners: [{ id: 'tea-someoneelse0001' }] });
|
|
285
|
+
const r1 = await call(put(out), { body, headers: withKey });
|
|
286
|
+
assert.equal(r1.body.error.code, 'workspace_not_reachable');
|
|
287
|
+
assert.ok(!pool.calls.some((c) => /INSERT/.test(c.sql)), 'nothing was saved');
|
|
288
|
+
|
|
289
|
+
const mismatched = stubClient();
|
|
290
|
+
mismatched.getService = async () => { const e = new Error('other workspace'); e.name = 'RenderOwnerMismatch'; throw e; };
|
|
291
|
+
const r2 = await call(put(mismatched), { body, headers: withKey });
|
|
292
|
+
assert.equal(r2.status, 403);
|
|
293
|
+
assert.ok(!pool.calls.some((c) => /INSERT/.test(c.sql)));
|
|
294
|
+
|
|
295
|
+
const r3 = await call(put(stubClient()), { body, headers: withKey });
|
|
296
|
+
assert.equal(r3.status, 200);
|
|
297
|
+
assert.equal(r3.body.app.serviceName, 'shop');
|
|
298
|
+
assert.ok(pool.calls.some((c) => /INSERT/.test(c.sql)));
|
|
299
|
+
assert.ok(!JSON.stringify(pool.calls).includes(KEY), 'the key is in no query');
|
|
300
|
+
|
|
301
|
+
for (const bad of [{ ownerId: 'nope', serviceId: SERVICE }, { ownerId: OWNER, serviceId: 'nope' }]) {
|
|
302
|
+
assert.equal((await call(put(stubClient()), { body: bad, headers: withKey })).status, 400);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('the routes are gated on the deploy door atom, and none is a bare requireRank', () => {
|
|
307
|
+
for (const f of ['app', 'history', 'act']) {
|
|
308
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/render-deploy/routes', `${f}.js`), 'utf8');
|
|
309
|
+
const routes = src.match(/router\.(get|put|post)\(/g) || [];
|
|
310
|
+
const gated = src.match(/api\.requireBuilder, api\.requirePermission\('core\.pin\.move'\)/g) || [];
|
|
311
|
+
assert.equal(gated.length, routes.length, `${f}: every route carries the gate`);
|
|
312
|
+
assert.doesNotMatch(src, /api\.requireRank\(/);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('the key is never stored, logged or written: no module file puts it in a query, a log line or storage', () => {
|
|
317
|
+
const dir = path.join(ROOT, 'modules/render-deploy');
|
|
318
|
+
const files = ['deploys.js', 'render.js', 'routes/app.js', 'routes/history.js', 'routes/act.js', 'public/deploy.js'];
|
|
319
|
+
for (const f of files) {
|
|
320
|
+
const src = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
321
|
+
assert.doesNotMatch(src, /localStorage|sessionStorage|document\.cookie/, `${f} keeps nothing in the browser`);
|
|
322
|
+
assert.doesNotMatch(src, /log\.(info|warn|error)\([^)]*\bkey\b/, `${f} logs no key`);
|
|
323
|
+
}
|
|
324
|
+
const migration = fs.readFileSync(path.join(dir, 'migrations/render_deploy_001_app.sql'), 'utf8');
|
|
325
|
+
const cols = [...migration.matchAll(/^ {2}([a-z_]+) /gm)].map((m) => m[1]);
|
|
326
|
+
assert.deepEqual(cols, ["id", "owner_id", "service_id", "service_name", "connected_by", "connected_at"], "no column could hold a key");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ---- the one client, and its workspace guard ---------------------------------------------
|
|
330
|
+
|
|
331
|
+
test('the real client refuses a rollback into another workspace before any write', async () => {
|
|
332
|
+
const seen = [];
|
|
333
|
+
const fetchImpl = async (url, init) => {
|
|
334
|
+
seen.push(`${init.method} ${url.replace('https://api.render.com/v1', '')}`);
|
|
335
|
+
return { ok: true, status: 200, text: async () => JSON.stringify({ id: SERVICE, ownerId: 'tea-someoneelse0001' }) };
|
|
336
|
+
};
|
|
337
|
+
const api = new RenderApi({ token: KEY, ownerId: OWNER, dryRun: false, fetchImpl });
|
|
338
|
+
await assert.rejects(() => api.rollbackTo(SERVICE, 'dep-old000000002'), (e) => e.name === 'RenderOwnerMismatch');
|
|
339
|
+
await assert.rejects(() => api.listDeploys(SERVICE), (e) => e.name === 'RenderOwnerMismatch');
|
|
340
|
+
assert.ok(!seen.some((s) => s.startsWith('POST')), 'no POST was sent');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('the real client lists deploys in either of Render\'s shapes and posts the rollback to the service', async () => {
|
|
344
|
+
const seen = [];
|
|
345
|
+
const fetchImpl = async (url, init) => {
|
|
346
|
+
const p = url.replace('https://api.render.com/v1', '');
|
|
347
|
+
seen.push(`${init.method} ${p}${init.body ? ' ' + init.body : ''}`);
|
|
348
|
+
if (p === `/services/${SERVICE}`) return { ok: true, status: 200, text: async () => JSON.stringify(SERVICE_OBJ) };
|
|
349
|
+
if (p.startsWith(`/services/${SERVICE}/deploys`)) return { ok: true, status: 200, text: async () => JSON.stringify([{ deploy: DEPLOYS[0], cursor: 'a' }, DEPLOYS[1]]) };
|
|
350
|
+
return { ok: true, status: 200, text: async () => JSON.stringify({ id: 'dep-new000000009' }) };
|
|
351
|
+
};
|
|
352
|
+
const api = new RenderApi({ token: KEY, ownerId: OWNER, dryRun: false, fetchImpl });
|
|
353
|
+
assert.deepEqual((await api.listDeploys(SERVICE)).map((d) => d.id), ['dep-live00000001', 'dep-old000000002']);
|
|
354
|
+
await api.rollbackTo(SERVICE, 'dep-old000000002');
|
|
355
|
+
assert.ok(seen.includes(`POST /services/${SERVICE}/rollback {"deployId":"dep-old000000002"}`));
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ---- the module: on and off ----------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
test('the manifest is default-off, and declares the three routes it ships', () => {
|
|
361
|
+
const m = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/render-deploy/module.json'), 'utf8'));
|
|
362
|
+
assert.equal(m.default, false);
|
|
363
|
+
assert.deepEqual(m.contributes.routes.sort(), ['act', 'app', 'history']);
|
|
364
|
+
for (const r of m.contributes.routes) assert.ok(fs.existsSync(path.join(ROOT, 'modules/render-deploy/routes', `${r}.js`)));
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('turned off, the loader mounts none of its routes; turned on, it mounts all three', () => {
|
|
368
|
+
const loader = require('../src/module-loader/loader.js');
|
|
369
|
+
const mountedFor = (isEnabled) => {
|
|
370
|
+
loader._reset();
|
|
371
|
+
const routers = [];
|
|
372
|
+
loader.mountModuleRoutes({ use: (r) => routers.push(r) }, { isEnabled });
|
|
373
|
+
const paths = routers.flatMap((r) => (r.stack || []).filter((l) => l.route).map((l) => l.route.path));
|
|
374
|
+
return paths.filter((p) => p.startsWith('/render-deploy/'));
|
|
375
|
+
};
|
|
376
|
+
const on = mountedFor((k) => k === 'render-deploy' || k === 'government' || k === 'lifecycle' || k === 'hall-ui' || k === 'memory');
|
|
377
|
+
assert.deepEqual([...new Set(on)].sort(), ['/render-deploy/act', '/render-deploy/app', '/render-deploy/history']);
|
|
378
|
+
const off = mountedFor((k) => k !== 'render-deploy');
|
|
379
|
+
assert.deepEqual(off, [], 'no route of a module that is off');
|
|
380
|
+
loader._reset();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('the hall serves /deploy where the module is on, and sends every other hall to Settings', () => {
|
|
384
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
|
|
385
|
+
assert.match(src, /isModuleEnabled\('provisioning'\) \|\| isModuleEnabled\('render-deploy'\)/);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ---- the page --------------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
function pageHarness({ responses }) {
|
|
391
|
+
const els = {};
|
|
392
|
+
const mk = (id) => (els[id] = els[id] || { id, hidden: true, value: '', handlers: {}, _html: '', set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; }, addEventListener(t, fn) { this.handlers[t] = fn; } });
|
|
393
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
394
|
+
class Element {}
|
|
395
|
+
const requests = [];
|
|
396
|
+
let click = null;
|
|
397
|
+
const KNOWN = ['rd-panel', 'rd-list', 'rd-key', 'rd-owner', 'rd-service'];
|
|
398
|
+
const document = {
|
|
399
|
+
getElementById: (id) => (KNOWN.includes(id) ? mk(id) : null),
|
|
400
|
+
addEventListener: (t, fn) => { if (t === 'click') click = fn; },
|
|
401
|
+
createElement: () => ({}),
|
|
402
|
+
head: { appendChild() {} },
|
|
403
|
+
};
|
|
404
|
+
mk('rd-panel').contains = () => true;
|
|
405
|
+
const window = {
|
|
406
|
+
OTB: { escapeHtml },
|
|
407
|
+
BongosClient: { createClient: () => ({ request: async (method, url, o) => { requests.push({ method, url, headers: (o && o.headers) || {}, body: o && o.body }); return responses(method, url, o); } }) },
|
|
408
|
+
location: { assign() {} },
|
|
409
|
+
};
|
|
410
|
+
const ctx = vm.createContext({ window, document, Element, Date, Number, String, Promise, setTimeout });
|
|
411
|
+
vm.runInContext(fs.readFileSync(path.join(ROOT, 'modules/render-deploy/public/deploy.js'), 'utf8'), ctx);
|
|
412
|
+
const tick = () => new Promise((r) => setTimeout(r, 15));
|
|
413
|
+
// A press on whatever matches `selector`, with an optional data attribute value.
|
|
414
|
+
const press = async (selector, attr) => {
|
|
415
|
+
const t = new Element();
|
|
416
|
+
t.closest = (sel) => (sel === selector || (attr && sel === `[${attr[0]}]`) ? { getAttribute: () => attr && attr[1] } : null);
|
|
417
|
+
click({ target: t });
|
|
418
|
+
await tick();
|
|
419
|
+
};
|
|
420
|
+
const typeKey = (v) => els['rd-key'].handlers.input({ target: { value: v } });
|
|
421
|
+
return { els, requests, press, typeKey, tick, list: () => els['rd-list'].innerHTML };
|
|
422
|
+
}
|
|
423
|
+
const url_ends = (u, tail) => String(u).endsWith(tail);
|
|
424
|
+
const ok = (data) => ({ ok: true, status: 200, data });
|
|
425
|
+
const HIST = () => ({ connected: true, needsKey: false, app: { serviceName: 'shop', serviceId: SERVICE }, service: { plan: 'starter' }, ...deploys.shapeHistory({ service: SERVICE_OBJ, deploys: DEPLOYS, titles: { 1004057: 'The named task' } }) });
|
|
426
|
+
const APPRES = ok({ app: { serviceName: 'shop', serviceId: SERVICE } });
|
|
427
|
+
|
|
428
|
+
function livePage(actAnswer) {
|
|
429
|
+
return pageHarness({ responses: (m, url) => {
|
|
430
|
+
if (url_ends(url, "/render-deploy/app")) return APPRES;
|
|
431
|
+
if (url_ends(url, "/render-deploy/act")) return actAnswer || ok({ ok: true, render: { statusWords: 'Building' } });
|
|
432
|
+
return ok(HIST());
|
|
433
|
+
} });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
test('the page asks for the key before it reads anything, and sends none until one is typed', async () => {
|
|
437
|
+
const h = livePage();
|
|
438
|
+
await h.tick();
|
|
439
|
+
assert.match(h.list(), /Read from Render/);
|
|
440
|
+
assert.ok(!h.requests.some((r) => r.headers['x-render-key']), 'no key on the wire yet');
|
|
441
|
+
h.typeKey(KEY);
|
|
442
|
+
await h.press('#rd-read');
|
|
443
|
+
const read = h.requests.at(-1);
|
|
444
|
+
assert.equal(read.headers['x-render-key'], KEY, 'the key rides the header');
|
|
445
|
+
assert.ok(!/rnd_/.test(read.url), 'and never the address');
|
|
446
|
+
assert.ok(!('key' in (read.body || {})), 'and never a body');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('the page draws the live mark, the tasks in each deploy, and the plain word on previews', async () => {
|
|
450
|
+
const h = livePage();
|
|
451
|
+
await h.tick(); h.typeKey(KEY); await h.press('#rd-read');
|
|
452
|
+
const html = h.list();
|
|
453
|
+
assert.match(html, /Live now/);
|
|
454
|
+
assert.match(html, /The named task/);
|
|
455
|
+
assert.match(html, /named in the commit/);
|
|
456
|
+
assert.match(html, /the oldest deploy shown, so its range starts here/);
|
|
457
|
+
await h.press('[data-view]', ['data-view', 'dep-old000000002']);
|
|
458
|
+
assert.match(h.list(), /You’re viewing deploy c2c2c2c/);
|
|
459
|
+
assert.match(h.list(), /roll back to it/);
|
|
460
|
+
await h.press('#rd-unview');
|
|
461
|
+
assert.doesNotMatch(h.list(), /You’re viewing/);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('pressing Roll back only opens step two: what happens, what it costs, and no request', async () => {
|
|
465
|
+
const h = livePage();
|
|
466
|
+
await h.tick(); h.typeKey(KEY); await h.press('#rd-read');
|
|
467
|
+
const before = h.requests.length;
|
|
468
|
+
await h.press('[data-rollback]', ['data-rollback', 'dep-old000000002']);
|
|
469
|
+
assert.equal(h.requests.length, before, 'no request was sent by the first press');
|
|
470
|
+
assert.match(h.list(), /Check this before you confirm/);
|
|
471
|
+
assert.match(h.list(), /runs no new build/);
|
|
472
|
+
assert.match(h.list(), /Confirm roll back/);
|
|
473
|
+
await h.press('#rd-cancel');
|
|
474
|
+
assert.doesNotMatch(h.list(), /Confirm roll back/);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('the confirm sends the deploy that was shown, confirm true, and the deploy that was live', async () => {
|
|
478
|
+
const h = livePage();
|
|
479
|
+
await h.tick(); h.typeKey(KEY); await h.press('#rd-read');
|
|
480
|
+
await h.press('[data-rollback]', ['data-rollback', 'dep-old000000002']);
|
|
481
|
+
await h.press('#rd-confirm');
|
|
482
|
+
const act = h.requests.find((r) => url_ends(r.url, "/render-deploy/act"));
|
|
483
|
+
assert.deepEqual(JSON.parse(JSON.stringify(act.body)), { action: 'rollback', deployId: 'dep-old000000002', confirm: true, expectLiveDeployId: 'dep-live00000001' });
|
|
484
|
+
assert.equal(act.headers['x-render-key'], KEY);
|
|
485
|
+
assert.match(h.list(), /Render accepted it/);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('a refusal from the server is shown, with the words that nothing was changed', async () => {
|
|
489
|
+
const h = livePage({ ok: false, status: 409, data: { error: { code: 'stale_history', message: 'What is live on Render changed since you were shown this step.' } } });
|
|
490
|
+
await h.tick(); h.typeKey(KEY); await h.press('#rd-read');
|
|
491
|
+
await h.press('#rd-deploy');
|
|
492
|
+
await h.press('#rd-confirm');
|
|
493
|
+
assert.match(h.list(), /Nothing was changed/);
|
|
494
|
+
assert.match(h.list(), /changed since you were shown this step/);
|
|
495
|
+
});
|
package/tests/ship_preflight.mjs
CHANGED
|
@@ -321,23 +321,22 @@ test('findMainWorktree: returns null outside any git repo', () => {
|
|
|
321
321
|
// ---- closeoutPlan (task 932) ----
|
|
322
322
|
|
|
323
323
|
test('closeoutPlan: clean tree → clean:true, no leftover', () => {
|
|
324
|
-
const plan = closeoutPlan(''
|
|
324
|
+
const plan = closeoutPlan('');
|
|
325
325
|
assert.equal(plan.clean, true);
|
|
326
326
|
assert.deepEqual(plan.leftover, []);
|
|
327
|
-
assert.equal(plan.stashLabel, 'otb-postship-932');
|
|
328
327
|
});
|
|
329
328
|
|
|
330
329
|
test('closeoutPlan: null porcelain is treated as clean', () => {
|
|
331
|
-
const plan = closeoutPlan(null
|
|
330
|
+
const plan = closeoutPlan(null);
|
|
332
331
|
assert.equal(plan.clean, true);
|
|
333
332
|
assert.deepEqual(plan.leftover, []);
|
|
334
333
|
});
|
|
335
334
|
|
|
336
335
|
test('closeoutPlan: untracked-only scratch counts as leftover (preflight lets it through)', () => {
|
|
337
336
|
// The preflight (trackedDirty) IGNORES untracked, so untracked scratch is the
|
|
338
|
-
// common post-ship leftover — closeoutPlan must still flag it
|
|
337
|
+
// common post-ship leftover — closeoutPlan must still flag it.
|
|
339
338
|
const out = '?? scratch/notes.md\n?? art/tmp.png\n';
|
|
340
|
-
const plan = closeoutPlan(out
|
|
339
|
+
const plan = closeoutPlan(out);
|
|
341
340
|
assert.equal(plan.clean, false);
|
|
342
341
|
assert.deepEqual(plan.leftover, ['scratch/notes.md', 'art/tmp.png']);
|
|
343
342
|
// trackedDirty would have seen these as clean — the two helpers differ on purpose.
|
|
@@ -346,13 +345,67 @@ test('closeoutPlan: untracked-only scratch counts as leftover (preflight lets it
|
|
|
346
345
|
|
|
347
346
|
test('closeoutPlan: tracked + untracked mix → all paths, clean:false', () => {
|
|
348
347
|
const out = ' M scripts/gds/ship.js\n?? scratch.txt\nR a.js -> b.js\n';
|
|
349
|
-
const plan = closeoutPlan(out
|
|
348
|
+
const plan = closeoutPlan(out);
|
|
350
349
|
assert.equal(plan.clean, false);
|
|
351
350
|
assert.deepEqual(plan.leftover, ['scripts/gds/ship.js', 'scratch.txt', 'b.js']);
|
|
352
351
|
});
|
|
353
352
|
|
|
354
|
-
|
|
355
|
-
|
|
353
|
+
// task 1004254: the close-out REPORTS leftovers and leaves them in place. The
|
|
354
|
+
// stash stack is repo-global across every worktree, so stashing there put one
|
|
355
|
+
// builder's scratch (and an owner's review renders) where another agent's pop
|
|
356
|
+
// could apply it to the wrong branch. Run the real close-out in a throwaway repo.
|
|
357
|
+
test('postShipCloseout: lists leftovers, leaves them in place, never touches the stash', () => {
|
|
358
|
+
const { postShipCloseout } = require('../scripts/gds/ship-finish.js');
|
|
359
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'closeout-'));
|
|
360
|
+
const prevCwd = process.cwd();
|
|
361
|
+
const origLog = console.log;
|
|
362
|
+
const lines = [];
|
|
363
|
+
try {
|
|
364
|
+
execSync('git init -q', { cwd: tmp });
|
|
365
|
+
execSync('git config user.email t@t.t && git config user.name t', { cwd: tmp, shell: true });
|
|
366
|
+
fs.writeFileSync(path.join(tmp, 'a.txt'), 'one\n');
|
|
367
|
+
execSync('git add a.txt && git commit -q -m init', { cwd: tmp, shell: true });
|
|
368
|
+
fs.writeFileSync(path.join(tmp, 'a.txt'), 'edited\n');
|
|
369
|
+
fs.writeFileSync(path.join(tmp, 'render-after.png'), 'png');
|
|
370
|
+
process.chdir(tmp);
|
|
371
|
+
console.log = (...a) => lines.push(a.join(' '));
|
|
372
|
+
postShipCloseout(1004254);
|
|
373
|
+
} finally {
|
|
374
|
+
console.log = origLog;
|
|
375
|
+
process.chdir(prevCwd);
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const out = lines.join('\n');
|
|
379
|
+
assert.match(out, /2 uncommitted\/untracked path\(s\)/);
|
|
380
|
+
assert.match(out, /^ a\.txt$/m); // the whole path — gitOk's trim() once listed it as '.txt'
|
|
381
|
+
assert.match(out, /render-after\.png/);
|
|
382
|
+
assert.match(out, /left in place/);
|
|
383
|
+
assert.doesNotMatch(out, /stash/i);
|
|
384
|
+
assert.equal(execSync('git stash list', { cwd: tmp, encoding: 'utf8' }).trim(), '');
|
|
385
|
+
assert.equal(fs.readFileSync(path.join(tmp, 'render-after.png'), 'utf8'), 'png');
|
|
386
|
+
assert.equal(fs.readFileSync(path.join(tmp, 'a.txt'), 'utf8'), 'edited\n');
|
|
387
|
+
} finally {
|
|
388
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('postShipCloseout: a clean tree reports archive-ready', () => {
|
|
393
|
+
const { postShipCloseout } = require('../scripts/gds/ship-finish.js');
|
|
394
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'closeout-'));
|
|
395
|
+
const prevCwd = process.cwd();
|
|
396
|
+
const origLog = console.log;
|
|
397
|
+
const lines = [];
|
|
398
|
+
try {
|
|
399
|
+
execSync('git init -q', { cwd: tmp });
|
|
400
|
+
process.chdir(tmp);
|
|
401
|
+
console.log = (...a) => lines.push(a.join(' '));
|
|
402
|
+
postShipCloseout(7);
|
|
403
|
+
} finally {
|
|
404
|
+
console.log = origLog;
|
|
405
|
+
process.chdir(prevCwd);
|
|
406
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
407
|
+
}
|
|
408
|
+
assert.match(lines.join('\n'), /working tree clean/);
|
|
356
409
|
});
|
|
357
410
|
|
|
358
411
|
// ---- the residual phantom ship: untracked files left behind (task 1001436) ----
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// tests/smoke_dependencies_witness.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004191 (finding 62 of the task 1000379 audit): scripts/gds/smoke-dependencies.sh
|
|
4
|
+
// is the only round-trip for the auto-promotion trigger, and its status check used
|
|
5
|
+
// to assert on task A — which the script itself promotes BY HAND in an earlier
|
|
6
|
+
// step. "A is ready after B ships" therefore passed with the trigger broken.
|
|
7
|
+
//
|
|
8
|
+
// The fix adds C: a dependent of B that nothing ever promotes by hand, checked to
|
|
9
|
+
// be 'backlog' before B ships and 'ready' after. This pins that shape statically
|
|
10
|
+
// (the smoke itself writes live tasks, so CI cannot run it).
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/smoke_dependencies_witness.mjs
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { makeRunner } from './helpers.mjs';
|
|
17
|
+
|
|
18
|
+
const { test, summary } = makeRunner();
|
|
19
|
+
const src = readFileSync(new URL('../scripts/gds/smoke-dependencies.sh', import.meta.url), 'utf8');
|
|
20
|
+
const code = src.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
|
|
21
|
+
|
|
22
|
+
await test('a second dependent C is created at backlog and wired to B', () => {
|
|
23
|
+
assert.match(code, /__smoke__ dep-test C [^"]*\\",\\"description\\":\\"smoke\\",\\"status\\":\\"backlog\\"/);
|
|
24
|
+
assert.match(code, /tasks\/\$C_ID\/dependencies"[\s\S]*depends_on_task_id\\": \$B_ID/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
await test('C is never promoted by hand', () => {
|
|
28
|
+
assert.doesNotMatch(code, /tasks\/\$C_ID\/promote/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await test('C is asserted backlog BEFORE B ships and ready AFTER', () => {
|
|
32
|
+
const pre = code.indexOf('[ "$C_PRE" = "backlog" ]');
|
|
33
|
+
const ship = code.indexOf('tasks/$B_ID/ship');
|
|
34
|
+
const post = code.indexOf('[ "$C_STATUS" = "ready" ]');
|
|
35
|
+
assert.ok(pre >= 0 && ship > pre && post > ship, 'pre-check, ship, post-check in that order');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await test('the post-ship status assertion is not made on the hand-promoted A', () => {
|
|
39
|
+
assert.doesNotMatch(code, /\[ "\$A_STATUS" = "ready" \]/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await test('C is cleaned up (abandoned) like A', () => {
|
|
43
|
+
assert.match(code, /tasks\/\$C_ID\/abandon/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
summary();
|