mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(lease): name the double-crash limit of breaking a dead lock Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): field list 2 F1 entry; capy/support.env GSTACK_MIN_REVISION to this release Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.113.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
367 lines
21 KiB
TypeScript
367 lines
21 KiB
TypeScript
/**
|
|
* bin/gstack-upgrade: the deterministic, unattended upgrade every fix line
|
|
* names. Pinned on its machine lines (`gstack-upgrade: current|behind|upgraded`),
|
|
* exit codes (0 ok, 1 behind on --check, 3 refused with nothing changed) and
|
|
* what it refuses: a dirty tracked byte, a diverged local commit, a pinned
|
|
* (detached or off-default) checkout, an unreachable remote. Never stash,
|
|
* never reset: HEAD, file bytes and the reflog are unchanged on refusal.
|
|
* Fixture: a bare remote with commits A (1.0.0) and B (1.0.1), a depth-1
|
|
* file:// clone at A, ./setup and bin/gstack-capy-install stubs that log argv,
|
|
* on the simulated Capy host (test/helpers/capy-host.ts).
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { capyHost, type CapyHost } from './helpers/capy-host';
|
|
|
|
const REPO = path.resolve(import.meta.dir, '..');
|
|
const DEPS = [
|
|
'bin/gstack-upgrade', 'bin/gstack-bun-version.sh', 'bin/gstack-paths', 'bin/gstack-state-root.sh',
|
|
'bin/gstack-egress-lib.sh', 'bin/gstack-egress-receipt', 'lib/egress-receipt.ts', 'lib/state-root.ts',
|
|
'bin/gstack-lease', 'bin/gstack-harness-pid.sh', 'bin/gstack-install-verdict.sh',
|
|
];
|
|
const hosts: CapyHost[] = [];
|
|
const children: ChildProcess[] = [];
|
|
afterEach(() => {
|
|
for (const c of children.splice(0)) c.kill('SIGKILL');
|
|
for (const h of hosts.splice(0)) h.cleanup();
|
|
});
|
|
|
|
/** A child that stays alive for the test: a live lease holder that is not in gstack-upgrade's ancestry. */
|
|
function sleeper(): number {
|
|
const c = spawn('sleep', ['300'], { stdio: 'ignore' });
|
|
children.push(c);
|
|
return c.pid!;
|
|
}
|
|
|
|
const write = (file: string, text: string, mode = 0o644) => {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, text, { mode });
|
|
};
|
|
|
|
function fixture() {
|
|
const host = capyHost({ systemBun: '1.4.2' });
|
|
hosts.push(host);
|
|
const git = (args: string[], cwd: string) => {
|
|
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: host.env, timeout: 30_000 });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
|
return r.stdout.trim();
|
|
};
|
|
const log = path.join(host.base, 'calls.log');
|
|
const seed = path.join(host.base, 'seed');
|
|
const bare = path.join(host.base, 'remote.git');
|
|
fs.mkdirSync(seed, { recursive: true });
|
|
git(['init', '-q', '-b', 'main'], seed);
|
|
for (const rel of DEPS) {
|
|
if (!fs.existsSync(path.join(REPO, rel))) continue;
|
|
fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true });
|
|
fs.copyFileSync(path.join(REPO, rel), path.join(seed, rel));
|
|
fs.chmodSync(path.join(seed, rel), fs.statSync(path.join(REPO, rel)).mode);
|
|
}
|
|
write(path.join(seed, 'VERSION'), '1.0.0\n');
|
|
write(path.join(seed, 'README.md'), 'gstack fixture\n');
|
|
write(path.join(seed, 'bin/gstack-config'), '#!/usr/bin/env bash\nexit 0\n', 0o755);
|
|
write(path.join(seed, 'setup'), '#!/usr/bin/env bash\necho "setup $*" >> "$UPGRADE_LOG"\nexit "${STUB_SETUP_EXIT:-0}"\n', 0o755);
|
|
git(['add', '-A'], seed);
|
|
git(['commit', '-qm', 'A'], seed);
|
|
const a = git(['rev-parse', 'HEAD'], seed);
|
|
git(['init', '-q', '--bare', '-b', 'main', bare], host.base);
|
|
git(['push', '-q', bare, 'main'], seed);
|
|
const root = path.join(host.base, 'gstack');
|
|
git(['clone', '-q', '--single-branch', '--depth', '1', `file://${bare}`, root], host.base);
|
|
write(path.join(seed, 'VERSION'), '1.0.1\n');
|
|
git(['commit', '-qam', 'B'], seed);
|
|
const b = git(['rev-parse', 'HEAD'], seed);
|
|
git(['push', '-q', bare, 'main'], seed);
|
|
const env = { ...host.env, UPGRADE_LOG: log };
|
|
const run = (args: string[] = [], extra: Record<string, string> = {}) =>
|
|
spawnSync('bash', [path.join(root, 'bin/gstack-upgrade'), ...args], { cwd: host.home, encoding: 'utf8', env: { ...env, ...extra }, timeout: 60_000 });
|
|
return {
|
|
host, root, bare, a, b, run, git: (args: string[]) => git(args, root),
|
|
calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [],
|
|
};
|
|
}
|
|
|
|
describe('gstack-upgrade', () => {
|
|
test('--check: behind prints both versions and the commit count, exit 1, writes nothing', () => {
|
|
const f = fixture();
|
|
expect(f.git(['rev-parse', '--is-shallow-repository'])).toBe('true');
|
|
const r = f.run(['--check']);
|
|
expect(r.status, r.stdout + r.stderr).toBe(1);
|
|
expect(r.stdout).toContain('gstack-upgrade: behind 1.0.0 -> 1.0.1 (1 commits)');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
|
expect(f.calls()).toEqual([]);
|
|
});
|
|
|
|
test('default: fast-forwards to the remote default branch and runs setup --refresh-registered; --check is then current', () => {
|
|
const f = fixture();
|
|
const r = f.run();
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(r.stdout).toContain('gstack-upgrade: upgraded 1.0.0 -> 1.0.1');
|
|
expect(f.calls()).toEqual(['setup --refresh-registered']);
|
|
const again = f.run(['--check']);
|
|
expect(again.status).toBe(0);
|
|
expect(again.stdout).toContain('gstack-upgrade: current 1.0.1');
|
|
});
|
|
|
|
test('a reinstall that fails after the fast-forward exits 1; the next run completes it instead of saying current', () => {
|
|
const f = fixture();
|
|
const failed = f.run([], { STUB_SETUP_EXIT: '1' });
|
|
expect(failed.status).toBe(1);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(failed.stderr).toContain('reinstall failed');
|
|
expect(f.run(['--check']).status).toBe(1);
|
|
const retry = f.run();
|
|
expect(retry.status, retry.stdout + retry.stderr).toBe(0);
|
|
expect(retry.stdout).toContain('reinstall pending from 1.0.0');
|
|
expect(f.calls()).toEqual(['setup --refresh-registered', 'setup --refresh-registered']);
|
|
expect(f.run().stdout).toContain('gstack-upgrade: current 1.0.1');
|
|
});
|
|
|
|
test('a Capy install (the installer marker) runs bin/gstack-capy-install instead of setup', () => {
|
|
const f = fixture();
|
|
write(path.join(f.root, 'bin/gstack-capy-install'), '#!/usr/bin/env bash\necho "capy-install $*" >> "$UPGRADE_LOG"\necho "gstack: ok $(cat VERSION) for=autoplan"\n', 0o755);
|
|
write(path.join(f.root, '.git/gstack-capy-install.state'), `head=${f.a}\n`);
|
|
const r = f.run();
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(f.calls()).toEqual(['capy-install']);
|
|
expect(r.stdout.trim().split('\n').at(-1)).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
});
|
|
|
|
test('a dirty tracked byte refuses with UPGRADE_REFUSED: exit 3, HEAD, bytes and reflog unchanged', () => {
|
|
const f = fixture();
|
|
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
|
|
const reflog = f.git(['reflog']);
|
|
const r = f.run();
|
|
expect(r.status).toBe(3);
|
|
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
|
|
expect(r.stderr).toContain('README.md');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
|
expect(fs.readFileSync(path.join(f.root, 'README.md'), 'utf8')).toBe('local edit\n');
|
|
expect(f.git(['reflog'])).toBe(reflog);
|
|
expect(f.git(['stash', 'list'])).toBe('');
|
|
expect(f.calls()).toEqual([]);
|
|
});
|
|
|
|
test('a diverged local commit refuses with UPGRADE_REFUSED: never stash, never reset', () => {
|
|
const f = fixture();
|
|
fs.writeFileSync(path.join(f.root, 'README.md'), 'local commit\n');
|
|
f.git(['commit', '-qam', 'local']);
|
|
const head = f.git(['rev-parse', 'HEAD']);
|
|
const reflog = f.git(['reflog']);
|
|
const r = f.run();
|
|
expect(r.status).toBe(3);
|
|
expect(r.stderr).toContain('(UPGRADE_REFUSED)');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(head);
|
|
expect(f.git(['reflog'])).toBe(reflog);
|
|
expect(f.calls()).toEqual([]);
|
|
});
|
|
|
|
test('a detached (pinned) checkout refuses; an unreachable remote is UPGRADE_FETCH_FAILED (exit 3) for --check and default', () => {
|
|
const f = fixture();
|
|
f.git(['checkout', '-q', '--detach', 'HEAD']);
|
|
const pinned = f.run();
|
|
expect(pinned.status).toBe(3);
|
|
expect(pinned.stderr).toContain('(UPGRADE_REFUSED)');
|
|
expect(pinned.stderr).toContain('detached');
|
|
f.git(['checkout', '-q', 'main']);
|
|
fs.rmSync(f.bare, { recursive: true, force: true });
|
|
for (const args of [['--check'], []]) {
|
|
const offline = f.run(args);
|
|
expect(offline.status).toBe(3);
|
|
expect(offline.stderr).toContain('(UPGRADE_FETCH_FAILED)');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
|
}
|
|
});
|
|
|
|
test('refuses UPGRADE_BUSY while a foreign live lease exists, HEAD and bytes unchanged; GSTACK_UPGRADE_FORCE=1 proceeds', () => {
|
|
const f = fixture();
|
|
const live = sleeper();
|
|
const touch = spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), 'touch', '--root', f.root, '--pid', String(live)], { encoding: 'utf8', env: f.host.env, timeout: 30_000 });
|
|
expect(touch.status, touch.stdout + touch.stderr).toBe(0);
|
|
// Our own session's lease (this bun process is an ancestor of the upgrader) never counts.
|
|
spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), 'touch', '--root', f.root, '--pid', String(process.pid)], { encoding: 'utf8', env: f.host.env, timeout: 30_000 });
|
|
const reflog = f.git(['reflog']);
|
|
const r = f.run();
|
|
expect(r.status, r.stdout + r.stderr).toBe(3);
|
|
expect(r.stderr).toContain('(UPGRADE_BUSY)');
|
|
expect(r.stderr).toContain('nothing was changed');
|
|
expect(r.stderr).toMatch(new RegExp(`pid ${live} \\(\\d+m sleep\\)`));
|
|
expect(r.stderr).not.toContain(`pid ${process.pid} `);
|
|
expect(r.stderr).toContain('wait for those sessions to finish, or re-run with GSTACK_UPGRADE_FORCE=1 when you know they are idle');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
|
expect(fs.readFileSync(path.join(f.root, 'VERSION'), 'utf8')).toBe('1.0.0\n');
|
|
expect(f.git(['reflog'])).toBe(reflog);
|
|
expect(fs.existsSync(path.join(f.root, '.git/gstack-upgrade.pending'))).toBe(false);
|
|
expect(f.calls()).toEqual([]);
|
|
const forced = f.run([], { GSTACK_UPGRADE_FORCE: '1' });
|
|
expect(forced.status, forced.stdout + forced.stderr).toBe(0);
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(f.calls()).toEqual(['setup --refresh-registered']);
|
|
});
|
|
|
|
test('a dead-pid lease does not block', () => {
|
|
const f = fixture();
|
|
const dead = spawnSync('true', [], { timeout: 30_000 }).pid!;
|
|
const touch = spawnSync('bash', [path.join(f.root, 'bin/gstack-lease'), 'touch', '--root', f.root, '--pid', String(dead)], { encoding: 'utf8', env: f.host.env, timeout: 30_000 });
|
|
expect(touch.status, touch.stdout + touch.stderr).toBe(0);
|
|
const r = f.run();
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(r.stderr).not.toContain('UPGRADE_BUSY');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
});
|
|
|
|
test('--ff-only-no-reinstall: HEAD moves and the upgraded line prints, no reinstall ran, no pending marker; refusals still hold', () => {
|
|
const f = fixture();
|
|
fs.writeFileSync(path.join(f.root, 'README.md'), 'local edit\n');
|
|
const dirty = f.run(['--ff-only-no-reinstall']);
|
|
expect(dirty.status).toBe(3);
|
|
expect(dirty.stderr).toContain('(UPGRADE_REFUSED)');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.a);
|
|
f.git(['checkout', '-q', '--', 'README.md']);
|
|
const r = f.run(['--ff-only-no-reinstall']);
|
|
expect(r.status, r.stdout + r.stderr).toBe(0);
|
|
expect(r.stdout).toContain('gstack-upgrade: upgraded 1.0.0 -> 1.0.1');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(f.calls()).toEqual([]);
|
|
expect(fs.existsSync(path.join(f.root, '.git/gstack-upgrade.pending'))).toBe(false);
|
|
const again = f.run(['--check']);
|
|
expect(again.status, again.stdout + again.stderr).toBe(0);
|
|
expect(again.stdout).toContain('gstack-upgrade: current 1.0.1');
|
|
});
|
|
|
|
// Plan A7/A-b: every success path certifies the caller's project.
|
|
const STUB_CAPY = [
|
|
'#!/usr/bin/env bash',
|
|
'echo "capy-install $* cwd=$(pwd -P)" >> "$UPGRADE_LOG"',
|
|
'case " $* " in *" --project "*)',
|
|
' [ -n "${STUB_PIN_CONFLICT:-}" ] && { echo "gstack-capy-install: Bun conflict: gstack needs >=1.4.2 (verified archives: 1.4.2 1.4.3), $2/package.json pins exactly 1.3.14; fix: raise the project\'s pin (RUNTIME_PIN_CONFLICT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-install-runtime-conflict)" >&2; exit 3; }',
|
|
' [ -n "${STUB_PINS_FAIL:-}" ] && { echo "FAIL pins — bun 1.4.2 outside CI 1.4.3; fix: x (PROJECT_PIN_MISMATCH; d)"; echo "gstack: fail pins"; exit 1; }',
|
|
' [ -n "${STUB_PROJECT_STOP:-}" ] && { echo "gstack-capy-install: preflight: jq not found; fix: install jq (INSTALL_PREFLIGHT_FAILED; d)" >&2; exit 3; } ;;',
|
|
'esac',
|
|
'echo "wrapper-env rev=${GSTACK_CAPY_WRAPPER_REV:-} path=${GSTACK_CAPY_WRAPPER_PATH:-}" >> "$UPGRADE_LOG.env"',
|
|
'[ -n "${STUB_STOP:-}" ] && { echo "gstack-capy-install: preflight: curl not found; fix: install curl (INSTALL_PREFLIGHT_FAILED; d)" >&2; exit 3; }',
|
|
'[ -n "${STUB_WRAPPER_FAIL:-}" ] && { echo "FAIL wrapper — every Capy skill copy is stale; fix: x (CAPY_WRAPPER_STALE; d)"; echo "gstack: fail wrapper"; exit 1; }',
|
|
'[ -f "${STUB_FAIL_ONCE:-/nonexistent}" ] && { rm -f "$STUB_FAIL_ONCE"; echo "gstack: fail setup — ./setup failed"; exit 1; }',
|
|
'echo "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan"',
|
|
].join('\n') + '\n';
|
|
const capyFixture = () => {
|
|
const f = fixture();
|
|
write(path.join(f.root, 'bin/gstack-capy-install'), STUB_CAPY, 0o755);
|
|
write(path.join(f.root, '.git/gstack-capy-install.state'), `head=${f.a}\n`);
|
|
const project = path.join(f.host.base, 'project');
|
|
fs.mkdirSync(project, { recursive: true });
|
|
spawnSync('git', ['init', '-q', project], { timeout: 30_000 });
|
|
const from = (cwd: string, args: string[] = [], extra: Record<string, string> = {}) =>
|
|
spawnSync('bash', [path.join(f.root, 'bin/gstack-upgrade'), ...args], { cwd, encoding: 'utf8', env: { ...f.host.env, UPGRADE_LOG: path.join(f.host.base, 'calls.log'), ...extra }, timeout: 60_000 });
|
|
return { ...f, project: fs.realpathSync(project), from, pending: path.join(f.root, '.git/gstack-upgrade.pending') };
|
|
};
|
|
|
|
test('A-b: from a RUNTIME_PIN_CONFLICT project the move is kept, `upgraded` then `gstack: fail pins`, exit 1, no pending marker; the no-op path says the same', () => {
|
|
const f = capyFixture();
|
|
const r = f.from(f.project, [], { STUB_PIN_CONFLICT: '1' });
|
|
expect(r.status, r.stdout + r.stderr).toBe(1);
|
|
const lines = r.stdout.trim().split('\n');
|
|
expect(lines[0]).toBe('gstack-upgrade: upgraded 1.0.0 -> 1.0.1');
|
|
expect(lines.at(-1)).toBe(`gstack: fail pins — Bun conflict: gstack needs >=1.4.2 (verified archives: 1.4.2 1.4.3), ${f.project}/package.json pins exactly 1.3.14; fix: raise the project's pin (RUNTIME_PIN_CONFLICT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-install-runtime-conflict)`);
|
|
expect(r.stdout).not.toContain('gstack: ok');
|
|
expect(f.git(['rev-parse', 'HEAD'])).toBe(f.b);
|
|
expect(fs.existsSync(f.pending)).toBe(false);
|
|
expect(f.calls()).toEqual([`capy-install cwd=${f.root}`, `capy-install --project ${f.project} cwd=${f.project}`]);
|
|
const again = f.from(f.project, [], { STUB_PIN_CONFLICT: '1' });
|
|
expect(again.status).toBe(1);
|
|
expect(again.stdout.trim().split('\n')[0]).toBe('gstack-upgrade: current 1.0.1');
|
|
expect(again.stdout.trim().split('\n').at(-1)).toMatch(/^gstack: fail pins — Bun conflict: /);
|
|
expect(f.from(f.project, ['--check'], { STUB_PIN_CONFLICT: '1' }).status).toBe(0);
|
|
});
|
|
|
|
test('a verified-pin project ends on the installer\'s ok for that project; a pins failure ends on its `gstack: fail pins`; from /tmp nothing is certified', () => {
|
|
const f = capyFixture();
|
|
const ok = f.from(f.project);
|
|
expect(ok.status, ok.stdout + ok.stderr).toBe(0);
|
|
expect(ok.stdout.trim().split('\n').at(-1)).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
expect(ok.stdout.match(/^gstack: /gm)).toHaveLength(1);
|
|
const fail = f.from(f.project, [], { STUB_PINS_FAIL: '1' });
|
|
expect(fail.status).toBe(1);
|
|
expect(fail.stdout.trim().split('\n').at(-1)).toBe('gstack: fail pins');
|
|
expect(fs.existsSync(f.pending)).toBe(false);
|
|
const g = capyFixture();
|
|
const outside = g.from(fs.realpathSync(g.host.base), [], { STUB_PINS_FAIL: '1' });
|
|
expect(outside.status, outside.stdout + outside.stderr).toBe(0);
|
|
expect(g.calls()).toEqual([`capy-install cwd=${g.root}`]);
|
|
expect(outside.stdout.trim().split('\n').at(-1)).toBe('gstack: ok 1.0.1 for=autoplan');
|
|
});
|
|
|
|
test('a pending repair resumed from another project certifies that project', () => {
|
|
const f = capyFixture();
|
|
const once = path.join(f.host.base, 'fail-once');
|
|
write(once, '');
|
|
const failed = f.from(fs.realpathSync(f.host.base), [], { STUB_FAIL_ONCE: once });
|
|
expect(failed.status).toBe(1);
|
|
expect(failed.stdout).toContain('gstack: fail setup');
|
|
expect(fs.existsSync(f.pending)).toBe(true);
|
|
const resumed = f.from(f.project);
|
|
expect(resumed.status, resumed.stdout + resumed.stderr).toBe(0);
|
|
expect(resumed.stdout).toContain('gstack-upgrade: current 1.0.1; reinstall pending from 1.0.0');
|
|
expect(fs.existsSync(f.pending)).toBe(false);
|
|
expect(f.calls().at(-1)).toBe(`capy-install --project ${f.project} cwd=${f.project}`);
|
|
});
|
|
|
|
// Integration items 2 and 9: the wrapper row is never a reinstall failure,
|
|
// and every installer stop line becomes a trailer.
|
|
test('a reinstall whose only failing row is wrapper leaves no pending marker; the installer runs with the checkout copy as the active wrapper', () => {
|
|
const f = capyFixture();
|
|
write(path.join(f.root, 'capy/skill/gstack/scripts/install.sh'), 'GSTACK_CAPY_WRAPPER=7\n');
|
|
const r = f.from(fs.realpathSync(f.host.base), [], { STUB_WRAPPER_FAIL: '1' });
|
|
expect(r.status).toBe(1);
|
|
expect(r.stdout).toContain('gstack: fail wrapper');
|
|
expect(r.stdout).toContain('only the wrapper row failed (an owner action on the Drive copy, CAPY_WRAPPER_STALE), so no reinstall is pending');
|
|
expect(r.stderr).not.toContain('completes the pending reinstall');
|
|
expect(fs.existsSync(f.pending)).toBe(false);
|
|
const again = f.from(fs.realpathSync(f.host.base));
|
|
expect(again.stdout).not.toContain('reinstall pending');
|
|
expect(fs.readFileSync(path.join(f.host.base, 'calls.log.env'), 'utf8')).toContain(`rev=7 path=${f.root}/capy/skill/gstack/scripts/install.sh`);
|
|
});
|
|
|
|
test('an installer stop line maps to `gstack: fail runtime — <msg>`: in the caller-project check (no pending) and in the reinstall (pending kept)', () => {
|
|
const f = capyFixture();
|
|
const cert = f.from(f.project, [], { STUB_PROJECT_STOP: '1' });
|
|
expect(cert.status).toBe(1);
|
|
expect(cert.stdout.trim().split('\n').at(-1)).toBe('gstack: fail runtime — preflight: jq not found; fix: install jq (INSTALL_PREFLIGHT_FAILED; d)');
|
|
expect(fs.existsSync(f.pending)).toBe(false);
|
|
const g = capyFixture();
|
|
const re = g.from(g.project, [], { STUB_STOP: '1' });
|
|
expect(re.status).toBe(1);
|
|
expect(re.stdout.trim().split('\n').at(-1)).toBe('gstack: fail runtime — preflight: curl not found; fix: install curl (INSTALL_PREFLIGHT_FAILED; d)');
|
|
expect(fs.existsSync(g.pending)).toBe(true);
|
|
});
|
|
|
|
test('off Capy the caller\'s project is certified by the doctor\'s runtime and pins rows', () => {
|
|
const f = fixture();
|
|
write(path.join(f.root, 'bin/gstack-doctor'), '#!/usr/bin/env bash\necho "doctor $*" >> "$UPGRADE_LOG"\n[ -n "${STUB_PINS_FAIL:-}" ] && { echo "FAIL pins — x"; echo "gstack: fail pins"; exit 1; }\necho "gstack: ok 1.0.1 for=require=runtime,pins"\n', 0o755);
|
|
const project = path.join(f.host.base, 'project');
|
|
fs.mkdirSync(project);
|
|
spawnSync('git', ['init', '-q', project], { timeout: 30_000 });
|
|
const r = spawnSync('bash', [path.join(f.root, 'bin/gstack-upgrade')], { cwd: project, encoding: 'utf8', env: { ...f.host.env, UPGRADE_LOG: path.join(f.host.base, 'calls.log'), STUB_PINS_FAIL: '1' }, timeout: 60_000 });
|
|
expect(r.status).toBe(1);
|
|
expect(r.stdout.trim().split('\n').at(-1)).toBe('gstack: fail pins');
|
|
expect(f.calls()).toEqual(['setup --refresh-registered', `doctor --check --require runtime,pins --project ${fs.realpathSync(project)}`]);
|
|
});
|
|
|
|
test('every bin a trailer or fix line names exists in the repo', () => {
|
|
const sources = ['bin/gstack-capy-install', 'bin/gstack-doctor-check.sh', 'bin/gstack-skill-end', 'bin/gstack-upgrade', 'docs/capy-install.md',
|
|
'capy/skill/gstack/SKILL.md', 'capy/skill/gstack/scripts/install.sh'];
|
|
const named = new Set<string>();
|
|
for (const rel of sources) {
|
|
expect(fs.existsSync(path.join(REPO, rel)), rel).toBe(true);
|
|
const lines = fs.readFileSync(path.join(REPO, rel), 'utf8').split('\n').filter(l => /fix:|\brun |gstack: (ok|fail)/.test(l));
|
|
for (const m of lines.join('\n').matchAll(/(?:bin\/|\brun |\bfix: )(gstack-[a-z0-9-]+(?:\.sh|\.ts)?)\b/g)) named.add(m[1]);
|
|
}
|
|
expect(named.has('gstack-upgrade')).toBe(true);
|
|
const missing = [...named].filter(n => !fs.existsSync(path.join(REPO, 'bin', n)));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|