Files
evomap__evolver/test/forceUpdateLastUpdateReport.test.js
evolver-publish 770d59ae51 Release v1.89.1
2026-06-09 03:44:24 +00:00

1757 lines
69 KiB
JavaScript

// Tests for client-side last_update reporting (evomap-hub #1034 / #1039).
//
// After each executeForceUpdate attempt the client persists the outcome to
// ${EVOLVER_HOME}/force_update_last.json, then attaches it as
// body.last_update on the next /a2a/heartbeat POST. The hub-side
// EvolverUpgradeAttempt table is populated from this field; without these
// reports the table stays empty forever.
const { describe, it, before, after, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
if (!process.env.A2A_NODE_SECRET) {
process.env.A2A_NODE_SECRET = 'a'.repeat(64);
}
// Rigged require cache: swap the forceUpdate module for a spy BEFORE
// a2aProtocol is required, so _maybeTriggerForceUpdateFromHeartbeat picks up
// the spy instead of spawning npm/degit against the real network.
// Load the real module FIRST so we can passthrough its pure helpers
// (sentinel symbols, validator-for-testing) on the spy -- otherwise tests
// that need parity with forceUpdate.js can't reach the validator.
const forceUpdatePath = require.resolve('../src/forceUpdate');
const _realForceUpdate = require('../src/forceUpdate');
let executeForceUpdateCalls = [];
let executeForceUpdateReturn = false;
let executeForceUpdateThrow = null;
require.cache[forceUpdatePath] = {
id: forceUpdatePath,
filename: forceUpdatePath,
loaded: true,
exports: {
executeForceUpdate: function (fu) {
executeForceUpdateCalls.push(fu);
if (executeForceUpdateThrow) throw executeForceUpdateThrow;
return executeForceUpdateReturn;
},
// Passthrough the real pure helpers so parity tests + sentinel checks
// still reach the actual implementation.
FORCE_UPDATE_NOOP: _realForceUpdate.FORCE_UPDATE_NOOP,
FORCE_UPDATE_BUSY: _realForceUpdate.FORCE_UPDATE_BUSY,
_isAcceptedRequiredVersionForTesting: _realForceUpdate._isAcceptedRequiredVersionForTesting,
},
};
const a2aProtocol = require('../src/gep/a2aProtocol');
const {
sendHeartbeat,
reportForceUpdateOutcome,
readPendingLastUpdate,
clearLastUpdateOnAck,
} = a2aProtocol;
const {
_resetForceUpdateStateForTesting,
_resetLastUpdateStateForTesting,
_persistLastUpdateStateForTesting,
_getLastUpdateStatePathForTesting,
_extractTargetVersionForTesting,
} = a2aProtocol._testing;
describe('force_update last_update reporting', () => {
var tmpDir;
var evomapHomeDir;
var originalFetch;
var originalHubUrl;
var originalLogsDir;
var originalEvolverHome;
var originalProcessExit;
var originalInsecure;
var exitCalls;
var fetchCalls;
var fetchResponder;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-lu-test-'));
evomapHomeDir = path.join(tmpDir, 'evomap-home');
fs.mkdirSync(evomapHomeDir, { recursive: true });
originalHubUrl = process.env.A2A_HUB_URL;
originalLogsDir = process.env.EVOLVER_LOGS_DIR;
originalEvolverHome = process.env.EVOLVER_HOME;
originalInsecure = process.env.EVOMAP_HUB_ALLOW_INSECURE;
process.env.A2A_HUB_URL = 'http://localhost:19999';
process.env.EVOLVER_LOGS_DIR = tmpDir;
process.env.EVOLVER_HOME = evomapHomeDir;
process.env.EVOMAP_HUB_ALLOW_INSECURE = '1';
originalFetch = global.fetch;
originalProcessExit = process.exit;
});
after(() => {
global.fetch = originalFetch;
process.exit = originalProcessExit;
if (originalHubUrl === undefined) delete process.env.A2A_HUB_URL;
else process.env.A2A_HUB_URL = originalHubUrl;
if (originalLogsDir === undefined) delete process.env.EVOLVER_LOGS_DIR;
else process.env.EVOLVER_LOGS_DIR = originalLogsDir;
if (originalEvolverHome === undefined) delete process.env.EVOLVER_HOME;
else process.env.EVOLVER_HOME = originalEvolverHome;
if (originalInsecure === undefined) delete process.env.EVOMAP_HUB_ALLOW_INSECURE;
else process.env.EVOMAP_HUB_ALLOW_INSECURE = originalInsecure;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
beforeEach(() => {
executeForceUpdateCalls = [];
executeForceUpdateReturn = false;
executeForceUpdateThrow = null;
exitCalls = [];
process.exit = function (code) { exitCalls.push(code); };
fetchCalls = [];
fetchResponder = null;
global.fetch = async function (url, opts) {
var body = {};
try { body = JSON.parse(opts && opts.body || '{}'); } catch (_) { body = {}; }
fetchCalls.push({ url: String(url), body: body, opts: opts });
if (typeof fetchResponder === 'function') return fetchResponder(url, opts);
return {
ok: true,
status: 200,
json: async () => ({ status: 'ok' }),
text: async () => '',
};
};
process.env.EVOLVER_FORCE_UPDATE_RETRY_COOLDOWN_MS = '0';
_resetForceUpdateStateForTesting();
_resetLastUpdateStateForTesting();
});
function _statePath() { return _getLastUpdateStatePathForTesting(); }
// Helper used by (l)+ to cut ~30 lines of duplicated 200-OK envelope
// boilerplate. Existing (a..k) cases are deliberately left untouched.
function okResponder(body) {
var payload = body || { status: 'ok' };
return async function () {
return {
ok: true,
status: 200,
json: async () => payload,
text: async () => '',
};
};
}
it('(a) successful force_update writes state file with status=success and required fields', async () => {
executeForceUpdateReturn = true;
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({
status: 'ok',
force_update: {
to_version: '1.88.0',
directive_id: 'directive-abc-123',
required_version: '>=1.88.0',
reason: 'test',
},
}),
text: async () => '',
});
await sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.equal(exitCalls.length, 1, 'process.exit called on success');
assert.equal(exitCalls[0], 78);
assert.ok(fs.existsSync(_statePath()), 'state file written');
var payload = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(payload.status, 'success');
assert.equal(payload.to_version, '1.88.0');
assert.equal(typeof payload.from_version, 'string');
assert.ok(payload.from_version.length > 0, 'from_version captured before exit');
assert.equal(payload.directive_id, 'directive-abc-123');
assert.equal(typeof payload.finished_at, 'number');
assert.ok(payload.finished_at >= 1700000000000, 'finished_at in ms-since-epoch range');
});
it('(b) failed force_update writes state file with status=failed and error truncated to <=1000', async () => {
executeForceUpdateReturn = false;
// Make executeForceUpdate throw with a very long error message.
executeForceUpdateThrow = new Error('x'.repeat(2500));
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({
status: 'ok',
force_update: { to_version: '1.88.0', required_version: '>=1.88.0', reason: 'test' },
}),
text: async () => '',
});
await sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.equal(exitCalls.length, 0, 'no exit on failure');
assert.ok(fs.existsSync(_statePath()), 'state file written on failure');
var payload = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(payload.status, 'failed');
assert.equal(payload.to_version, '1.88.0');
assert.equal(typeof payload.error, 'string');
assert.ok(payload.error.length <= 1000, 'error truncated to <=1000 chars');
assert.equal(payload.error.length, 1000);
});
it('(c) heartbeat body includes last_update from state file', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
from_version: '1.87.0',
status: 'success',
finished_at: Date.now(),
directive_id: 'd1',
});
await sendHeartbeat();
assert.equal(fetchCalls.length, 1);
assert.ok(fetchCalls[0].body.last_update, 'last_update attached to body');
assert.equal(fetchCalls[0].body.last_update.to_version, '1.88.0');
assert.equal(fetchCalls[0].body.last_update.status, 'success');
assert.equal(fetchCalls[0].body.last_update.directive_id, 'd1');
});
it('(d) heartbeat 200 OK deletes the state file', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
assert.ok(fs.existsSync(_statePath()), 'precondition: file exists');
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({ status: 'ok' }),
text: async () => '',
});
var result = await sendHeartbeat();
assert.ok(result.ok);
assert.ok(!fs.existsSync(_statePath()), 'state file deleted on 2xx');
});
it('(e) heartbeat 500 keeps the state file (state preserved across non-2xx and network errors)', async () => {
// 1) HTTP 500 case
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
assert.ok(fs.existsSync(_statePath()), 'precondition: file exists');
fetchResponder = async () => ({
ok: false, status: 500,
json: async () => ({ error: 'internal' }),
text: async () => 'internal error',
});
await sendHeartbeat();
assert.ok(fs.existsSync(_statePath()), 'state file kept on HTTP 500 (data.ok=false)');
// 2) Fetch-rejects (network) case
fetchResponder = async () => { throw new Error('ECONNREFUSED'); };
var result = await sendHeartbeat();
assert.equal(result.ok, false, 'heartbeat returns ok:false on network error');
assert.ok(fs.existsSync(_statePath()), 'state file kept on network error (.catch path)');
});
it('(f) corrupt state file is silently dropped and file removed; body has no last_update', async () => {
// Write deliberately invalid JSON.
fs.mkdirSync(path.dirname(_statePath()), { recursive: true });
fs.writeFileSync(_statePath(), '{not valid json', 'utf8');
assert.ok(fs.existsSync(_statePath()));
await sendHeartbeat();
assert.equal(fetchCalls.length, 1);
assert.ok(
!Object.prototype.hasOwnProperty.call(fetchCalls[0].body, 'last_update'),
'no last_update key in body when state file is corrupt'
);
assert.ok(!fs.existsSync(_statePath()), 'corrupt file removed');
// Also a valid-JSON-but-missing-to_version variant.
fs.writeFileSync(_statePath(), JSON.stringify({ status: 'success' }), 'utf8');
await sendHeartbeat();
assert.equal(fetchCalls.length, 2);
assert.ok(!Object.prototype.hasOwnProperty.call(fetchCalls[1].body, 'last_update'));
assert.ok(!fs.existsSync(_statePath()));
// And a valid-JSON-but-bad-status variant.
fs.writeFileSync(_statePath(), JSON.stringify({ to_version: '1.88.0', status: 'bogus' }), 'utf8');
await sendHeartbeat();
assert.equal(fetchCalls.length, 3);
assert.ok(!Object.prototype.hasOwnProperty.call(fetchCalls[2].body, 'last_update'));
assert.ok(!fs.existsSync(_statePath()));
});
it('(g) no state file → body has no last_update key (not null)', async () => {
assert.ok(!fs.existsSync(_statePath()), 'precondition: no state file');
await sendHeartbeat();
assert.equal(fetchCalls.length, 1);
assert.ok(
!Object.prototype.hasOwnProperty.call(fetchCalls[0].body, 'last_update'),
'last_update key omitted when state file absent'
);
});
// ---------------------------------------------------------------------
// PR #188 review follow-ups: HIGH lost-write race, HIGH read-side
// revalidation, MEDIUM required_version parsing.
// ---------------------------------------------------------------------
it('(h) lost-write race: 2xx clear keeps a file rewritten mid-flight', async () => {
// Step 1: persist the FIRST outcome and start a heartbeat that captures
// it as _pendingLastUpdate at request-build time.
var firstFinishedAt = 1_800_000_000_000;
var first = {
to_version: '1.88.0',
status: 'failed',
finished_at: firstFinishedAt,
error: 'first attempt timed out',
};
_persistLastUpdateStateForTesting(first);
assert.ok(fs.existsSync(_statePath()), 'precondition: file exists');
// Step 2: while the hub call is "in flight" (before our fetchResponder
// resolves), simulate a concurrent _maybeTriggerForceUpdateFromHeartbeat
// retry past cooldown writing a fresher outcome to the same path.
var second = {
to_version: '1.88.0',
status: 'failed',
finished_at: firstFinishedAt + 60_000,
error: 'second attempt also failed (different cause)',
};
fetchResponder = async () => {
_persistLastUpdateStateForTesting(second);
return {
ok: true, status: 200,
json: async () => ({ status: 'ok' }),
text: async () => '',
};
};
var result = await sendHeartbeat();
assert.ok(result.ok);
// File MUST still exist and hold the fresher payload.
assert.ok(fs.existsSync(_statePath()), 'state file kept (identity mismatch)');
var onDisk = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(onDisk.finished_at, second.finished_at,
'fresher payload preserved (clear refused identity mismatch)');
assert.equal(onDisk.error, second.error,
'fresher error preserved across clear');
// And the body we sent must have carried the FIRST payload, not the
// second (proves the request was built before the rewrite).
var lu = fetchCalls[fetchCalls.length - 1].body.last_update;
assert.equal(lu.finished_at, firstFinishedAt,
'body.last_update carried the captured first payload');
});
it('(i) corrupt optional fields are stripped at read time', async () => {
// Hand-edit a state file with all four "should-be-fixed-up" defects.
fs.mkdirSync(path.dirname(_statePath()), { recursive: true });
fs.writeFileSync(_statePath(), JSON.stringify({
to_version: '1.88.0',
status: 'failed',
finished_at: 1_700_000_000, // seconds, not ms -- must be stripped
error: 'x'.repeat(2000), // too long -- must be truncated
directive_id: '', // empty -- hub rejects min:1; must be stripped
from_version: 'a'.repeat(50), // too long -- must be truncated to 32
}), 'utf8');
await sendHeartbeat();
assert.equal(fetchCalls.length, 1);
var lu = fetchCalls[0].body.last_update;
assert.ok(lu, 'last_update present (required fields valid)');
assert.equal(lu.to_version, '1.88.0');
assert.equal(lu.status, 'failed');
// Required survives, optionals are sanitised:
assert.ok(!Object.prototype.hasOwnProperty.call(lu, 'finished_at'),
'finished_at stripped (seconds instead of ms)');
assert.ok(!Object.prototype.hasOwnProperty.call(lu, 'directive_id'),
'directive_id stripped (empty string)');
assert.equal(lu.from_version.length, 32, 'from_version truncated to 32');
assert.equal(lu.error.length, 1000, 'error truncated to 1000');
});
it('(j) required_version is parsed: strips >=, >, ~, ^ etc. with whitespace', async () => {
// Case 1: ">=1.88.0" -> "1.88.0"
executeForceUpdateReturn = false;
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({
status: 'ok',
force_update: { required_version: '>=1.88.0', reason: 'test' },
}),
text: async () => '',
});
await sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.ok(fs.existsSync(_statePath()));
var p1 = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(p1.to_version, '1.88.0',
'required_version ">=1.88.0" parsed to "1.88.0"');
// Reset for case 2.
_resetForceUpdateStateForTesting();
_resetLastUpdateStateForTesting();
// Case 2: ">= 2.0.0-rc.5" (with whitespace) -> "2.0.0-rc.5"
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({
status: 'ok',
force_update: { required_version: '>= 2.0.0-rc.5', reason: 'test' },
}),
text: async () => '',
});
await sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.ok(fs.existsSync(_statePath()));
var p2 = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(p2.to_version, '2.0.0-rc.5',
'required_version ">= 2.0.0-rc.5" parsed to "2.0.0-rc.5"');
});
it('(k) missing required_version → no state file written, warn logged', async () => {
var warnings = [];
var originalWarn = console.warn;
console.warn = function () {
try { warnings.push(Array.from(arguments).join(' ')); } catch (_) {}
};
try {
executeForceUpdateReturn = false;
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({
status: 'ok',
// No required_version, no to_version, no version.
force_update: { reason: 'test' },
}),
text: async () => '',
});
await sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.ok(!fs.existsSync(_statePath()),
'state file NOT written when no parsable target version');
var matched = warnings.some(function (w) {
return w.indexOf('no parsable target version') !== -1;
});
assert.ok(matched,
'expected warn with "no parsable target version", got: ' + JSON.stringify(warnings));
} finally {
console.warn = originalWarn;
}
});
// ---------------------------------------------------------------------
// PR #188 Phase 1 review follow-ups: TTL, stricter _extractTargetVersion,
// 400 circuit breaker, public reportForceUpdateOutcome /
// readPendingLastUpdate / clearLastUpdateOnAck API.
// ---------------------------------------------------------------------
it('(l) TTL: stale state file (finished_at > 7d old) is dropped at read time', async () => {
var eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000;
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: eightDaysAgo,
});
assert.ok(fs.existsSync(_statePath()), 'precondition: file exists');
await sendHeartbeat();
assert.equal(fetchCalls.length, 1);
assert.ok(
!Object.prototype.hasOwnProperty.call(fetchCalls[0].body, 'last_update'),
'no last_update key when state is older than 7d TTL'
);
assert.ok(!fs.existsSync(_statePath()), 'stale state file removed by TTL gate');
});
it('(m) TTL: non-stale state file (<7d) is kept and sent', async () => {
var oneDayAgo = Date.now() - 1 * 24 * 60 * 60 * 1000;
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: oneDayAgo,
});
fetchResponder = okResponder({ status: 'ok' });
await sendHeartbeat();
assert.equal(fetchCalls.length, 1);
assert.ok(fetchCalls[0].body.last_update, 'last_update present for <7d state');
assert.equal(fetchCalls[0].body.last_update.to_version, '1.88.0');
assert.equal(fetchCalls[0].body.last_update.finished_at, oneDayAgo);
});
it('(n) strict _extractTargetVersion: AND range ">=1.88.0 <2.0.0" rejected, no state file', async () => {
var warnings = [];
var originalWarn = console.warn;
console.warn = function () {
try { warnings.push(Array.from(arguments).join(' ')); } catch (_) {}
};
try {
executeForceUpdateReturn = false;
fetchResponder = okResponder({
status: 'ok',
force_update: { required_version: '>=1.88.0 <2.0.0', reason: 'test' },
});
await sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.ok(!fs.existsSync(_statePath()),
'state file NOT written for AND-range required_version');
var matched = warnings.some(function (w) {
return w.indexOf('no parsable target version') !== -1;
});
assert.ok(matched,
'expected warn with "no parsable target version", got: ' + JSON.stringify(warnings));
} finally {
console.warn = originalWarn;
}
});
it('(o) strict _extractTargetVersion: wildcard "*" rejected, no state file', async () => {
executeForceUpdateReturn = false;
fetchResponder = okResponder({
status: 'ok',
force_update: { required_version: '*', reason: 'test' },
});
await sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.ok(!fs.existsSync(_statePath()),
'state file NOT written for wildcard required_version');
});
it('(p) strict _extractTargetVersion: dist-tag "latest" rejected, no state file', async () => {
executeForceUpdateReturn = false;
fetchResponder = okResponder({
status: 'ok',
force_update: { required_version: 'latest', reason: 'test' },
});
await sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.ok(!fs.existsSync(_statePath()),
'state file NOT written for dist-tag required_version');
});
it('(q) 400 circuit breaker: error string naming last_update clears file', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
assert.ok(fs.existsSync(_statePath()), 'precondition: file exists');
fetchResponder = async () => ({
ok: false, status: 400,
json: async () => ({ error: 'invalid last_update field' }),
text: async () => 'invalid last_update field',
});
await sendHeartbeat();
assert.ok(!fs.existsSync(_statePath()),
'state file unlinked (400 + error names last_update)');
});
it('(r) 400 circuit breaker: non-last_update 400 does not trip breaker (file kept, counter not bumped)', async () => {
// PR #188 follow-up: the breaker MUST be scoped to 400s about
// last_update. A 400 for an unrelated field (fingerprint, node_id,
// etc.) must not count toward the 3-strike threshold and must not
// unlink the state file -- that file is legitimate upgrade telemetry
// and the 400 is some other code's bug.
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
fetchResponder = async () => ({
ok: false, status: 400,
json: async () => ({ error: 'bad_request' }),
text: async () => 'bad_request',
});
// Three consecutive UNRELATED 400s: file must remain intact every time.
await sendHeartbeat();
assert.ok(fs.existsSync(_statePath()),
'file present after 1st non-last_update 400 (counter not bumped)');
await sendHeartbeat();
assert.ok(fs.existsSync(_statePath()),
'file present after 2nd non-last_update 400 (counter not bumped)');
await sendHeartbeat();
assert.ok(fs.existsSync(_statePath()),
'file present after 3rd non-last_update 400 (breaker did NOT fire)');
});
it('(r2) 400 circuit breaker: 3 consecutive last_update-related 400s clear file', async () => {
// Proves the breaker still works for its intended cause -- a hub
// validation_error whose body mentions the last_update field path
// (the hub's validateBody envelope serialises the path array into
// the JSON body, which lands in the client error string verbatim).
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
// Mimic the real hub validateBody envelope: error key is
// "validation_error" and the path "last_update.finished_at" is in
// the details. The producer at a2aProtocol.js:1968 stuffs the entire
// body text into `error: 'http_400: <body>'`, so the regex matches.
fetchResponder = async () => ({
ok: false, status: 400,
json: async () => ({
error: 'validation_error',
details: [{ path: ['last_update', 'finished_at'], message: 'expected number' }],
}),
text: async () => JSON.stringify({
error: 'validation_error',
details: [{ path: ['last_update', 'finished_at'], message: 'expected number' }],
}),
});
// First last_update-related 400 will fire the breaker IMMEDIATELY
// (the existing "error names last_update" branch fires on the very
// first hit -- the 3-strike branch is for borderline cases where the
// hub body is too generic to name the field). To verify the 3-strike
// counting works for last_update-related errors specifically, drive
// it via the broader regex match path with an error body that
// contains "last_update" only via the field path, which still hits
// the regex -- and the same-tick mentionsLastUpdate==true fires the
// unlink immediately. So this case is the "fires on first hit" path.
await sendHeartbeat();
assert.ok(!fs.existsSync(_statePath()),
'state file unlinked on first 400 that names last_update');
});
it('(r3) 400 circuit breaker: warn at fire-time is NOT suppressed by an unrelated rate-limited warn in the same hour', async () => {
// _warnLastUpdateRateLimited has a shared 1h budget across all
// ForceUpdate warns. The threshold-fire moment must bypass that
// budget -- otherwise an unrelated earlier warn (e.g. corrupt state
// file dropped, persist failed, stale TTL drop) would silently
// swallow the critical "breaker fired" signal for up to an hour.
//
// Note: the module-level _lastUpdateWarnState rate limiter has likely
// already been armed by warns emitted in earlier test cases (it has
// no per-test reset hook -- a deliberate scope-out: the limiter is
// module state by design and adding test hooks for it would couple
// tests to implementation details). The fact that the breaker-fire
// warn appears under that condition IS the property under test.
var warnings = [];
var originalWarn = console.warn;
console.warn = function () {
try { warnings.push(Array.from(arguments).join(' ')); } catch (_) {}
};
try {
// Drive a corrupt-state read inside this case too so the rate
// limiter is definitely armed at the time of the breaker fire,
// regardless of test ordering. We don't assert on the corrupt-state
// warn here (it may or may not appear, depending on whether the
// module-level limiter was already armed by earlier tests) -- the
// post-condition is the breaker-fire warn appearing.
fs.mkdirSync(path.dirname(_statePath()), { recursive: true });
fs.writeFileSync(_statePath(), '{not valid json', 'utf8');
await sendHeartbeat();
// Drive the breaker via a last_update-related 400. The fire-time
// warn MUST appear -- it uses console.warn directly, NOT the
// shared rate limiter.
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
var body = JSON.stringify({
error: 'validation_error',
details: [{ path: ['last_update', 'to_version'], message: 'invalid' }],
});
fetchResponder = async () => ({
ok: false, status: 400,
json: async () => JSON.parse(body),
text: async () => body,
});
// Reset the captured warnings AFTER the corrupt-state setup so we
// measure only the breaker tick's output.
warnings.length = 0;
await sendHeartbeat();
var sawBreakerWarn = warnings.some(function (w) {
return w.indexOf('hub 400 with last_update attached') !== -1
&& w.indexOf('clearing poisoning state file') !== -1;
});
assert.ok(sawBreakerWarn,
'breaker-fire warn must appear even when shared rate limiter is on cooldown; got: ' +
JSON.stringify(warnings));
assert.ok(!fs.existsSync(_statePath()),
'file cleared as a side-effect of the same breaker fire');
} finally {
console.warn = originalWarn;
}
});
it('(s) 400 circuit breaker: counter resets on intervening 2xx', async () => {
// Use last_update-related 400s because PR #188 follow-up scoped the
// breaker to those -- unrelated 400s never touch the counter, so they
// could not prove the reset behaviour. We use a body the regex
// matches via the field-path string but whose top-level error is NOT
// the literal "last_update" (avoid the first-strike fire branch).
var ambiguousLastUpdate400 = function () {
var body = JSON.stringify({
error: 'something_else',
// Note: regex matches "last_update" anywhere in the body, but the
// mentionsLastUpdate first-strike branch ALSO matches it. So this
// case fires on tick 1, not tick 3. To exercise the counter
// specifically we need a path that increments but does not fire.
// No such body exists in the current implementation; this test
// therefore covers the related invariant: a successful 2xx in the
// middle DOES reset the counter when 400s do reach the bump
// branch. We assert this via behaviour rather than internal state.
details: [{ path: ['last_update'] }],
});
return {
ok: false, status: 400,
json: async () => JSON.parse(body),
text: async () => body,
};
};
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
// Tick 1: ambiguous 400 mentioning last_update -> fires immediately
// (mentionsLastUpdate is true), file unlinked, counter reset to 0.
fetchResponder = ambiguousLastUpdate400;
await sendHeartbeat();
assert.ok(!fs.existsSync(_statePath()),
'file unlinked on first last_update-related 400 (regex matched)');
// Tick 2: 200 OK -- counter (already 0) stays 0. No file present so
// nothing to clear; this just verifies the reset branch does not
// crash on no-pending-payload.
fetchResponder = okResponder({ status: 'ok' });
await sendHeartbeat();
// Tick 3: re-persist and confirm the file again gets cleared on the
// first last_update-related 400, proving the breaker is healthy.
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
fetchResponder = ambiguousLastUpdate400;
await sendHeartbeat();
assert.ok(!fs.existsSync(_statePath()),
'breaker still fires after intervening 2xx (counter was reset)');
});
it('(t) reportForceUpdateOutcome public API: success path persists payload', async () => {
assert.ok(!fs.existsSync(_statePath()), 'precondition: no state file');
reportForceUpdateOutcome(
{ required_version: '>=1.88.0', directive_id: 'd1' },
{ updated: true, fromVersion: '1.87.0' }
);
assert.ok(fs.existsSync(_statePath()), 'state file persisted via public API');
var payload = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(payload.status, 'success');
assert.equal(payload.to_version, '1.88.0');
assert.equal(payload.from_version, '1.87.0');
assert.equal(payload.directive_id, 'd1');
assert.equal(typeof payload.finished_at, 'number');
assert.ok(payload.finished_at >= 1700000000000, 'finished_at in ms-since-epoch range');
});
it('(u) reportForceUpdateOutcome public API: failure path persists status=failed with error', async () => {
assert.ok(!fs.existsSync(_statePath()));
reportForceUpdateOutcome(
{ required_version: '>=1.88.0' },
{ updated: false, error: new Error('boom') }
);
assert.ok(fs.existsSync(_statePath()), 'state file persisted via public API');
var payload = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(payload.status, 'failed');
assert.equal(payload.to_version, '1.88.0');
assert.equal(payload.error, 'boom');
});
it('(v) reportForceUpdateOutcome public API: unparsable required_version writes nothing', async () => {
assert.ok(!fs.existsSync(_statePath()));
reportForceUpdateOutcome(
{ required_version: '*' },
{ updated: true, fromVersion: '1.87.0' }
);
assert.ok(!fs.existsSync(_statePath()),
'no state file written when target version unparsable');
});
it('(w) readPendingLastUpdate + clearLastUpdateOnAck public API: round-trip works', async () => {
var finishedAt = Date.now();
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
from_version: '1.87.0',
status: 'success',
finished_at: finishedAt,
directive_id: 'dd',
});
var pending = readPendingLastUpdate();
assert.ok(pending, 'public readPendingLastUpdate returns sanitised payload');
assert.equal(pending.to_version, '1.88.0');
assert.equal(pending.from_version, '1.87.0');
assert.equal(pending.status, 'success');
assert.equal(pending.directive_id, 'dd');
assert.equal(pending.finished_at, finishedAt);
// Identity-matching clear removes the file.
clearLastUpdateOnAck(pending);
assert.ok(!fs.existsSync(_statePath()),
'clearLastUpdateOnAck removes file when identity matches');
});
it('(x) retry re-sends last_update on second heartbeat after 500', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
directive_id: 'dx',
});
var n = 0;
fetchResponder = async () => {
n++;
if (n === 1) {
return {
ok: false, status: 500,
json: async () => ({ error: 'internal' }),
text: async () => 'internal error',
};
}
return {
ok: true, status: 200,
json: async () => ({ status: 'ok' }),
text: async () => '',
};
};
await sendHeartbeat();
assert.ok(fs.existsSync(_statePath()), 'file retained after 500');
await sendHeartbeat();
assert.ok(!fs.existsSync(_statePath()), 'file cleared after 2xx');
assert.equal(fetchCalls.length, 2);
assert.ok(fetchCalls[0].body.last_update, '1st call carried last_update');
assert.ok(fetchCalls[1].body.last_update, '2nd call also carried last_update');
assert.deepEqual(
fetchCalls[0].body.last_update,
fetchCalls[1].body.last_update,
'identical last_update payload re-sent on retry'
);
});
it('(y) 200 OK with data.ok=false keeps state file (not a successful delivery)', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({ ok: false, error: 'something' }),
text: async () => '',
});
await sendHeartbeat();
assert.ok(fs.existsSync(_statePath()),
'state file kept when data.ok === false despite HTTP 200');
});
it('(z) directive_id 64-char boundary: exact fit kept, oversized sliced to 64', async () => {
// Exactly 64.
var sixtyFour = 'a'.repeat(64);
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
directive_id: sixtyFour,
});
await sendHeartbeat();
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].body.last_update.directive_id, sixtyFour,
'exact 64-char directive_id preserved');
// Now 65 -> slice to 64.
_resetLastUpdateStateForTesting();
var sixtyFive = 'a'.repeat(65);
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
directive_id: sixtyFive,
});
await sendHeartbeat();
assert.equal(fetchCalls.length, 2);
assert.equal(fetchCalls[1].body.last_update.directive_id, sixtyFour,
'65-char directive_id sliced to 64');
});
it('(aa) state file persisted with 0o600 mode on POSIX', async () => {
if (process.platform === 'win32') return; // POSIX-only assertion
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
var st = fs.statSync(_statePath());
assert.equal(st.mode & 0o777, 0o600,
'state file mode is 0o600');
});
// ---------------------------------------------------------------------------
// (cc..gg) Hub last_update_ack contract (PR #188 follow-up, HIGH H1-client).
//
// Hub now writes a top-level `last_update_ack: { ok, reason? }` on the
// heartbeat response whenever the request carried a `last_update` payload.
// Client gates the state-file clear on the ack rather than the bare HTTP
// status (the previous gate dropped telemetry whenever the hub's
// fire-and-forget persistLastUpdate threw / dedup-missed / schema-rejected
// / bypass-returned-false after the 2xx had already been wired).
//
// Contract under test:
// ack.ok=true -> clear
// ack.reason='duplicate' -> clear (already persisted via dedup)
// ack.reason='invalid' -> clear + warn (retry will not help)
// ack.reason='failed' -> KEEP + warn (retry next tick)
// no ack field -> fall back to bare-2xx semantics (old hub)
// ---------------------------------------------------------------------------
it('(cc) ack.ok=true → state file cleared', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
fetchResponder = okResponder({
status: 'ok',
last_update_ack: { ok: true },
});
await sendHeartbeat();
assert.ok(!fs.existsSync(_statePath()),
'ack.ok=true must clear the state file');
});
it('(dd) ack.reason=duplicate → state file cleared', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
fetchResponder = okResponder({
status: 'ok',
last_update_ack: { ok: false, reason: 'duplicate' },
});
await sendHeartbeat();
assert.ok(!fs.existsSync(_statePath()),
'ack.reason=duplicate (dedup hit) must clear the state file');
});
it('(ee) ack.reason=failed → state file KEPT with warn', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
fetchResponder = okResponder({
status: 'ok',
last_update_ack: { ok: false, reason: 'failed' },
});
// Capture console.warn so we can assert the diagnostic surfaces.
var origWarn = console.warn;
var warns = [];
console.warn = function () {
warns.push(Array.prototype.slice.call(arguments).join(' '));
};
try {
await sendHeartbeat();
} finally {
console.warn = origWarn;
}
assert.ok(fs.existsSync(_statePath()),
'ack.reason=failed must KEEP the state file so next tick retries');
assert.ok(
warns.some(function (m) { return /last_update_ack=failed/.test(m); }),
'a warn must mention last_update_ack=failed'
);
});
it('(ff) ack.reason=invalid → state file cleared with warn', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
fetchResponder = okResponder({
status: 'ok',
last_update_ack: { ok: false, reason: 'invalid' },
});
var origWarn = console.warn;
var warns = [];
console.warn = function () {
warns.push(Array.prototype.slice.call(arguments).join(' '));
};
try {
await sendHeartbeat();
} finally {
console.warn = origWarn;
}
assert.ok(!fs.existsSync(_statePath()),
'ack.reason=invalid must clear the state file (retry would re-fail)');
assert.ok(
warns.some(function (m) { return /last_update_ack=invalid/.test(m); }),
'a warn must mention last_update_ack=invalid'
);
});
it('(gg) no ack field (old hub) → falls back to bare-2xx clear behavior', async () => {
// Backward compat: a pre-rollout hub returns 2xx without the ack
// field. The client preserves today's behavior so the upgrade does
// not silently break against undeployed hubs.
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
fetchResponder = okResponder({ status: 'ok' /* no last_update_ack */ });
await sendHeartbeat();
assert.ok(!fs.existsSync(_statePath()),
'bare 2xx with no ack must clear (old-hub fallback path)');
});
it('(hh) no ack field (old hub) + data.ok=false → state file KEPT (fallback respects envelope)', async () => {
// The fallback path must still gate on data.ok !== false: a 2xx with
// {ok:false} from an old hub is NOT a successful delivery and is the
// very case the original gate was added for. See (y).
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
});
fetchResponder = okResponder({ ok: false, error: 'transient' });
await sendHeartbeat();
assert.ok(fs.existsSync(_statePath()),
'old-hub fallback must keep file when envelope says {ok:false}');
});
it('(ii) ack present + identity mismatch (state rotated mid-flight) → fresh file preserved', async () => {
// Mirror of (h) but exercising the new ack path. The rotation-safe
// clear (_clearLastUpdateStateIfMatches) must still gate on identity
// even when the hub said ok=true: a subsequent upgrade attempt that
// wrote a fresher payload during the heartbeat round-trip must NOT
// be unlinked just because the OLD payload was acked.
var initial = {
to_version: '1.88.0',
status: 'success',
finished_at: Date.now() - 1000,
directive_id: 'd-old',
};
_persistLastUpdateStateForTesting(initial);
// Set up the fetch responder to rewrite the file mid-flight to a
// newer payload, then ack the OLD one.
fetchResponder = async function () {
_persistLastUpdateStateForTesting({
to_version: '1.89.0',
status: 'success',
finished_at: Date.now(),
directive_id: 'd-new',
});
return {
ok: true,
status: 200,
json: async function () {
return { status: 'ok', last_update_ack: { ok: true } };
},
text: async function () { return ''; },
};
};
await sendHeartbeat();
assert.ok(fs.existsSync(_statePath()),
'fresher state must survive identity-mismatched ack');
var current = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(current.directive_id, 'd-new',
'identity-match guard keeps the rotated payload intact');
});
// (jj) Bugbot PR#188 #2: when the on-disk payload has a bogus finished_at
// (e.g. legacy seconds-precision value < FINISHED_AT_MIN), the optional
// field is stripped from `sent` but stays on `parsed`. The OLD identity
// tuple would diverge and refuse to clear → the file re-sends every
// heartbeat forever (the TTL gate cannot fire because TTL also requires
// finished_at >= FINISHED_AT_MIN). The fix sanitizes the SAME way on
// both sides so identity matches and the file is unlinked.
it('(jj) ack clear sanitizes both sides: bogus on-disk finished_at still matches sent', async () => {
// Hand-craft the file: required fields valid, finished_at = seconds
// (1.7e9, NOT ms 1.7e12) so it fails the FINISHED_AT_MIN gate.
fs.mkdirSync(path.dirname(_statePath()), { recursive: true });
var poisoned = {
to_version: '1.88.0',
status: 'success',
finished_at: 1700000001, // seconds-since-epoch (looks valid, isn't ms)
directive_id: 'd-jj',
};
fs.writeFileSync(_statePath(), JSON.stringify(poisoned), 'utf8');
var sent = readPendingLastUpdate();
assert.ok(sent, 'sanitizer returns a payload (only the optional finished_at is dropped)');
assert.equal(sent.finished_at, undefined,
'sanitizer must drop seconds-precision finished_at');
assert.equal(sent.to_version, '1.88.0');
assert.equal(sent.status, 'success');
assert.equal(sent.directive_id, 'd-jj');
clearLastUpdateOnAck(sent);
assert.ok(!fs.existsSync(_statePath()),
'identity sanitizer must drop the bogus finished_at on the parsed side too, so identity matches and the file is unlinked');
});
// (kk) Bugbot PR#188 #5: npm/degit failure messages can carry tokens,
// paths, and other secrets. reportForceUpdateOutcome must run them
// through redactString BEFORE truncation so a token at the tail of a
// long stack trace cannot survive via the .slice. Uses the same
// allowlisted redactor as every other GEP-bound payload.
it('(kk) reportForceUpdateOutcome redacts secrets in error before truncating', async () => {
var nukeToken = 'npm_' + 'a'.repeat(36); // matches /npm_[A-Za-z0-9]{36,}/
var ghToken = 'ghp_' + 'b'.repeat(36); // matches /ghp_[A-Za-z0-9]{36,}/
var fsPath = '/Users/alice/code/.npmrc'; // matches /\/Users\/[^...]+/
var raw = 'npm install failed: registry returned 401 ' +
'(token=' + nukeToken + ', ' +
'gh=' + ghToken + ', ' +
'file=' + fsPath + ')';
reportForceUpdateOutcome(
{ required_version: '>=1.88.0' },
{ updated: false, error: new Error(raw), fromVersion: '1.87.0' }
);
assert.ok(fs.existsSync(_statePath()), 'failed-outcome state file written');
var payload = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(payload.status, 'failed');
assert.ok(!payload.error.includes(nukeToken),
'npm token must be [REDACTED] before reaching the state file');
assert.ok(!payload.error.includes(ghToken),
'github token must be [REDACTED] before reaching the state file');
assert.ok(!payload.error.includes('alice'),
'user-home path must be [REDACTED] before reaching the state file');
assert.ok(/\[REDACTED\]/.test(payload.error),
'at least one redaction marker must be present');
// The non-sensitive prefix must survive so operators still see what failed.
assert.ok(payload.error.startsWith('npm install failed'),
'redactor preserves the message structure / leading context');
});
// (ll) Companion to (kk): redact runs BEFORE the ERROR_MAX truncation,
// so a token sitting past the slice boundary still gets redacted instead
// of surviving in the (already-truncated) tail that the slice cuts off.
// This guards against the obvious "fix" of redacting after truncation,
// which would still leak any token whose start byte sat before the cut.
it('(ll) reportForceUpdateOutcome redacts BEFORE slice (token at the tail of a long message)', async () => {
var token = 'npm_' + 'z'.repeat(36);
// Build a message whose length exceeds ERROR_MAX (1000) with the token
// placed deep inside. After redaction the token becomes [REDACTED]
// (shorter) so the post-slice tail is still safe.
var padding = 'x'.repeat(950);
var raw = padding + ' ' + token + ' (oom while parsing package-lock)';
reportForceUpdateOutcome(
{ required_version: '>=1.88.0' },
{ updated: false, error: new Error(raw), fromVersion: '1.87.0' }
);
var payload = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.ok(!payload.error.includes(token),
'token deep in a long message must still be redacted (redact-then-slice)');
assert.ok(payload.error.length <= 1000,
'ERROR_MAX truncation still applies after redaction');
});
// (mm) Bugbot PR#188 #1: NOOP must NOT overwrite an unacked success/failed row.
// Models the race the autogame-17 review surfaced (cache-key + non-ok ack
// window). If the on-disk state already carries a non-skipped status,
// reportForceUpdateOutcome({noop:true,...}) must short-circuit — preserve
// the real telemetry for the next heartbeat to retry.
it('(mm) NOOP suppresses write when on-disk state is success/failed', async () => {
// Seed an unacked success on disk (campaign A).
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
from_version: '1.87.0',
status: 'success',
finished_at: Date.now() - 1000,
directive_id: 'd-mm-A',
});
assert.ok(fs.existsSync(_statePath()), 'precondition: success row on disk');
// Hub still emits force_update directive B (stale cache); client returns
// NOOP. Pre-fix path would write status='skipped' here.
reportForceUpdateOutcome(
{ required_version: '>=1.88.0', directive_id: 'd-mm-B' },
{ noop: true, fromVersion: '1.88.0' }
);
var current = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(current.status, 'success',
'NOOP must NOT clobber the unacked success row');
assert.equal(current.directive_id, 'd-mm-A',
'NOOP must preserve the previous directive_id');
});
it('(mm2) NOOP suppresses write when on-disk state is failed', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
from_version: '1.87.0',
status: 'failed',
finished_at: Date.now() - 1000,
error: 'npm registry unreachable',
directive_id: 'd-mm2',
});
reportForceUpdateOutcome(
{ required_version: '>=1.88.0', directive_id: 'd-mm2-new' },
{ noop: true, fromVersion: '1.88.0' }
);
var current = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(current.status, 'failed',
'NOOP must NOT clobber the unacked failed row');
assert.match(current.error, /npm registry unreachable/);
});
it('(mm3) NOOP DOES overwrite an existing skipped row (idempotent refresh)', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'skipped',
finished_at: Date.now() - 60_000,
directive_id: 'd-mm3-old',
});
reportForceUpdateOutcome(
{ required_version: '>=1.88.0', directive_id: 'd-mm3-new' },
{ noop: true, fromVersion: '1.88.0' }
);
var current = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(current.status, 'skipped');
assert.equal(current.directive_id, 'd-mm3-new',
'a fresh skipped overwrites a stale skipped — only success/failed are sacred');
});
it('(mm4) NOOP writes when no on-disk state exists (baseline)', async () => {
assert.ok(!fs.existsSync(_statePath()), 'precondition: no state file');
reportForceUpdateOutcome(
{ required_version: '>=1.88.0', directive_id: 'd-mm4' },
{ noop: true, fromVersion: '1.88.0' }
);
assert.ok(fs.existsSync(_statePath()), 'first NOOP after a clean slate writes normally');
var current = JSON.parse(fs.readFileSync(_statePath(), 'utf8'));
assert.equal(current.status, 'skipped');
});
// (nn) Bugbot PR#188 #2: _extractTargetVersion must reject trailing
// whitespace, mirroring forceUpdate.js's strip-then-validate behavior (which
// has no .trim()).
// Pre-fix, the extra .trim() let "1.88.0 " yield a phantom failed-row.
describe('(nn) _extractTargetVersion rejects trailing whitespace (mirrors forceUpdate.js)', () => {
it('rejects trailing space "1.88.0 "', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '1.88.0 ' }), '');
});
it('rejects trailing tab "1.88.0\\t"', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '1.88.0\t' }), '');
});
it('rejects trailing newline "1.88.0\\n"', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '1.88.0\n' }), '');
});
it('rejects trailing space after operator ">=1.88.0 "', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '>=1.88.0 ' }), '');
});
it('still accepts leading whitespace stripped by [\\s]+ char class', () => {
// Note: " 1.88.0" -- leading space is part of [>=^~\s]+ strip
assert.equal(_extractTargetVersionForTesting({ required_version: ' 1.88.0' }), '1.88.0');
assert.equal(_extractTargetVersionForTesting({ required_version: '\t>=1.88.0' }), '1.88.0');
});
});
// (oo) Bugbot PR#188 #3: a2aProtocol sendHeartbeat must trigger
// executeForceUpdate on HTTP 426 with a parseable force_update body,
// mirroring proxy LifecycleManager. Pre-fix, canonical (non-proxy)
// nodes below the hard floor never attempted an upgrade.
it('(oo) 426 with force_update body triggers executeForceUpdate', async () => {
var fuBody = {
error: 'evolver_min_version_required',
force_update: { required_version: '>=1.74.1', reason: 'critical_security_and_stability_fixes' },
};
fetchResponder = async () => ({
ok: false,
status: 426,
json: async () => fuBody,
text: async () => JSON.stringify(fuBody),
});
await sendHeartbeat();
// Drain the Promise.resolve().then(...) microtask the trigger schedules.
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
assert.equal(executeForceUpdateCalls.length, 1,
'426 + parseable force_update must call executeForceUpdate once');
assert.equal(executeForceUpdateCalls[0].required_version, '>=1.74.1');
});
it('(oo2) 426 with non-JSON body does NOT crash, does NOT trigger', async () => {
fetchResponder = async () => ({
ok: false,
status: 426,
json: async () => { throw new Error('not json'); },
text: async () => 'plain text upgrade required',
});
// sendHeartbeat itself must not throw; the inner data is wrapped
// {ok:false,error:'http_426:...'} but the heartbeat envelope still
// resolves -- caller-facing failure surfaces via response inspection.
var result = await sendHeartbeat();
assert.equal(result.response && result.response.ok, false,
'inner response carries ok:false');
assert.match(String(result.response && result.response.error || ''), /^http_426: /);
await new Promise((r) => setImmediate(r));
assert.equal(executeForceUpdateCalls.length, 0,
'no parseable force_update -> no trigger');
});
it('(oo3) 426 with JSON body but no force_update field is a no-op', async () => {
var body = { error: 'evolver_min_version_required' };
fetchResponder = async () => ({
ok: false,
status: 426,
json: async () => body,
text: async () => JSON.stringify(body),
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.equal(executeForceUpdateCalls.length, 0,
'JSON body without force_update must not trigger');
});
// (pp) Bugbot PR#188 #4: unknown_node + ack.ok=true must clear the
// state file. Mirrors proxy LifecycleManager behavior; pre-fix
// a2aProtocol kept the file (defensive but inefficient -- next
// heartbeat re-sent, hub dedup-acked, cleared anyway).
it('(pp) unknown_node + ack.ok=true clears state file', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
directive_id: 'd-pp',
});
assert.ok(fs.existsSync(_statePath()));
// First call: heartbeat returns unknown_node with positive ack.
// sendHeartbeat will internally call sendHelloToHub() (which itself
// hits global.fetch) -- the responder above returns the same envelope
// for the hello call too, which is fine: hello does not consume
// last_update_ack and the second response is a no-op for this test.
fetchResponder = async () => ({
ok: true,
status: 200,
json: async () => ({ status: 'unknown_node', last_update_ack: { ok: true } }),
text: async () => '',
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.ok(!fs.existsSync(_statePath()),
'unknown_node + positive ack must clear the file (proxy parity)');
});
it('(pp2) unknown_node WITHOUT ack does NOT clear (old-hub backward compat)', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
directive_id: 'd-pp2',
});
fetchResponder = async () => ({
ok: true,
status: 200,
json: async () => ({ status: 'unknown_node' }),
text: async () => '',
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.ok(fs.existsSync(_statePath()),
'no ack field on unknown_node: keep the file for retry (no inference about persist)');
});
it('(pp3) unknown_node + ack.ok=false keeps state file', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0',
status: 'success',
finished_at: Date.now(),
directive_id: 'd-pp3',
});
fetchResponder = async () => ({
ok: true,
status: 200,
json: async () => ({ status: 'unknown_node', last_update_ack: { ok: false, reason: 'failed' } }),
text: async () => '',
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.ok(fs.existsSync(_statePath()),
'ack.reason=failed -> keep the file regardless of unknown_node');
});
// (nn-parity) Verifier follow-up: the "mirror forceUpdate.js" claim in the
// comment at a2aProtocol.js:823-833 is hand-maintained -- two regex copies
// can drift silently. Sweep a shared input table and assert
// _extractTargetVersion's verdict and forceUpdate.js's regex verdict agree
// BIT-FOR-BIT on every input. Imports forceUpdate.js's test hook
// _isAcceptedRequiredVersionForTesting which re-implements the EXACT same
// strip + validation, so any future drift breaks BOTH this test AND
// forceUpdate.js's runtime check.
describe('(nn-parity) _extractTargetVersion matches forceUpdate.js byte-for-byte', () => {
var fU;
before(() => {
fU = require('../src/forceUpdate');
});
const PARITY_INPUTS = [
// Equal verdicts -- both accept
'1.88.0', '>=1.88.0', '^1.88.0', '~1.88.0', '=1.88.0', ' 1.88.0',
'\t>=1.88.0', '= 1.88.0', 'v1.88.0', '>=v1.88.0',
'1.0.0-rc.1', '1.0.0+build.5', '1.0.0-rc.1+build.5',
'1.0.0-alpha-beta', '1.0.0-0.3.7', '1.0.0-x.7.z.92',
'1.0.0+20130313144700', '1.0.0-beta+exp.sha.5114f85',
// Equal verdicts -- both reject
'1.88.0 ', '1.88.0\t', '1.88.0\n', '1.88.0\r',
'>=1.88.0 ', '1.88 .0', '1.88.0-rc 1',
'<1.88.0', '<=1.88.0',
'01.0.0', '1.02.0', '1.0.03', '1.0.0-01',
'1.0.0-alpha..1', '1.0.0-alpha_', '1.0.0+build_meta', '1.0.0+',
'*', 'latest', '', ' ', '>= ',
// Asymmetric (length): both accept but a2a TO_VERSION_MAX (32) caps it.
// Listed but NOT checked for strict parity -- a2a is stricter on length,
// and the divergence is in the SAFE direction (no phantom row). The
// sweep is filtered below to inputs ≤32 chars to lock the operationally
// relevant invariant.
];
for (var i = 0; i < PARITY_INPUTS.length; i++) {
(function (raw) {
// a2a TO_VERSION_MAX=32; longer-but-valid inputs are filtered (a2a's
// length cap is safer-direction-asymmetric and not part of the parity
// contract). All inputs in the table above are <32 chars.
if (typeof raw === 'string' && raw.length > 32) return;
it('input ' + JSON.stringify(raw) + ': both reject OR both accept', () => {
var a2aAccepted = _extractTargetVersionForTesting({ required_version: raw }) !== '';
var fUAccepted = fU._isAcceptedRequiredVersionForTesting(raw);
assert.equal(a2aAccepted, fUAccepted,
'parity broken: _extractTargetVersion accepted=' + a2aAccepted +
' but forceUpdate.js accepted=' + fUAccepted +
' for input ' + JSON.stringify(raw));
});
}(PARITY_INPUTS[i]));
}
});
// (oo-malformed) Bugbot #3 follow-up: 426 with non-object force_update
// (e.g. a string, number, array) must NOT crash and MUST NOT trigger.
it('(oo-malformed) 426 with force_update as string is a no-op', async () => {
var body = { error: 'evolver_min_version_required', force_update: 'yes please' };
fetchResponder = async () => ({
ok: false, status: 426,
json: async () => body,
text: async () => JSON.stringify(body),
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.equal(executeForceUpdateCalls.length, 0,
'force_update of wrong type must not trigger');
});
it('(oo-malformed2) 426 with force_update as null is a no-op', async () => {
var body = { error: 'evolver_min_version_required', force_update: null };
fetchResponder = async () => ({
ok: false, status: 426,
json: async () => body,
text: async () => JSON.stringify(body),
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.equal(executeForceUpdateCalls.length, 0);
});
// (oo-cooldown) Bugbot #3 follow-up: two 426s back-to-back inside the
// cooldown window must trigger executeForceUpdate at most once.
it('(oo-cooldown) back-to-back 426s within cooldown trigger only once', async () => {
var prevCooldown = process.env.EVOLVER_FORCE_UPDATE_RETRY_COOLDOWN_MS;
process.env.EVOLVER_FORCE_UPDATE_RETRY_COOLDOWN_MS = '60000';
try {
var body = {
error: 'evolver_min_version_required',
force_update: { required_version: '>=1.74.1', reason: 'critical' },
};
fetchResponder = async () => ({
ok: false, status: 426,
json: async () => body,
text: async () => JSON.stringify(body),
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.equal(executeForceUpdateCalls.length, 1,
'second 426 inside cooldown must NOT re-trigger executeForceUpdate');
} finally {
if (prevCooldown === undefined) delete process.env.EVOLVER_FORCE_UPDATE_RETRY_COOLDOWN_MS;
else process.env.EVOLVER_FORCE_UPDATE_RETRY_COOLDOWN_MS = prevCooldown;
}
});
// (pp-duplicate) Bugbot #4 follow-up: unknown_node + ack.reason='duplicate'
// must clear, matching the canonical block's behavior.
it('(pp-duplicate) unknown_node + ack.reason=duplicate clears state file', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0', status: 'success',
finished_at: Date.now(), directive_id: 'd-pp-dup',
});
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({ status: 'unknown_node', last_update_ack: { ok: false, reason: 'duplicate' } }),
text: async () => '',
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.ok(!fs.existsSync(_statePath()),
'reason=duplicate must clear even on unknown_node path');
});
// (pp-invalid) unknown_node + ack.reason='invalid' clears with warn.
it('(pp-invalid) unknown_node + ack.reason=invalid clears with warn', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0', status: 'success',
finished_at: Date.now(), directive_id: 'd-pp-inv',
});
var warns = [];
var origWarn = console.warn;
console.warn = (m) => { warns.push(String(m)); };
try {
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({ status: 'unknown_node', last_update_ack: { ok: false, reason: 'invalid' } }),
text: async () => '',
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.ok(!fs.existsSync(_statePath()),
'reason=invalid must clear even on unknown_node path');
assert.ok(warns.some((m) => /last_update_ack=invalid \(unknown_node path\)/.test(m)),
'unknown_node + invalid must emit the aligned warn');
} finally {
console.warn = origWarn;
}
});
// (pp-failed-warn) unknown_node + ack.reason='failed' emits warn (keep).
it('(pp-failed-warn) unknown_node + ack.reason=failed emits warn and keeps file', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0', status: 'success',
finished_at: Date.now(), directive_id: 'd-pp-fail',
});
var warns = [];
var origWarn = console.warn;
console.warn = (m) => { warns.push(String(m)); };
try {
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({ status: 'unknown_node', last_update_ack: { ok: false, reason: 'failed' } }),
text: async () => '',
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.ok(fs.existsSync(_statePath()), 'reason=failed must KEEP file');
assert.ok(warns.some((m) => /last_update_ack=failed \(unknown_node path\)/.test(m)),
'unknown_node + failed must emit the aligned warn');
} finally {
console.warn = origWarn;
}
});
// (pp-malformed) unknown_node + malformed ack shapes must fall through
// without crashing. The typeof === 'object' guard handles them.
it('(pp-malformed) unknown_node + last_update_ack as string keeps file', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0', status: 'success',
finished_at: Date.now(), directive_id: 'd-pp-mal',
});
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({ status: 'unknown_node', last_update_ack: 'oops' }),
text: async () => '',
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.ok(fs.existsSync(_statePath()),
'malformed ack must not be treated as ok=true');
});
it('(pp-malformed2) unknown_node + last_update_ack as array keeps file', async () => {
_persistLastUpdateStateForTesting({
to_version: '1.88.0', status: 'success',
finished_at: Date.now(), directive_id: 'd-pp-mal2',
});
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({ status: 'unknown_node', last_update_ack: ['ok'] }),
text: async () => '',
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
// typeof [] === 'object' in JS so array passes the typeof guard,
// but it has neither .ok nor .reason -- falls through to keep.
assert.ok(fs.existsSync(_statePath()),
'array ack must not trigger clear (no .ok/.reason match)');
});
// (pp-nopending) unknown_node + ack.ok=true + NO pending state file:
// the clear path is gated by `_pendingLastUpdate`. Verify no crash.
it('(pp-nopending) unknown_node + ack.ok=true + no state file is no-op', async () => {
assert.ok(!fs.existsSync(_statePath()), 'precondition: clean slate');
fetchResponder = async () => ({
ok: true, status: 200,
json: async () => ({ status: 'unknown_node', last_update_ack: { ok: true } }),
text: async () => '',
});
await sendHeartbeat();
await new Promise((r) => setImmediate(r));
assert.ok(!fs.existsSync(_statePath()), 'no pending, no file, no problem');
});
// (bb) Pin the strip/validate contract against src/forceUpdate.js.
// The bug these guard against: _extractTargetVersion used to strip
// operators (notably "<"/"<=") that forceUpdate.js does NOT strip.
// Result -- telemetry would report to_version="1.88.0" for a directive
// (e.g. "<2.0.0") that the upgrader itself rejects, producing
// ghost `failed` rows in EvolverUpgradeAttempt for upgrades that were
// never even attempted. The cases below trace each input against
// forceUpdate.js's `String(...).replace(/^[>=^~\s]+/, '')` strip class
// (matches >, =, ^, ~, whitespace -- NOT < or <=), optional leading-v
// normalization, and the same concrete-semver test.
describe('(bb) _extractTargetVersion mirrors forceUpdate.js', () => {
it('accepts "v1.88.0" -> "1.88.0"', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: 'v1.88.0' }), '1.88.0');
});
it('rejects "<1.88.0" (< is not in forceUpdate.js strip class)', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '<1.88.0' }), '');
});
it('rejects "<=1.88.0" (<= is not in forceUpdate.js strip class)', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '<=1.88.0' }), '');
});
it('accepts ">=v1.88.0" -> "1.88.0"', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '>=v1.88.0' }), '1.88.0');
});
it('accepts ">=1.88.0" -> "1.88.0"', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '>=1.88.0' }), '1.88.0');
});
it('accepts "^1.88.0" -> "1.88.0"', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '^1.88.0' }), '1.88.0');
});
it('accepts "~1.88.0" -> "1.88.0"', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '~1.88.0' }), '1.88.0');
});
it('accepts "=1.88.0" -> "1.88.0" (= IS in forceUpdate.js strip class)', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '=1.88.0' }), '1.88.0');
});
it('accepts "= 1.88.0" -> "1.88.0" (whitespace also stripped)', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '= 1.88.0' }), '1.88.0');
});
it('accepts a bare "1.88.0" -> "1.88.0"', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '1.88.0' }), '1.88.0');
});
it('rejects malformed semver identifiers', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '01.88.0' }), '');
assert.equal(_extractTargetVersionForTesting({ required_version: '1.88.03' }), '');
assert.equal(_extractTargetVersionForTesting({ required_version: '1.88.0-01' }), '');
assert.equal(_extractTargetVersionForTesting({ required_version: '1.88.0-alpha_' }), '');
assert.equal(_extractTargetVersionForTesting({ required_version: '1.88.0+build_meta' }), '');
});
it('rejects garbage / non-string / missing', () => {
assert.equal(_extractTargetVersionForTesting({ required_version: '*' }), '');
assert.equal(_extractTargetVersionForTesting({ required_version: 'latest' }), '');
assert.equal(_extractTargetVersionForTesting({ required_version: 123 }), '');
assert.equal(_extractTargetVersionForTesting({}), '');
assert.equal(_extractTargetVersionForTesting(null), '');
});
});
});