Files
msd-core/tests/antigravity-upgrades.test.cjs
Jakub Zych fe3ed06691
Some checks failed
Tests / PR mergeability (push) Successful in 18s
Tests / Base branch health (push) Successful in 9s
Tests / Detect test scope (push) Successful in 16s
Tests / lint-tests (push) Failing after 1m43s
Tests / plugin-validate (push) Successful in 58s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 19s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 18s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 19s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Duplicate auto-close sweep / sweep (push) Successful in 19s
CI timeout budget report / report (push) Failing after 14s
Close Draft PRs (sweep) / Sweep open draft PRs (push) Successful in 9s
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 9s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
chore: clear dead test and allowlist leftovers of dropped runtimes
2026-10-06 20:35:12 +02:00

287 lines
14 KiB
JavaScript

// docs-guard-exempt: docs/cli/... substrings are external antigravity.google URL citations in comments, not repo paths.
'use strict';
/**
* antigravity capability UPGRADES — ADR-1239 Phase B / #2096 (EoS/antigravity).
*
* Drives the user-reachable surface (spawned `bin/install.js` via
* `runMinimalInstall`) plus targeted unit coverage to prove the two real
* upgrades Antigravity contributes as part of the imperative-adapter
* migration:
*
* UPGRADE 1 — permission-writer: `configureAntigravityPermissions` writes
* Antigravity's native `{"permissions":{"allow":[...]}}` schema
* (antigravity.google/docs/cli/permissions) into the SAME settings.json
* MSD's own hook registration writes, non-destructively appending MSD's own
* read_file/command allow rules while preserving any existing user
* permissions (allow/deny/ask). Uninstall removes only the MSD-owned rules.
*
* UPGRADE 2 — MCP companion config: `configureAntigravityMcpConfig` writes
* a standalone `mcp_config.json` (antigravity.google/docs/cli/gcli-migration)
* registering the `msd` MCP server (`bin/msd-mcp-server.js`, the SAME
* companion OpenCode documents/wires), non-destructively preserving any
* other user-configured `mcpServers` entries. Uninstall removes only the
* `msd` entry.
*
* Both writers are NOT MSD_TEST_MODE-gated (unlike
* OpenCode's) — runMinimalInstall strips MSD_TEST_MODE from the spawned
* installer's env, so both fire during a "minimal" install too.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { runNode } = require('./helpers/process-seam.cjs');
const { runMinimalInstall, installerEnv, INSTALL_SCRIPT } = require('./helpers/install-shared.cjs');
const { cleanup } = require('./helpers.cjs');
const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const {
toTildePosixPath,
buildAntigravityAllowRules,
configureAntigravityPermissions,
configureAntigravityMcpConfig,
} = require('../bin/install.js');
const { PROTOCOL_VERSION } = require('../msd-core/bin/lib/mcp-server.cjs');
const { PACKAGE_NAME } = require('../msd-core/bin/lib/package-identity.cjs');
const MCP_SERVER_BIN = path.join(__dirname, '..', 'bin', 'msd-mcp-server.js');
const ANTIGRAVITY_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'antigravity', 'capability.json'), 'utf8'),
);
// ---------------------------------------------------------------------------
// Descriptor boundary — permissionWriter wiring
// ---------------------------------------------------------------------------
test('capabilities/antigravity/capability.json declares runtime.permissionWriter: "antigravity"', () => {
assert.equal(ANTIGRAVITY_CAP.runtime.permissionWriter, 'antigravity');
});
// ---------------------------------------------------------------------------
// UPGRADE 1: permission-writer unit coverage (toTildePosixPath / rule builder)
// ---------------------------------------------------------------------------
test('toTildePosixPath collapses a homedir-rooted path to "~/..." form', () => {
const home = require('node:os').homedir();
assert.equal(toTildePosixPath(path.join(home, '.gemini', 'antigravity')), '~/.gemini/antigravity');
});
test('toTildePosixPath leaves a non-homedir path as an absolute posix path', () => {
assert.equal(toTildePosixPath('/var/tmp/some-config-dir'), '/var/tmp/some-config-dir');
});
test('buildAntigravityAllowRules emits the 4 documented "action(target)" rule strings', () => {
const rules = buildAntigravityAllowRules('/tmp/ag-config');
assert.deepEqual(rules, [
'read_file(/tmp/ag-config/msd-core/*)',
'read_file(/tmp/ag-config/agents/msd-*)',
'read_file(/tmp/ag-config/skills/msd-*)',
'command(node /tmp/ag-config/hooks/*)',
]);
});
// ---------------------------------------------------------------------------
// UPGRADE 1: live install — settings.json permissions.allow (both scopes)
// ---------------------------------------------------------------------------
for (const scope of ['global', 'local']) {
test(`antigravity --${scope}: settings.json permissions.allow contains MSD's rules (UPGRADE 1)`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'antigravity', scope });
t.after(() => cleanup(root));
const settingsPath = path.join(configDir, 'settings.json');
assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.ok(settings.permissions && Array.isArray(settings.permissions.allow), 'permissions.allow must be an array');
// Asserting on the exact rule strings here would require reproducing
// toTildePosixPath's os.homedir()-relative substitution from OUTSIDE the
// spawned installer subprocess: runMinimalInstall overrides that
// subprocess's HOME to `root`, so for --global (configDir === root ===
// that subprocess's HOME) every rule collapses to the bare `~/...` form,
// while THIS test process's own (unrelated, real) os.homedir() would
// reconstruct full absolute paths instead — a guaranteed mismatch that
// has nothing to do with correctness. Assert on the documented rule
// SHAPE (action + path suffix) instead, which holds regardless of
// whether the prefix collapsed to `~` or stayed a full absolute path.
const expectedShapes = [
{ action: 'read_file(', suffix: '/msd-core/*)' },
{ action: 'read_file(', suffix: '/agents/msd-*)' },
{ action: 'read_file(', suffix: '/skills/msd-*)' },
{ action: 'command(node ', suffix: '/hooks/*)' },
];
for (const { action, suffix } of expectedShapes) {
const found = settings.permissions.allow.some((rule) => rule.startsWith(action) && rule.endsWith(suffix));
assert.ok(found, `permissions.allow must include a rule shaped like "${action}...${suffix}" — got ${JSON.stringify(settings.permissions.allow)}`);
}
// Priority-scoping (deny/ask) is a user-owned decision — MSD never writes it.
assert.equal(settings.permissions.deny, undefined, 'MSD must never write permissions.deny');
assert.equal(settings.permissions.ask, undefined, 'MSD must never write permissions.ask');
});
}
test('configureAntigravityPermissions is idempotent — a second call adds no duplicate rules', (t) => {
const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-perm-idem-'));
t.after(() => cleanup(root));
configureAntigravityPermissions(true, root);
configureAntigravityPermissions(true, root);
const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8'));
assert.equal(settings.permissions.allow.length, new Set(settings.permissions.allow).size, 'no duplicate allow entries');
assert.equal(settings.permissions.allow.length, buildAntigravityAllowRules(root).length);
});
test('configureAntigravityPermissions preserves a pre-existing user permissions block (allow/deny/ask)', (t) => {
const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-perm-preserve-'));
t.after(() => cleanup(root));
fs.writeFileSync(path.join(root, 'settings.json'), JSON.stringify({
permissions: {
allow: ['command(git)'],
deny: ['command(rm -rf)'],
ask: ['command(*)'],
},
userCustomField: 'preserve-me',
}, null, 2));
configureAntigravityPermissions(true, root);
const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8'));
assert.ok(settings.permissions.allow.includes('command(git)'), 'pre-existing allow entry preserved');
assert.deepEqual(settings.permissions.deny, ['command(rm -rf)'], 'deny block untouched');
assert.deepEqual(settings.permissions.ask, ['command(*)'], 'ask block untouched');
assert.equal(settings.userCustomField, 'preserve-me', 'unrelated top-level user fields preserved');
for (const rule of buildAntigravityAllowRules(root)) {
assert.ok(settings.permissions.allow.includes(rule));
}
});
// ---------------------------------------------------------------------------
// UPGRADE 2: MCP companion config — live install (both scopes)
// ---------------------------------------------------------------------------
for (const scope of ['global', 'local']) {
test(`antigravity --${scope}: mcp_config.json registers the msd MCP companion (UPGRADE 2)`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'antigravity', scope });
t.after(() => cleanup(root));
const mcpConfigPath = path.join(configDir, 'mcp_config.json');
assert.ok(fs.existsSync(mcpConfigPath), `${mcpConfigPath} must exist`);
const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8'));
assert.ok(mcpConfig.mcpServers && mcpConfig.mcpServers.msd, 'mcpServers.msd must be present');
assert.equal(mcpConfig.mcpServers.msd.command, 'npx');
assert.deepEqual(mcpConfig.mcpServers.msd.args, ['-y', '-p', PACKAGE_NAME, 'msd-mcp-server']);
});
}
test('configureAntigravityMcpConfig is idempotent — a second call does not clobber an existing msd entry', (t) => {
const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-mcp-idem-'));
t.after(() => cleanup(root));
configureAntigravityMcpConfig(true, root);
// Simulate a user hand-edit of the msd entry after install.
const configPath = path.join(root, 'mcp_config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.mcpServers.msd.args.push('--custom-flag');
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
configureAntigravityMcpConfig(true, root);
const after = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.ok(after.mcpServers.msd.args.includes('--custom-flag'), 'a user-owned msd override is never clobbered (Hyrum\'s Law)');
});
test('configureAntigravityMcpConfig preserves a pre-existing unrelated mcpServers entry', (t) => {
const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-mcp-preserve-'));
t.after(() => cleanup(root));
fs.writeFileSync(path.join(root, 'mcp_config.json'), JSON.stringify({
mcpServers: {
'my-own-server': { command: 'my-tool', args: ['--flag'] },
},
}, null, 2));
configureAntigravityMcpConfig(true, root);
const config = JSON.parse(fs.readFileSync(path.join(root, 'mcp_config.json'), 'utf8'));
assert.deepEqual(config.mcpServers['my-own-server'], { command: 'my-tool', args: ['--flag'] });
assert.ok(config.mcpServers.msd);
});
// AC-style proof: the companion mcp_config.json points at (bin/msd-mcp-server.js)
// is actually reachable, not just documented. Mirrors tests/msd-mcp-server-bin.test.cjs
// exactly (same shim, same line-delimited JSON-RPC over stdio).
test('UPGRADE 2: msd-mcp-server companion is reachable — spawn, initialize, tools/list over stdio', () => {
const stdin = [
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }),
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
].join('\n') + '\n';
const res = spawnSync(process.execPath, [MCP_SERVER_BIN], {
input: stdin,
encoding: 'utf-8',
timeout: PROBE_TIMEOUT_MS,
env: { ...process.env, MSD_TEST_MODE: '1' },
});
assert.strictEqual(res.status, 0, `msd-mcp-server must exit cleanly on stdin EOF; stderr: ${res.stderr}`);
const lines = res.stdout.trim().split('\n').map((l) => JSON.parse(l));
assert.strictEqual(lines.length, 2, 'one response per request');
assert.strictEqual(lines[0].result.protocolVersion, PROTOCOL_VERSION, 'initialize handshake succeeds');
const toolNames = lines[1].result.tools.map((t) => t.name).sort();
assert.deepStrictEqual(
toolNames,
['msd_invoke_command', 'msd_read_state', 'msd_write_state'],
'the companion the mcp_config.json entry connects to advertises the real MSD tool surface',
);
});
// ---------------------------------------------------------------------------
// Uninstall — symmetric cleanup for both upgrades
// ---------------------------------------------------------------------------
test('antigravity --global uninstall removes only MSD-owned permissions.allow rules + mcpServers.msd, preserving user data', (t) => {
const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-uninstall-'));
t.after(() => cleanup(root));
const args = [INSTALL_SCRIPT, '--antigravity', '--global', '--config-dir', root];
const installResult = runNode(args, {
env: installerEnv({ HOME: root, USERPROFILE: root }),
timeoutMs: INSTALL_TIMEOUT_MS,
});
assert.strictEqual(installResult.exitCode, 0, `install failed: ${installResult.stderr}`);
// Seed user-owned data alongside MSD's contributions, post-install.
const settingsPath = path.join(root, 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
settings.permissions.allow.push('command(git)');
settings.permissions.deny = ['command(rm -rf)'];
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
const mcpConfigPath = path.join(root, 'mcp_config.json');
const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8'));
mcpConfig.mcpServers['my-own-server'] = { command: 'my-tool', args: [] };
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
const uninstallArgs = [INSTALL_SCRIPT, '--antigravity', '--global', '--config-dir', root, '--uninstall'];
const uninstallResult = runNode(uninstallArgs, {
env: installerEnv({ HOME: root, USERPROFILE: root }),
timeoutMs: INSTALL_TIMEOUT_MS,
});
assert.strictEqual(uninstallResult.exitCode, 0, `uninstall failed: ${uninstallResult.stderr}`);
const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.deepEqual(settingsAfter.permissions.allow, ['command(git)'], 'MSD allow rules removed, user rule preserved');
assert.deepEqual(settingsAfter.permissions.deny, ['command(rm -rf)'], 'user deny rule preserved');
const mcpConfigAfter = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8'));
assert.equal(mcpConfigAfter.mcpServers.msd, undefined, 'msd MCP entry removed');
assert.deepEqual(mcpConfigAfter.mcpServers['my-own-server'], { command: 'my-tool', args: [] }, 'user MCP server preserved');
});