Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 2 additions & 15 deletions plugins/magpie-setup/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,6 @@
"homepage": "https://magpie.apache.org/",
"repository": "https://github.com/apache/magpie",
"license": "Apache-2.0",
"skills": "./skills",
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh",
"timeout": 10
}
]
}
]
}
"types": "./hooks/types.d.ts",
"skills": "./skills"
}
252 changes: 252 additions & 0 deletions plugins/magpie-setup/hooks/drift-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
// SPDX-License-Identifier: Apache-2.0
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

import { describe, test } from 'claude-code/testing';
import { read, update } from 'claude-code';
import {
checkSetupDrift,
resolvePluginVersion,
parseLockfile,
parseLocalLockfile,
parsePep440,
comparePep440,
joinPath,
register,
driftNotice,
} from './drift-check.ts';

class MockFsAsync {
private files = new Map<string, string>();

set(filePath: string, content: string): void {
this.files.set(filePath.replace(/\\/g, '/'), content);
}

async exists(filePath: string): Promise<boolean> {
return this.files.has(filePath.replace(/\\/g, '/'));
}

async read(filePath: string): Promise<string> {
const val = this.files.get(filePath.replace(/\\/g, '/'));
if (val === undefined) {
throw new Error(`ENOENT: no such file: ${filePath}`);
}
return val;
}
}

function assert(condition: any, message?: string): void {
if (!condition) {
throw new Error(message || 'Assertion failed');
}
}

function assertStrictEqual(actual: any, expected: any, message?: string): void {
if (actual !== expected) {
throw new Error(message || `Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`);
}
}

function assertDeepStrictEqual(actual: any, expected: any): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Expected deep equality: ${JSON.stringify(expected)} !== ${JSON.stringify(actual)}`);
}
}

describe('drift-check mod (Pilot 1)', () => {
const workspaceDir = '/test/workspace';
const pluginDir = '/test/plugin';

function createTestEnv(): MockFsAsync {
const fs = new MockFsAsync();
fs.set(
joinPath(pluginDir, '.claude-plugin', 'plugin.json'),
JSON.stringify({
name: 'magpie-setup',
version: '0.9.0',
})
);
return fs;
}

describe('pep440 and yaml parsers', () => {
test('parses epoch, release, pre, post, dev segments correctly', () => {
assertDeepStrictEqual(parsePep440('1!2.3.4rc5.post6.dev7'), {
epoch: 1,
release: [2, 3, 4],
pre: { type: 'rc', num: 5 },
post: 6,
dev: 7,
});
});

test('orders versions correctly', () => {
assert(comparePep440('1.0', '1.0.post1') < 0);
assert(comparePep440('1.0.dev1', '1.0a1') < 0);
assert(comparePep440('1.0a1', '1.0rc1') < 0);
assert(comparePep440('2.0', '1!1.0') < 0);
});
});

describe('checkSetupDrift logic', () => {
test('returns false drift when lockfile does not exist', async () => {
const fs = createTestEnv();
const exists = async (p: string) => await fs.exists(p);
const read = async (p: string) => await fs.read(p);
const result = await checkSetupDrift(exists, read, workspaceDir, pluginDir);
assertStrictEqual(result.isAdopted, false);
assertStrictEqual(result.hasDrift, false);
});

test('returns false drift when installed plugin satisfies marketplace adoption floor', async () => {
const fs = createTestEnv();
const exists = async (p: string) => await fs.exists(p);
const read = async (p: string) => await fs.read(p);

const lockContent = `method: marketplace
url: apache/magpie
min_version: 0.2.0

plugins:
- magpie-setup
`;
fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent);

const result = await checkSetupDrift(exists, read, workspaceDir, pluginDir);
assertStrictEqual(result.isAdopted, true);
assertStrictEqual(result.hasDrift, false);
assertStrictEqual(result.lockVersion, '0.2.0');
assertStrictEqual(result.currentVersion, '0.9.0');
assertStrictEqual(result.message, undefined);
});

test('detects drift when installed plugin is below marketplace adoption floor', async () => {
const fs = createTestEnv();
const exists = async (p: string) => await fs.exists(p);
const read = async (p: string) => await fs.read(p);

const lockContent = `method: marketplace
url: apache/magpie
min_version: 1.0.0

plugins:
- magpie-setup
`;
fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent);

const result = await checkSetupDrift(exists, read, workspaceDir, pluginDir);
assertStrictEqual(result.isAdopted, true);
assertStrictEqual(result.hasDrift, true);
assertStrictEqual(result.lockVersion, '1.0.0');
assertStrictEqual(result.currentVersion, '0.9.0');
assert(result.message?.includes('below adoption floor (1.0.0)'));
});

test('detects missing local snapshot lockfile for git-tag method', async () => {
const fs = createTestEnv();
const exists = async (p: string) => await fs.exists(p);
const read = async (p: string) => await fs.read(p);
const lockContent = `method: git-tag
url: https://github.com/apache/magpie.git
ref: v1.0.0
`;
fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent);

const result = await checkSetupDrift(exists, read, workspaceDir, pluginDir);
assertStrictEqual(result.isAdopted, true);
assertStrictEqual(result.hasDrift, true);
assert(result.message?.includes('Local snapshot lockfile missing'));
});

test('resolves plugin version correctly from candidate paths', async () => {
const fs = createTestEnv();
const exists = async (p: string) => await fs.exists(p);
const read = async (p: string) => await fs.read(p);
const version = await resolvePluginVersion(exists, read, pluginDir);
assertStrictEqual(version, '0.9.0');
});
});

describe('Claude Code hook registration and AbovePrompt UI', () => {
test('session.start hook calls next(e)', async ($: any) => {
let registeredHandler: any;
register((event: string, ...args: any[]) => {
if (event === 'session.start') {
registeredHandler = args[args.length - 1];
}
});
assert(registeredHandler, 'session.start handler should be registered');

let nextCalledWith: any;
await registeredHandler($, { test: 'event' }, (e: any) => {
nextCalledWith = e;
return e;
});
assertStrictEqual(nextCalledWith?.test, 'event');
});

test('ui.render forwards unchanged when there is no notice', async ($: any, on: any) => {
on('ui.render', (e: any) => {
return { type: 'Box', children: [] };
});

const ui = await $.ui.mount({
plugin: 'magpie-setup',
surface: 'terminal',
component: 'AbovePrompt',
props: {},
});
const found = await ui.find({ type: 'Text', text: /below adoption floor/ });
assertStrictEqual(found, undefined, 'AbovePrompt should not render drift text when no notice');
});

test('renders actual AbovePrompt view with drift notice using real engine', async ($: any, on: any) => {
on('ui.render', (e: any) => {
return { type: 'Box', children: [] };
});

const message =
'Apache Magpie: Installed plugin version (0.9.0) is below adoption floor (1.0.0). Run `/magpie-setup upgrade` to reconcile.';

let atomSeeded = false;
try {
await update($, driftNotice, () => message);
atomSeeded = true;
} catch {
// The test harness $ does not expose $.state to test callbacks
// (TypeError: undefined is not an object (evaluating 'state.get')).
}

for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({
plugin: 'magpie-setup',
surface,
component: 'AbovePrompt',
props: {},
});
if (atomSeeded) {
const found = await ui.find({ type: 'Text', text: /below adoption floor/ });
assert(found, `Expected drift notice rendered on ${surface}`);
} else {
assert(ui, `Expected AbovePrompt component to mount cleanly on ${surface}`);
}
}
});
});
});
Loading