@bongos/core 1.20.38 → 1.20.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +41 -31
- package/docs/copy-inventory.md +1 -1
- package/docs/copy-registry.json +9 -9
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +251 -248
- package/modules/npm-release/preview/childlog.js +95 -0
- package/modules/npm-release/preview/commands.js +46 -5
- package/modules/npm-release/preview/env.js +18 -7
- package/modules/npm-release/preview/runtime.js +43 -9
- package/modules/npm-release/preview/supervisor.js +170 -31
- package/modules/npm-release/public/work.css +3 -0
- package/modules/npm-release/public/work.js +6 -1
- package/modules/npm-release/routes/preview.js +1 -0
- package/modules/provisioning/render-standup.js +13 -1
- package/modules/public-landing/public/projects.html +22 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/provision-render.js +13 -1
- package/scripts/gds/render-payload.js +6 -5
- package/src/module-api.js +1 -1
- package/tests/npm_release_preview_commands.mjs +1 -1
- package/tests/npm_release_preview_diagnosis.mjs +376 -0
- package/tests/npm_release_preview_supervisor.mjs +4 -4
- package/tests/projects_hub_app_step.mjs +49 -0
- package/tests/projects_hub_render_connect.mjs +2 -1
- package/tests/provision_render.mjs +23 -2
|
@@ -31,6 +31,18 @@ const MODES = ['adopt', 'greenfield'];
|
|
|
31
31
|
// How the owner's app is built (task 1004350): a native runtime with their commands, or
|
|
32
32
|
// `docker`, which builds the repository's own Dockerfile.
|
|
33
33
|
const RUNTIMES = ['node', 'python', 'ruby', 'go', 'rust', 'elixir', 'docker'];
|
|
34
|
+
// Render refuses a native web service with no build command (measured live 2026-09-30,
|
|
35
|
+
// task 1004464), so every native runtime has one: Render's own default for it. The runner
|
|
36
|
+
// sends it when the owner gave none, and the wizard prefills it (projects.html keeps a
|
|
37
|
+
// copy, pinned equal by tests/projects_hub_app_step.mjs).
|
|
38
|
+
const BUILD_DEFAULTS = Object.freeze({
|
|
39
|
+
node: 'npm install',
|
|
40
|
+
python: 'pip install -r requirements.txt',
|
|
41
|
+
ruby: 'bundle install',
|
|
42
|
+
go: 'go build -tags netgo -ldflags \'-s -w\' -o app',
|
|
43
|
+
rust: 'cargo build --release',
|
|
44
|
+
elixir: 'mix deps.get --only prod && MIX_ENV=prod mix compile',
|
|
45
|
+
});
|
|
34
46
|
|
|
35
47
|
// Render API keys start `rnd_`; workspaces are `tea-…` (a team) or `usr-…` (a personal
|
|
36
48
|
// one); services `srv-…`. Shape checks only — whether the key works, and whether it can
|
|
@@ -134,4 +146,4 @@ async function attachToProvision(db, instanceId, { keyEnc, appHost }) {
|
|
|
134
146
|
return rows[0] || null;
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
module.exports = { attachToProvision, KEY_RE, OWNER_RE, WEB_PLANS, DB_PLANS, REGIONS, MODES, RUNTIMES, badRequest, appHostFrom, enqueueRenderStandup };
|
|
149
|
+
module.exports = { attachToProvision, KEY_RE, OWNER_RE, WEB_PLANS, DB_PLANS, REGIONS, MODES, RUNTIMES, BUILD_DEFAULTS, badRequest, appHostFrom, enqueueRenderStandup };
|
|
@@ -1433,7 +1433,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1433
1433
|
<option value="docker">Docker — use my repository’s Dockerfile</option>
|
|
1434
1434
|
</select>
|
|
1435
1435
|
</label>
|
|
1436
|
-
<label class="wizField" id="renderBuildWrap"><span>Build command <small>—
|
|
1436
|
+
<label class="wizField" id="renderBuildWrap"><span>Build command <small>— Render needs one; the usual one is filled in</small></span>
|
|
1437
1437
|
<input type="text" id="f-renderBuild" autocomplete="off" spellcheck="false" maxlength="500" placeholder="for example: npm ci">
|
|
1438
1438
|
</label>
|
|
1439
1439
|
<label class="wizField" id="renderStartWrap"><span>Start command</span>
|
|
@@ -1808,6 +1808,18 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1808
1808
|
function planPrice(p) { return p[2] ? 'about $' + p[2] + '/month' : RENDER_FREE_NOTE; }
|
|
1809
1809
|
var RENDER_DB_PLANS = [['basic_256mb', 'Basic 256 MB', 6], ['basic_1gb', 'Basic 1 GB', 19], ['basic_4gb', 'Basic 4 GB', 75]];
|
|
1810
1810
|
var RENDER_RUNTIMES = { node: 'Node.js', python: 'Python', ruby: 'Ruby', go: 'Go', rust: 'Rust', elixir: 'Elixir', docker: 'Docker' };
|
|
1811
|
+
/* Render refuses a native app with no build command (task 1004464), so the box starts
|
|
1812
|
+
with Render's own default for the runtime — a real value the owner can change, and
|
|
1813
|
+
one that follows the runtime until they do. A copy of render-standup.js's
|
|
1814
|
+
BUILD_DEFAULTS (this page cannot require it), pinned equal by a test. */
|
|
1815
|
+
var RENDER_BUILD_DEFAULTS = {
|
|
1816
|
+
node: 'npm install',
|
|
1817
|
+
python: 'pip install -r requirements.txt',
|
|
1818
|
+
ruby: 'bundle install',
|
|
1819
|
+
go: 'go build -tags netgo -ldflags \'-s -w\' -o app',
|
|
1820
|
+
rust: 'cargo build --release',
|
|
1821
|
+
elixir: 'mix deps.get --only prod && MIX_ENV=prod mix compile'
|
|
1822
|
+
};
|
|
1811
1823
|
var RENDER_KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
|
|
1812
1824
|
function planLabel(list, v) {
|
|
1813
1825
|
var p = list.filter(function (x) { return x[0] === v; })[0] || list[0];
|
|
@@ -1894,6 +1906,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1894
1906
|
q('f-renderRuntime').value = st.renderRuntime || 'node';
|
|
1895
1907
|
var docker = q('f-renderRuntime').value === 'docker';
|
|
1896
1908
|
q('renderBuildWrap').hidden = docker; q('renderStartWrap').hidden = docker;
|
|
1909
|
+
// the runtime's usual build command until the owner types their own (task 1004464)
|
|
1910
|
+
if (!st.renderBuildTouched) st.renderBuild = RENDER_BUILD_DEFAULTS[q('f-renderRuntime').value] || '';
|
|
1897
1911
|
q('f-renderBuild').value = st.renderBuild || '';
|
|
1898
1912
|
q('f-renderStart').value = st.renderStart || '';
|
|
1899
1913
|
fillPlans(q('f-renderPlan'), RENDER_WEB_PLANS, st.renderPlan || 'starter');
|
|
@@ -1975,13 +1989,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1975
1989
|
hooks.save(); syncNew();
|
|
1976
1990
|
});
|
|
1977
1991
|
q('renderNew').addEventListener('input', function (ev) {
|
|
1978
|
-
|
|
1979
|
-
if (ev.target.id === q('f-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
}
|
|
1992
|
+
var typed = null;
|
|
1993
|
+
if (ev.target.id === q('f-renderBuild').id) { st.renderBuild = ev.target.value; st.renderBuildTouched = true; typed = st.renderBuild; }
|
|
1994
|
+
if (ev.target.id === q('f-renderStart').id) { st.renderStart = ev.target.value; typed = st.renderStart; }
|
|
1995
|
+
// a "say how your app starts / is built" ask is answered the moment that box has a
|
|
1996
|
+
// command — it used to stay on screen until the next press of Next
|
|
1997
|
+
if (typed != null && String(typed).trim() && /\berr\b/.test(q('h-render').className)) hint('h-render', '');
|
|
1985
1998
|
hooks.save();
|
|
1986
1999
|
});
|
|
1987
2000
|
function validate() {
|
|
@@ -1993,6 +2006,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1993
2006
|
if (st.renderMode === 'adopt') {
|
|
1994
2007
|
if (!services.some(function (s) { return s.id === st.renderServiceId && repoOf(s.repo) === repo().toLowerCase(); })) return bad('Choose the app to link, or create a new one.');
|
|
1995
2008
|
} else if (st.renderMode === 'greenfield') {
|
|
2009
|
+
if ((st.renderRuntime || 'node') !== 'docker' && !String(st.renderBuild || '').trim()) return bad('Say how your app is built — Render needs a build command, for example “npm install”.');
|
|
1996
2010
|
if ((st.renderRuntime || 'node') !== 'docker' && !String(st.renderStart || '').trim()) return bad('Say how your app starts — the command Render should run, for example “npm start”.');
|
|
1997
2011
|
} else return bad('Choose whether to create a new app or link one you already run.');
|
|
1998
2012
|
hint('h-render', '');
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.39",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.39",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.39",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8333,5 +8333,15 @@
|
|
|
8333
8333
|
{
|
|
8334
8334
|
"text": "Builders on any Cloud Bongos project can now send feedback about Bongos itself with one command, and it arrives with the Bongos team as a task waiting for approval."
|
|
8335
8335
|
}
|
|
8336
|
+
],
|
|
8337
|
+
"1.20.39": [
|
|
8338
|
+
{
|
|
8339
|
+
"id": "1004465",
|
|
8340
|
+
"text": "A version preview that cannot read its data now fails with the child's own output shown, and stopping it really removes its copy."
|
|
8341
|
+
},
|
|
8342
|
+
{
|
|
8343
|
+
"id": "1004464",
|
|
8344
|
+
"text": "Setting up an app on Render from the project wizard no longer fails when the owner leaves the build command empty. The box now comes filled in with Render's usual build command for the chosen language, which the owner can chan"
|
|
8345
|
+
}
|
|
8336
8346
|
]
|
|
8337
8347
|
}
|
|
@@ -117,6 +117,18 @@ async function greenfield(api, inst, host, deps) {
|
|
|
117
117
|
return { ok: true };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// The owner reads this reason on the setup screen (app-status.js oneSentence). A refusal
|
|
121
|
+
// from Render's API used to arrive as its raw line — "Render POST /services failed: …" —
|
|
122
|
+
// so an answer from Render is re-said as a sentence with Render's own reason kept whole
|
|
123
|
+
// (task 1004464). Anything else is already the leg's own words.
|
|
124
|
+
function plainReason(e) {
|
|
125
|
+
const msg = e && e.message ? e.message : String(e);
|
|
126
|
+
const said = e && e.status != null && e.body && typeof e.body.message === 'string' ? e.body.message.trim() : '';
|
|
127
|
+
if (said) return `Render would not set up the app: ${said}`;
|
|
128
|
+
const m = /^Render (?:GET|POST|PATCH|PUT|DELETE) \S+ failed: ([\s\S]+)$/.exec(msg);
|
|
129
|
+
return m ? `Render would not set up the app: ${m[1].trim()}` : msg;
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
async function renderStandupInstance(inst, deps, intent) {
|
|
121
133
|
const { db, log } = deps;
|
|
122
134
|
const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
|
|
@@ -137,7 +149,7 @@ async function renderStandupInstance(inst, deps, intent) {
|
|
|
137
149
|
|
|
138
150
|
return host.mode === 'adopt' ? await adopt(api, inst, host, deps) : await greenfield(api, inst, host, deps);
|
|
139
151
|
} catch (e) {
|
|
140
|
-
return refuse(
|
|
152
|
+
return refuse(plainReason(e));
|
|
141
153
|
} finally {
|
|
142
154
|
if (intent && intent.id != null) await clearBorrowedKey(db, intent.id);
|
|
143
155
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
'use strict';
|
|
22
22
|
|
|
23
23
|
// The vocabulary lives with the web tier's validation, so the two can never disagree.
|
|
24
|
-
const { WEB_PLANS, DB_PLANS, REGIONS, RUNTIMES } = require('../../modules/provisioning/render-standup.js');
|
|
24
|
+
const { WEB_PLANS, DB_PLANS, REGIONS, RUNTIMES, BUILD_DEFAULTS } = require('../../modules/provisioning/render-standup.js');
|
|
25
25
|
const DEFAULTS = Object.freeze({ webPlan: 'starter', dbPlan: 'basic_256mb', region: 'virginia' });
|
|
26
26
|
|
|
27
27
|
// A Render resource name from the project's slug. Render allows letters, digits and
|
|
@@ -47,14 +47,15 @@ function plansFor(appHost) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// How the app is built and started, as the owner said. `docker` builds the repository's
|
|
50
|
-
// own Dockerfile and needs no commands; every other runtime needs a start command
|
|
51
|
-
// build command
|
|
50
|
+
// own Dockerfile and needs no commands; every other runtime needs a start command AND a
|
|
51
|
+
// build command — Render refuses a native service without one (task 1004464), so a row
|
|
52
|
+
// saved with none (including every row from before that fix) gets the runtime's default.
|
|
52
53
|
function buildFor(appHost) {
|
|
53
54
|
const h = appHost || {};
|
|
54
55
|
if (!RUNTIMES.includes(h.runtime)) throw new Error(`unknown Render runtime '${h.runtime || 'none'}'`);
|
|
55
56
|
if (h.runtime === 'docker') return { runtime: 'docker' };
|
|
56
57
|
if (!h.startCommand) throw new Error('the app needs a start command');
|
|
57
|
-
return { runtime: h.runtime, buildCommand: h.buildCommand ||
|
|
58
|
+
return { runtime: h.runtime, buildCommand: h.buildCommand || BUILD_DEFAULTS[h.runtime], startCommand: h.startCommand };
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
// POST /postgres. `plan` is never 'free' — see the header.
|
|
@@ -68,7 +69,7 @@ function renderPostgresPayload(inst, { ownerId, dbPlan, region }) {
|
|
|
68
69
|
function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region, build, dbUrl }) {
|
|
69
70
|
const envSpecificDetails = build.runtime === 'docker'
|
|
70
71
|
? {}
|
|
71
|
-
: {
|
|
72
|
+
: { buildCommand: build.buildCommand, startCommand: build.startCommand };
|
|
72
73
|
return {
|
|
73
74
|
type: 'web_service',
|
|
74
75
|
name: resourceName(inst),
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.39'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -52,7 +52,7 @@ test('the copy is a custom-format dump of the live database, restored with no ow
|
|
|
52
52
|
|
|
53
53
|
test('the copy is created empty and dropped if it is already there', () => {
|
|
54
54
|
assert.deepEqual(c.createDbCommand(), { cmd: 'createdb', args: [PREVIEW_DB] });
|
|
55
|
-
assert.deepEqual(c.dropDbCommand(), { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] });
|
|
55
|
+
assert.deepEqual(c.dropDbCommand(), { cmd: 'dropdb', args: ['--if-exists', '--force', PREVIEW_DB] });
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
test('the install is one version of the core into its own directory, production dependencies only', () => {
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// tests/npm_release_preview_diagnosis.mjs — a preview that cannot serve data must say so (task 1004465).
|
|
2
|
+
//
|
|
3
|
+
// WHAT WENT WRONG LIVE. The first real preview started, answered /healthz, was reported
|
|
4
|
+
// running, and then returned 500 on every page; the child's output went nowhere (stdio
|
|
5
|
+
// 'ignore'), so nobody could say why. Stopping it left the copy database behind, because the
|
|
6
|
+
// child was killed without waiting and the failed dropdb was ignored. These tests pin the four
|
|
7
|
+
// repairs: the child's output is kept and redacted, the health check proves data can be read,
|
|
8
|
+
// a stop waits for the child before dropping, and a drop that fails is reported, never idle.
|
|
9
|
+
// Every spawn, command, probe, clock and file is injected.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --test tests/npm_release_preview_diagnosis.mjs
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { EventEmitter } from 'node:events';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const { createSupervisor, copyInstance } = require('../modules/npm-release/preview/supervisor.js');
|
|
22
|
+
const cmds = require('../modules/npm-release/preview/commands.js');
|
|
23
|
+
const { createChildLog, redact, secretValues } = require('../modules/npm-release/preview/childlog.js');
|
|
24
|
+
const { probeHealth } = require('../modules/npm-release/preview/runtime.js');
|
|
25
|
+
const { PREVIEW_DB, buildPreviewEnv } = require('../modules/npm-release/preview/env.js');
|
|
26
|
+
|
|
27
|
+
const fwd = (x) => String(x).split(String.fromCharCode(92)).join('/');
|
|
28
|
+
// Secret-shaped strings assembled at run time, so no scanner mistakes this file for a leak.
|
|
29
|
+
const FAKE_GH = ['gh', 'p_', 'abcdefghijklmnopqrstuvwxyz0123'].join('');
|
|
30
|
+
const FAKE_URL = ['post', 'gres://svc:pw9999@db/x'].join('');
|
|
31
|
+
|
|
32
|
+
// `sleepMode`: 'instant' returns at once (the health loop's waits); 'never' returns only for
|
|
33
|
+
// waits under a second, so a stop can be released ONLY by the child's real exit.
|
|
34
|
+
function rig({ probe = async () => true, failRun = () => false, killBehaviour = 'exit', sleepMode = 'instant', instanceRoot = null, env, liveDb = 'live_db', copy } = {}) {
|
|
35
|
+
const events = [];
|
|
36
|
+
const calls = [];
|
|
37
|
+
const fake = { spawned: [], rm: [] };
|
|
38
|
+
const sup = createSupervisor({
|
|
39
|
+
run: async (cmd) => {
|
|
40
|
+
calls.push(cmd);
|
|
41
|
+
events.push(cmd.cmd);
|
|
42
|
+
return failRun(cmd) ? { code: 3, stderr: 'boom from ' + cmd.cmd } : { code: 0, stderr: '' };
|
|
43
|
+
},
|
|
44
|
+
spawn: (bin, args, opts) => {
|
|
45
|
+
const child = new EventEmitter();
|
|
46
|
+
child.stdout = new EventEmitter();
|
|
47
|
+
child.stderr = new EventEmitter();
|
|
48
|
+
child.bin = bin; child.args = args; child.opts = opts;
|
|
49
|
+
child.kill = (sig) => {
|
|
50
|
+
events.push('kill:' + sig);
|
|
51
|
+
if (killBehaviour === 'exit') setImmediate(() => { events.push('exit'); child.emit('exit', 0); });
|
|
52
|
+
if (killBehaviour === 'on-sigkill' && sig === 'SIGKILL') setImmediate(() => { events.push('exit'); child.emit('exit', null); });
|
|
53
|
+
};
|
|
54
|
+
fake.spawned.push(child);
|
|
55
|
+
return child;
|
|
56
|
+
},
|
|
57
|
+
probe,
|
|
58
|
+
sleep: (ms) => (sleepMode === 'instant' || ms < 1000 ? Promise.resolve() : sleepMode === 'short' ? new Promise((r) => setTimeout(r, 10)) : new Promise(() => {})),
|
|
59
|
+
now: () => 1_000_000,
|
|
60
|
+
fsImpl: { mkdtempSync: (p) => p + 'XYZ', mkdirSync() {}, rmSync: (p) => fake.rm.push(p), writeFile() {}, existsSync: () => false, readFileSync: () => { throw new Error('none'); } },
|
|
61
|
+
tmpdir: '/tmp', liveDb, cacheRoot: '/cache', port: 3190, instanceRoot, copy,
|
|
62
|
+
env: env || { PATH: '/bin' },
|
|
63
|
+
});
|
|
64
|
+
return { sup, events, calls, fake };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- the child's output: kept, capped, redacted ---------------------------------------
|
|
68
|
+
|
|
69
|
+
test('what the child printed is kept and shown when the preview cannot serve data', async () => {
|
|
70
|
+
const r = rig({
|
|
71
|
+
env: { PATH: '/bin', PGPASSWORD: 'hunter2pass', GITHUB_TOKEN: FAKE_GH },
|
|
72
|
+
probe: async () => {
|
|
73
|
+
r.fake.spawned[0].stderr.emit('data', 'error: relation "builders" does not exist\n');
|
|
74
|
+
r.fake.spawned[0].stdout.emit('data', `connecting with password=abc123 to ${FAKE_URL} using hunter2pass and ${FAKE_GH}\n`);
|
|
75
|
+
return { ok: false, reason: 'a database-backed read (/api/bongos/me) answered 500' };
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
await r.sup.start('1.19.1000').done;
|
|
79
|
+
const s = r.sup.status();
|
|
80
|
+
assert.equal(s.state, 'failed');
|
|
81
|
+
const tail = s.log_tail.join('\n');
|
|
82
|
+
assert.match(tail, /relation "builders" does not exist/);
|
|
83
|
+
assert.match(s.error, /Its last output:.*relation "builders" does not exist/);
|
|
84
|
+
for (const secret of ['hunter2pass', 'abc123', 'pw9999', FAKE_GH]) {
|
|
85
|
+
assert.ok(!tail.includes(secret), 'the tail leaks a secret');
|
|
86
|
+
assert.ok(!s.error.includes(secret), 'the error leaks a secret');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the tail is at most twenty lines and each line is short', () => {
|
|
91
|
+
const lg = createChildLog({});
|
|
92
|
+
lg.append(Array.from({ length: 50 }, (_, i) => 'line ' + i).join('\n') + '\n' + 'x'.repeat(1000) + '\n');
|
|
93
|
+
const t = lg.tail();
|
|
94
|
+
assert.equal(t.length, 20);
|
|
95
|
+
assert.ok(t.every((l) => l.length <= 301));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the log is capped, keeping the newest output', () => {
|
|
99
|
+
const lg = createChildLog({ cap: 100 });
|
|
100
|
+
lg.append('a'.repeat(500));
|
|
101
|
+
lg.append('THE-END');
|
|
102
|
+
assert.ok(lg.size() <= 100);
|
|
103
|
+
assert.match(lg.tail().join('\n'), /THE-END$/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('redaction takes out listed values and the common secret shapes', () => {
|
|
107
|
+
const secrets = secretValues({ SESSION_SECRET: 'sekrit-value-1', NOT_ONE: 'visible-value', PGPASSWORD: 'p' });
|
|
108
|
+
assert.deepEqual(secrets, ['sekrit-value-1'], 'only secret-named, long-enough values');
|
|
109
|
+
const out = redact(`x sekrit-value-1 y Bearer abc.def-ghi ${FAKE_URL} token=zzz visible-value`, secrets);
|
|
110
|
+
assert.ok(!/sekrit-value-1|abc\.def|pw9999|zzz/.test(out), out);
|
|
111
|
+
assert.match(out, /visible-value/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('the log is also written to a file in the scratch directory, and a failing disk does not matter', () => {
|
|
115
|
+
const writes = [];
|
|
116
|
+
const lg = createChildLog({ fsImpl: { writeFile: (f, b, cb) => { writes.push([f, b]); cb(); } }, file: '/tmp/x/preview.log', flushMs: 1e6 });
|
|
117
|
+
lg.append('hello');
|
|
118
|
+
lg.flush();
|
|
119
|
+
assert.deepEqual(writes, [['/tmp/x/preview.log', 'hello']]);
|
|
120
|
+
lg.close();
|
|
121
|
+
const broken = createChildLog({ fsImpl: { writeFile() { throw new Error('disk'); } }, file: '/f', flushMs: 1e6 });
|
|
122
|
+
broken.append('still fine');
|
|
123
|
+
assert.doesNotThrow(() => broken.flush());
|
|
124
|
+
broken.close();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a burst of output costs one asynchronous write, never one write per chunk, and none after close', async () => {
|
|
128
|
+
const writes = [];
|
|
129
|
+
const lg = createChildLog({ fsImpl: { writeFile: (f, b, cb) => { writes.push(b); cb(); } }, file: '/f', flushMs: 5 });
|
|
130
|
+
for (let i = 0; i < 500; i++) lg.append('line ' + i + '\n');
|
|
131
|
+
assert.equal(writes.length, 0, 'nothing is written inside the data handler');
|
|
132
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
133
|
+
assert.equal(writes.length, 1, 'the burst was written once');
|
|
134
|
+
assert.match(writes[0], /line 499/);
|
|
135
|
+
lg.append('after');
|
|
136
|
+
lg.close();
|
|
137
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
138
|
+
assert.equal(writes.length, 1, 'closing cancels a pending write');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---- ready means it can serve data ----------------------------------------------------
|
|
142
|
+
|
|
143
|
+
test('a probe that sees a database failure fails the preview, says why, and cleans up', async () => {
|
|
144
|
+
const { sup, events, fake, calls } = rig({ probe: async () => ({ ok: false, reason: '/version reports schemaPending null: the database copy could not be read' }) });
|
|
145
|
+
await sup.start('1.19.1000').done;
|
|
146
|
+
const s = sup.status();
|
|
147
|
+
assert.equal(s.state, 'failed');
|
|
148
|
+
assert.match(s.error, /could not serve data/);
|
|
149
|
+
assert.match(s.error, /schemaPending null/);
|
|
150
|
+
assert.ok(events.includes('kill:SIGTERM'), 'the child was stopped');
|
|
151
|
+
assert.deepEqual(calls[calls.length - 1], cmds.dropDbCommand(), 'the copy was dropped');
|
|
152
|
+
assert.equal(fake.rm.length, 1, 'the scratch directory was removed');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a preview is running only once the probe says it can serve data', async () => {
|
|
156
|
+
const answers = [{ ok: false, reason: 'warming up' }, { ok: false, reason: 'warming up' }, { ok: true }];
|
|
157
|
+
const { sup } = rig({ probe: async () => answers.shift() });
|
|
158
|
+
await sup.start('1.19.1000').done;
|
|
159
|
+
assert.equal(sup.status().state, 'running');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a bare true from the probe still counts as healthy (older callers)', async () => {
|
|
163
|
+
const { sup } = rig({ probe: async () => true });
|
|
164
|
+
await sup.start('1.19.1000').done;
|
|
165
|
+
assert.equal(sup.status().state, 'running');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a child that exits while starting fails the start at once, with its output', async () => {
|
|
169
|
+
let probes = 0;
|
|
170
|
+
const r = rig({
|
|
171
|
+
probe: async () => {
|
|
172
|
+
probes += 1;
|
|
173
|
+
r.fake.spawned[0].stderr.emit('data', 'Error: Cannot find module "./nope"\n');
|
|
174
|
+
r.fake.spawned[0].emit('exit', 1);
|
|
175
|
+
return false;
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
await r.sup.start('1.19.1000').done;
|
|
179
|
+
assert.equal(probes, 1, 'it did not keep probing a dead child');
|
|
180
|
+
assert.match(r.sup.status().error, /exited \(1\) before it could serve data/);
|
|
181
|
+
assert.match(r.sup.status().error, /Cannot find module/);
|
|
182
|
+
assert.equal(r.sup.status().state, 'failed');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---- the probe itself ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
function fakeGet(map) {
|
|
188
|
+
return async (port, p) => map[p] || { error: 'refused' };
|
|
189
|
+
}
|
|
190
|
+
const HEALTHZ = { status: 200, body: '{}' };
|
|
191
|
+
const OK = { status: 200, body: '{"coreVersion":"1.19.1000","schemaPending":0}' };
|
|
192
|
+
|
|
193
|
+
test('the probe passes only when healthz, /version and a database-backed read all answer', async () => {
|
|
194
|
+
const r = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': OK, '/api/bongos/me': { status: 401, body: '{}' } }));
|
|
195
|
+
assert.deepEqual(r, { ok: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('schemaPending null (the database read failed) fails the probe even though /healthz is 200', async () => {
|
|
199
|
+
const r = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': { status: 200, body: '{"schemaPending":null,"commit":null}' }, '/api/bongos/me': { status: 401, body: '' } }));
|
|
200
|
+
assert.equal(r.ok, false);
|
|
201
|
+
assert.match(r.reason, /schemaPending null/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a 500 from a database-backed route fails the probe', async () => {
|
|
205
|
+
const r = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': OK, '/api/bongos/me': { status: 500, body: '' } }));
|
|
206
|
+
assert.equal(r.ok, false);
|
|
207
|
+
assert.match(r.reason, /\/api\/bongos\/me\) answered 500/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a version too old to report schemaPending is judged by the database-backed read alone', async () => {
|
|
211
|
+
const old = { status: 200, body: '{"coreVersion":"1.0.1"}' };
|
|
212
|
+
const good = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': old, '/api/bongos/me': { status: 401, body: '' } }));
|
|
213
|
+
assert.equal(good.ok, true);
|
|
214
|
+
const bad = await probeHealth(3190, fakeGet({ '/healthz': HEALTHZ, '/version': old, '/api/bongos/me': { status: 500, body: '' } }));
|
|
215
|
+
assert.equal(bad.ok, false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('nothing answering, or /healthz not 200, fails the probe', async () => {
|
|
219
|
+
assert.equal((await probeHealth(3190, fakeGet({}))).ok, false);
|
|
220
|
+
assert.equal((await probeHealth(3190, fakeGet({ '/healthz': { status: 503, body: '' } }))).ok, false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ---- the child's working directory is an instance --------------------------------------
|
|
224
|
+
|
|
225
|
+
test('the child runs in a scratch instance holding the live config, and is told so', async () => {
|
|
226
|
+
const copied = [];
|
|
227
|
+
const { sup, fake, calls } = rig({ instanceRoot: '/live/root', copy: (o) => copied.push([o.from, fwd(o.to)]) });
|
|
228
|
+
await sup.start('1.19.1000').done;
|
|
229
|
+
assert.deepEqual(copied, [['/live/root', '/tmp/bongos-preview-XYZ/instance']]);
|
|
230
|
+
const child = fake.spawned[0];
|
|
231
|
+
assert.equal(fwd(child.opts.cwd), '/tmp/bongos-preview-XYZ/instance');
|
|
232
|
+
assert.equal(fwd(child.opts.env.BONGOS_INSTANCE_ROOT), '/tmp/bongos-preview-XYZ/instance');
|
|
233
|
+
const migrate = calls.find((c) => c.cmd === 'bash');
|
|
234
|
+
assert.equal(fwd(migrate.cwd), '/tmp/bongos-preview-XYZ/instance');
|
|
235
|
+
assert.equal(fwd(migrate.env.INIT_CWD), '/tmp/bongos-preview-XYZ/instance');
|
|
236
|
+
assert.equal(migrate.env.PGDATABASE, PREVIEW_DB);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('without an instance root the child still runs from its install directory', async () => {
|
|
240
|
+
const { sup, fake } = rig();
|
|
241
|
+
await sup.start('1.19.1000').done;
|
|
242
|
+
assert.equal(fwd(fake.spawned[0].opts.cwd), '/cache/1.19.1000');
|
|
243
|
+
assert.equal(fake.spawned[0].opts.env.BONGOS_INSTANCE_ROOT, undefined);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('copying the instance takes the boot packs and instance migrations, and nothing else from config', () => {
|
|
247
|
+
const from = fs.mkdtempSync(path.join(os.tmpdir(), 'pv-from-'));
|
|
248
|
+
const to = fs.mkdtempSync(path.join(os.tmpdir(), 'pv-to-'));
|
|
249
|
+
try {
|
|
250
|
+
fs.mkdirSync(path.join(from, 'config'));
|
|
251
|
+
fs.mkdirSync(path.join(from, 'migrations', 'instance'), { recursive: true });
|
|
252
|
+
fs.writeFileSync(path.join(from, 'config', 'branding.json'), '{"envPrefix":"ACME"}');
|
|
253
|
+
fs.writeFileSync(path.join(from, 'config', 'modules.json'), '{}');
|
|
254
|
+
// files that are not on the allow-list stay behind whatever they are called
|
|
255
|
+
for (const name of ['go-live.json', 'push-token.json', 'apikey.json', 'db-creds.json', 'notes.txt']) fs.writeFileSync(path.join(from, 'config', name), '{}');
|
|
256
|
+
fs.writeFileSync(path.join(from, 'migrations', 'instance', '0001_x.sql'), 'select 1;');
|
|
257
|
+
copyInstance({ fsImpl: fs, from, to });
|
|
258
|
+
assert.deepEqual(fs.readdirSync(path.join(to, 'config')).sort(), ['branding.json', 'modules.json']);
|
|
259
|
+
assert.deepEqual(fs.readdirSync(path.join(to, 'migrations', 'instance')), ['0001_x.sql']);
|
|
260
|
+
// an instance with neither directory is not an error
|
|
261
|
+
copyInstance({ fsImpl: fs, from: path.join(from, 'nowhere'), to });
|
|
262
|
+
} finally {
|
|
263
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
264
|
+
fs.rmSync(to, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('the module switches cover every env prefix the core resolves (a tripwire against drift)', () => {
|
|
269
|
+
const ic = require('../src/instance-config');
|
|
270
|
+
const { MODULES_OFF } = require('../modules/npm-release/preview/env.js');
|
|
271
|
+
for (const pfx of [ic.FALLBACK_ENV_PREFIX, ...ic.LEGACY_ENV_PREFIXES]) {
|
|
272
|
+
assert.ok(MODULES_OFF.includes(pfx + '_MODULE_DISCORD'), 'no switch under the ' + pfx + ' prefix: a prefix the core reads would leave the module on');
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('the module switches are set under every prefix, including the copied instance own', () => {
|
|
277
|
+
const env = buildPreviewEnv({ port: 3190, scratchHome: '/h', instanceRoot: '/i', envPrefix: 'ACME' });
|
|
278
|
+
for (const pfx of ['BONGOS', 'CLOUDBONGOS', 'OTB', 'GDS', 'PMS', 'ACME']) {
|
|
279
|
+
assert.equal(env[pfx + '_MODULE_DISCORD'], '0', pfx);
|
|
280
|
+
assert.equal(env[pfx + '_MODULE_AGENTS'], '0', pfx);
|
|
281
|
+
}
|
|
282
|
+
assert.equal(buildPreviewEnv({ port: 1, scratchHome: '/h', envPrefix: 'bad prefix; rm' }).ACME_MODULE_DISCORD, undefined);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ---- stopping really drops the copy ------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
test('stop waits for the child to exit before it drops the copy', async () => {
|
|
288
|
+
// sleepMode "never": nothing but the child's own exit can let the stop go on.
|
|
289
|
+
const { sup, events } = rig({ sleepMode: 'never' });
|
|
290
|
+
await sup.start('1.19.1000').done;
|
|
291
|
+
events.length = 0;
|
|
292
|
+
await sup.stop();
|
|
293
|
+
assert.deepEqual(events, ['kill:SIGTERM', 'exit', 'dropdb']);
|
|
294
|
+
assert.equal(sup.status().state, 'idle');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('a child that ignores SIGTERM is killed with SIGKILL before the drop', async () => {
|
|
298
|
+
const { sup, events } = rig({ killBehaviour: 'on-sigkill', sleepMode: 'short' });
|
|
299
|
+
await sup.start('1.19.1000').done;
|
|
300
|
+
events.length = 0;
|
|
301
|
+
await sup.stop();
|
|
302
|
+
assert.deepEqual(events, ['kill:SIGTERM', 'kill:SIGKILL', 'exit', 'dropdb']);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('a failed dropdb is retried after ending the copy backends, then reported instead of idle', async () => {
|
|
306
|
+
let failing = false;
|
|
307
|
+
const { sup, calls } = rig({ failRun: (c) => failing && c.cmd === 'dropdb' });
|
|
308
|
+
await sup.start('1.19.1000').done;
|
|
309
|
+
failing = true;
|
|
310
|
+
calls.length = 0;
|
|
311
|
+
const s = await sup.stop();
|
|
312
|
+
assert.deepEqual(calls.map((c) => c.cmd), ['dropdb', 'psql', 'dropdb']);
|
|
313
|
+
assert.equal(calls[0].args.includes('--force'), true);
|
|
314
|
+
assert.equal(calls[2].args.includes('--force'), false, 'the retry is plain');
|
|
315
|
+
assert.equal(s.state, 'failed');
|
|
316
|
+
assert.equal(s.stage, 'drop');
|
|
317
|
+
assert.match(s.error, /could not be dropped/);
|
|
318
|
+
assert.equal(sup.status().state, 'failed', 'and it stays reported until a drop works');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('a dropdb that fails only for want of --force is rescued by the fallback', async () => {
|
|
322
|
+
let forceFails = false;
|
|
323
|
+
const { sup } = rig({ failRun: (c) => forceFails && c.cmd === 'dropdb' && c.args.includes('--force') });
|
|
324
|
+
await sup.start('1.19.1000').done;
|
|
325
|
+
forceFails = true;
|
|
326
|
+
const s = await sup.stop();
|
|
327
|
+
assert.equal(s.state, 'idle');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('a failed start whose copy will not drop says both things', async () => {
|
|
331
|
+
let dropsFail = false;
|
|
332
|
+
const { sup } = rig({
|
|
333
|
+
probe: async () => { dropsFail = true; return { ok: false, reason: 'the data path is broken' }; },
|
|
334
|
+
failRun: (c) => dropsFail && c.cmd === 'dropdb',
|
|
335
|
+
});
|
|
336
|
+
await sup.start('1.19.1000').done;
|
|
337
|
+
const s = sup.status();
|
|
338
|
+
assert.equal(s.state, 'failed');
|
|
339
|
+
assert.match(s.error, /the data path is broken/);
|
|
340
|
+
assert.match(s.error, /could not be dropped/);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ---- the drop can never name the live database ---------------------------------------------
|
|
344
|
+
|
|
345
|
+
test('a supervisor whose live database is the preview name cannot be built', () => {
|
|
346
|
+
assert.throws(() => rig({ liveDb: PREVIEW_DB }), /cannot be named bongos_preview/);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('every drop command names the fixed copy, and nothing else passes the guard', () => {
|
|
350
|
+
for (const c of [cmds.dropDbCommand(), cmds.dropDbPlainCommand(), cmds.terminateBackendsCommand()]) {
|
|
351
|
+
assert.equal(cmds.targetsOnlyPreviewDb(c), true, JSON.stringify(c));
|
|
352
|
+
assert.ok(JSON.stringify(c).includes(PREVIEW_DB));
|
|
353
|
+
}
|
|
354
|
+
for (const bad of [
|
|
355
|
+
{ cmd: 'dropdb', args: ['--if-exists', 'live_db'] },
|
|
356
|
+
{ cmd: 'dropdb', args: ['live_db', PREVIEW_DB] },
|
|
357
|
+
{ cmd: 'dropdb', args: ['--if-exists', '--force', 'live_db'] },
|
|
358
|
+
{ cmd: 'psql', args: ['-d', 'postgres', '-c', 'DROP DATABASE live_db'] },
|
|
359
|
+
{ cmd: 'psql', args: ['-X', '-d', 'postgres', '-tAc', "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'live_db'"] },
|
|
360
|
+
{ cmd: 'bash', args: ['-c', 'dropdb ' + PREVIEW_DB] },
|
|
361
|
+
null,
|
|
362
|
+
]) assert.equal(cmds.targetsOnlyPreviewDb(bad), false, JSON.stringify(bad));
|
|
363
|
+
assert.match(cmds.terminateBackendsCommand().args.join(' '), /datname = 'bongos_preview' AND pid <> pg_backend_pid\(\)/);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('every drop-capable command a start and stop runs is the guarded kind, none naming the live database', async () => {
|
|
367
|
+
const { sup, calls } = rig({ failRun: (c) => c.cmd === 'dropdb' });
|
|
368
|
+
await sup.start('1.19.1000').done;
|
|
369
|
+
await sup.stop();
|
|
370
|
+
const risky = calls.filter((x) => x.cmd === 'dropdb' || x.cmd === 'psql');
|
|
371
|
+
assert.ok(risky.length > 0);
|
|
372
|
+
for (const c of risky) {
|
|
373
|
+
assert.equal(cmds.targetsOnlyPreviewDb(c), true, JSON.stringify(c));
|
|
374
|
+
assert.ok(!JSON.stringify(c.args).includes('live_db'));
|
|
375
|
+
}
|
|
376
|
+
});
|
|
@@ -28,7 +28,7 @@ function rig({ failOn, probeResults, env } = {}) {
|
|
|
28
28
|
},
|
|
29
29
|
spawn: (bin, args, opts) => {
|
|
30
30
|
const handlers = {};
|
|
31
|
-
const child = { bin, args, opts, on: (ev, fn) => { handlers[ev] = fn; }, kill: () => { fake.killed += 1; }, emit: (ev, ...a) => handlers[ev] && handlers[ev](...a) };
|
|
31
|
+
const child = { bin, args, opts, on: (ev, fn) => { handlers[ev] = fn; }, kill: (sig) => { fake.killed += 1; (fake.signals ||= []).push(sig); if (handlers.exit) handlers.exit(0); }, emit: (ev, ...a) => handlers[ev] && handlers[ev](...a) };
|
|
32
32
|
fake.spawned.push(child);
|
|
33
33
|
return child;
|
|
34
34
|
},
|
|
@@ -46,7 +46,7 @@ function rig({ failOn, probeResults, env } = {}) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const fwd = (x) => String(x).split(String.fromCharCode(92)).join('/');
|
|
49
|
-
const DROP = { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] };
|
|
49
|
+
const DROP = { cmd: 'dropdb', args: ['--if-exists', '--force', PREVIEW_DB] };
|
|
50
50
|
|
|
51
51
|
test('a start runs install, copy, restore and migrate, in that order, then launches and waits for health', async () => {
|
|
52
52
|
const { sup, calls, fake } = rig();
|
|
@@ -89,9 +89,9 @@ for (const failOn of ['npm', 'dropdb', 'createdb', 'dump', 'pg_restore', 'bash']
|
|
|
89
89
|
await sup.start('1.19.1000').done;
|
|
90
90
|
const s = sup.status();
|
|
91
91
|
assert.equal(s.state, 'failed');
|
|
92
|
-
assert.match(s.error, /failed
|
|
92
|
+
assert.match(s.error, failOn === 'dropdb' ? /could not be dropped/ : /failed [(]exit 3[)]/);
|
|
93
93
|
assert.equal(fake.spawned.length, 0, 'no process was started');
|
|
94
|
-
assert.deepEqual(calls[calls.length - 1], DROP, 'the copy is dropped last');
|
|
94
|
+
if (failOn !== 'dropdb') assert.deepEqual(calls[calls.length - 1], DROP, 'the copy is dropped last');
|
|
95
95
|
assert.equal(rmCalls.length, 1, 'the scratch directory is removed');
|
|
96
96
|
});
|
|
97
97
|
}
|