mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
test: catch lost plugin edits and stale packaged release guidance (#1832)
Adds two checks for upgrade problems people have hit. 1. The installed daemon-restart scenario now seeds an older plugin and an edited binary. When the backup is denied, the restart must skip the managed refresh and leave every target unchanged, with a specific warning. Once permissions are restored, it must keep the exact recovery bytes and move to the shipped version. A missing or corrupt proof fails the report. 2. The package-content test reads the same tarball's what's-new heading and intro against its package.json, with independent stale-heading and stale-intro controls. Test-only: 5 files, +83. No product, scenario-grammar, scheduler or dependency change. One existing installed case gains one extra daemon start and file hashing.
This commit is contained in:
1 parent
dae1dd99fc
commit
9848c7b681
5 files changed
+83
No files matched your search
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
|
||||
// Exercise startup's real refresh in the existing restart case's disposable HOME.
|
||||
export async function preserveRefresh(daemon, home, rigBin) {
|
||||
const plugin = join(home, 'plugins/openrig-core');
|
||||
const recovery = join(home, 'backups/managed-refresh');
|
||||
const shipped = join(dirname(rigBin), '../daemon/assets/plugins/openrig-core');
|
||||
const edited = Buffer.from([0xff, 0, ...Buffer.from('unique owner edit\r\n')]);
|
||||
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
const files = dir => readdirSync(dir, { recursive: true, withFileTypes: true })
|
||||
.filter(entry => entry.isFile()).map(entry => join(entry.parentPath, entry.name));
|
||||
const snapshot = () => Object.fromEntries(files(plugin).sort()
|
||||
.map(file => [relative(plugin, file), sha(readFileSync(file))]));
|
||||
|
||||
await daemon.sigterm();
|
||||
writeFileSync(join(plugin, 'README.md'), edited);
|
||||
for (const harness of ['.claude-plugin', '.codex-plugin']) {
|
||||
const path = join(plugin, harness, 'plugin.json');
|
||||
const manifest = JSON.parse(readFileSync(path, 'utf8'));
|
||||
writeFileSync(path, JSON.stringify({ ...manifest, version: '0.0.1' }));
|
||||
}
|
||||
const before = snapshot();
|
||||
mkdirSync(recovery, { recursive: true });
|
||||
chmodSync(recovery, 0o500); // Non-root fixture: fail backup before any target write.
|
||||
try {
|
||||
await daemon.restart();
|
||||
assert.deepEqual(snapshot(), before, 'failed backup changed plugin bytes/version');
|
||||
assert.ok(readFileSync(join(home, 'daemon.log'), 'utf8').split('\n').some(line =>
|
||||
line.includes(`preservation failed for ${plugin}:`) && line.includes('refresh skipped')),
|
||||
'missing skipped-refresh warning for this plugin');
|
||||
} finally {
|
||||
chmodSync(recovery, 0o700);
|
||||
}
|
||||
await daemon.restart();
|
||||
const backup = files(recovery).find(file => readFileSync(file).equals(edited));
|
||||
assert.ok(backup, 'refresh lost the exact binary edit');
|
||||
assert.deepEqual(readFileSync(join(plugin, 'README.md')), readFileSync(join(shipped, 'README.md')));
|
||||
const version = dir => JSON.parse(readFileSync(join(dir, '.claude-plugin/plugin.json'), 'utf8')).version;
|
||||
assert.equal(version(plugin), version(shipped), 'managed version did not advance');
|
||||
assert.notEqual(version(plugin), '0.0.1');
|
||||
return { failedBackupUnchanged: true, editedSha256: sha(edited), backedUpSha256: sha(readFileSync(backup)),
|
||||
targetFilesChecked: Object.keys(before).length, version: version(plugin), shippedVersion: version(shipped) };
|
||||
}
|
||||
@@ -36,6 +36,15 @@ export function verifyPassingRun(exitCode, report, caseName) {
|
||||
assert.deepEqual(report.records[0], report.result);
|
||||
assert.equal(exitCode, 0);
|
||||
assert.equal(report.result.verdict, 'PASS');
|
||||
if (caseName === 'daemon-restart') {
|
||||
const proof = report.preservation;
|
||||
assert.equal(proof?.failedBackupUnchanged, true);
|
||||
assert.match(proof.editedSha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(proof.backedUpSha256, proof.editedSha256);
|
||||
assert.ok(proof.targetFilesChecked > 0);
|
||||
assert.equal(proof.version, proof.shippedVersion);
|
||||
assert.notEqual(proof.version, '0.0.1');
|
||||
}
|
||||
if (selected.kind === 'hook') {
|
||||
verifyHookOutput(report.hook);
|
||||
for (const text of selected.includes) assert.ok(report.hook.additionalContext.includes(text), text);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { networkInterfaces } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { defaultHostDaemon, runScenarioFile } from '../../daemon/test/helpers/scenario-pipeline.ts';
|
||||
import { CASES, PASSING_CASES, RESULT_PREFIX } from './result.mjs';
|
||||
import { preserveRefresh } from './preserve-refresh.mjs';
|
||||
|
||||
assert.equal(process.platform, 'linux', 'container execution only');
|
||||
assert.notEqual(process.getuid(), 0, 'run as the unprivileged testbed user');
|
||||
@@ -77,6 +78,10 @@ sys.exit(r.returncode)
|
||||
},
|
||||
daemon: async (scaffold, options) => {
|
||||
const daemon = await defaultHostDaemon(scaffold, options);
|
||||
if (caseName === 'daemon-restart') return {
|
||||
...daemon,
|
||||
restart: async () => { report.preservation = await preserveRefresh(daemon, scaffold.openrigHome, rigBin); },
|
||||
};
|
||||
if (mode === 'healthy' || selected.kind === 'hook') return daemon;
|
||||
return {
|
||||
...daemon,
|
||||
|
||||
@@ -30,6 +30,19 @@ const DAEMON_SURFACES = [
|
||||
"daemon/dist/gateway-slack-surface.js",
|
||||
];
|
||||
|
||||
function assertReleaseGuidance(version, note) {
|
||||
const [heading, intro] = note.trim().split(/\n\s*\n/, 2);
|
||||
assert.equal(heading, `# What changed in OpenRig ${version}`, 'packaged whats-new heading is stale');
|
||||
assert.ok(intro.includes(`just upgraded to ${version}.`), 'packaged whats-new introduction is stale');
|
||||
}
|
||||
|
||||
test("whats-new names its package version, and rejects stale heading or introduction", () => {
|
||||
const note = '# What changed in OpenRig 0.7.0\n\nThis note is for an agent on an install that was just upgraded to 0.7.0.';
|
||||
assertReleaseGuidance('0.7.0', note);
|
||||
assert.throws(() => assertReleaseGuidance('0.7.0', note.replace('OpenRig 0.7.0', 'OpenRig 0.6.12')), /heading is stale/);
|
||||
assert.throws(() => assertReleaseGuidance('0.7.0', note.replace('upgraded to 0.7.0', 'upgraded to 0.6.12')), /introduction is stale/);
|
||||
});
|
||||
|
||||
test("pre-check: @openrig/cli, ships the `rig` bin, and does not depend on the unpublished @openrig/daemon", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(CLI_DIR, "package.json"), "utf8"));
|
||||
assert.equal(pkg.name, "@openrig/cli", "must pack @openrig/cli, not the root openrig");
|
||||
@@ -61,6 +74,8 @@ test("package proof: the tarball ships the daemon surfaces and no packaged JS im
|
||||
`the shipped daemon must include ${surface} (a value-import resolution target)`);
|
||||
}
|
||||
const packed = JSON.parse(execFileSync("tar", ["-xzOf", tgzPath, "package/package.json"], { encoding: "utf8" }));
|
||||
const note = execFileSync("tar", ["-xzOf", tgzPath, "package/daemon/context-packs/reference/whats-new.md"], { encoding: "utf8" });
|
||||
assertReleaseGuidance(packed.version, note);
|
||||
assert.equal(packed.dependencies?.["@openrig/daemon"], undefined, "the packed manifest must not depend on @openrig/daemon");
|
||||
assert.equal(packed.bundledDependencies, undefined, "the packed manifest must not bundle dependencies");
|
||||
|
||||
|
||||
@@ -81,6 +81,14 @@ test('a passing-only case admits only a clean PASS of its own scenario', () => {
|
||||
const text = (PASSING_CASES[caseName].includes ?? []).join('\n');
|
||||
const hook = { rawOutput: JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } }), additionalContext: text };
|
||||
const pass = { ...(PASSING_CASES[caseName].kind === 'hook' ? { hook } : {}), mode: 'healthy', caseName, scenarioSha256: 'a'.repeat(64), result, records: [result], fault: null };
|
||||
if (caseName === 'daemon-restart') {
|
||||
pass.preservation = { failedBackupUnchanged: true, editedSha256: 'b'.repeat(64), backedUpSha256: 'b'.repeat(64),
|
||||
targetFilesChecked: 2, version: '0.1.17', shippedVersion: '0.1.17' };
|
||||
for (const changed of [undefined, { ...pass.preservation, failedBackupUnchanged: false },
|
||||
{ ...pass.preservation, backedUpSha256: 'c'.repeat(64) }, { ...pass.preservation, version: '0.0.1' }]) {
|
||||
assert.throws(() => verifyPassingRun(0, { ...pass, preservation: changed }, caseName));
|
||||
}
|
||||
}
|
||||
verifyPassingRun(0, pass, caseName);
|
||||
const fail = { ...result, verdict: 'FAIL', failedStep: 2 };
|
||||
for (const [exitCode, bad] of [
|
||||
|
||||
Reference in new issue
Block a user