Skip to content

Commit b9d68ab

Browse files
ithiria894claude
andcommitted
feat: E2E tests for Show Effective + move restrictions; fix rule move bug (v0.13.5)
12 new tests. Bug fix: rule duplicate case in mover.mjs switch. Drag-and-drop confirm modal now shows move warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4ffb9c9 commit b9d68ab

5 files changed

Lines changed: 212 additions & 4 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mcpware/claude-code-organizer",
3-
"version": "0.13.4",
3+
"version": "0.13.5",
44
"description": "Organize all your Claude Code memories, skills, MCP servers, commands, agents, rules, and hooks — see what loads globally vs per-project, then move items between scopes",
55
"type": "module",
66
"files": [

src/mover.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,6 @@ export function getValidDestinations(item, scopes) {
445445
case "skill":
446446
case "command":
447447
case "agent":
448-
case "rule":
449448
// File-based items: global is always valid; project scopes are valid only if
450449
// their .claude dir is distinct from global's ~/.claude (avoids silent overlap).
451450
return s.id === "global" || (s.repoDir && !sharesGlobalClaudeDir(s));

src/ui/app.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,6 +1896,13 @@ function showDragConfirm(item, fromScope, toScope) {
18961896
</div>
18971897
</div>`;
18981898

1899+
// Add move warning if applicable (same logic as move modal)
1900+
const warning = getMoveWarning(item);
1901+
const previewEl = document.getElementById("dcPreview");
1902+
if (warning && previewEl) {
1903+
previewEl.innerHTML += `<div class="move-warning" style="margin-top:10px;">${esc(warning)}</div>`;
1904+
}
1905+
18991906
document.getElementById("dragConfirmModal").classList.remove("hidden");
19001907
}
19011908

tests/e2e/dashboard.spec.mjs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,11 @@ async function createTestEnv() {
180180
}, null, 2));
181181

182182
// ── Project-level MCP (in repo root) ──
183+
// 'test-server' exists in both global and project → tests MCP shadowing
183184
await writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
184185
mcpServers: {
185186
'project-mcp': { command: 'node', args: ['local-server.js'] },
187+
'test-server': { command: 'node', args: ['project-server.js'] },
186188
}
187189
}, null, 2));
188190

@@ -202,19 +204,23 @@ async function createTestEnv() {
202204
await writeFile(join(globalCmdsDir, 'deploy.md'), '---\nname: deploy\ndescription: Deploy to production\n---\n# Deploy\nStep 1: Build\nStep 2: Push');
203205

204206
// ── Project commands ──
207+
// 'deploy' exists in both global and project → tests command conflict
205208
const projectCmdsDir = join(projectDir, '.claude', 'commands');
206209
await mkdir(projectCmdsDir, { recursive: true });
207210
await writeFile(join(projectCmdsDir, 'local-build.md'), '---\nname: local-build\ndescription: Build the project locally\n---\n# Local Build\nRun npm run build');
211+
await writeFile(join(projectCmdsDir, 'deploy.md'), '---\nname: deploy\ndescription: Deploy this project\n---\n# Deploy\nProject-specific deploy');
208212

209213
// ── Global agents ──
210214
const globalAgentsDir = join(claudeDir, 'agents');
211215
await mkdir(globalAgentsDir, { recursive: true });
212216
await writeFile(join(globalAgentsDir, 'code-reviewer.md'), '---\nname: code-reviewer\ndescription: Reviews code for bugs and quality\n---\n# Code Reviewer\nReview code carefully.');
213217

214218
// ── Project agents ──
219+
// 'code-reviewer' exists in both global and project → tests agent shadowing
215220
const projectAgentsDir = join(projectDir, '.claude', 'agents');
216221
await mkdir(projectAgentsDir, { recursive: true });
217222
await writeFile(join(projectAgentsDir, 'test-runner.md'), '---\nname: test-runner\ndescription: Runs tests and reports results\n---\n# Test Runner\nRun all tests.');
223+
await writeFile(join(projectAgentsDir, 'code-reviewer.md'), '---\nname: code-reviewer\ndescription: Project-specific code reviewer\n---\n# Code Reviewer\nProject-specific review.');
218224

219225
// ── Project rules (project-scoped only, locked) ──
220226
const projectRulesDir = join(projectDir, '.claude', 'rules');
@@ -3078,3 +3084,199 @@ test.describe('Path Resolution', () => {
30783084
await rm(tmpDir, { recursive: true, force: true });
30793085
});
30803086
});
3087+
3088+
// ── Show Effective + Move Restrictions ──────────────────────────────
3089+
3090+
test.describe('Show Effective — per-category rules', () => {
3091+
let env;
3092+
test.beforeAll(async () => { env = await createTestEnv(); });
3093+
test.afterAll(async () => { await env.cleanup(); });
3094+
3095+
test('Show Effective adds global items for participating categories only', async ({ page }) => {
3096+
await page.goto(env.baseURL);
3097+
await page.waitForSelector('#loading', { state: 'hidden' });
3098+
3099+
// Select workspace project scope
3100+
await page.locator(`.s-scope-hdr[data-scope-id="${env.encodedProject}"]`).click();
3101+
await page.waitForTimeout(300);
3102+
3103+
const beforeCount = await page.evaluate(() => document.querySelectorAll('.item').length);
3104+
3105+
// Click Show Effective
3106+
await page.click('#inheritToggleBtn');
3107+
await page.waitForTimeout(500);
3108+
3109+
const afterCount = await page.evaluate(() => document.querySelectorAll('.item').length);
3110+
expect(afterCount).toBeGreaterThan(beforeCount);
3111+
3112+
// Global items should have "Global" badge
3113+
const globalBadges = await page.evaluate(() =>
3114+
document.querySelectorAll('.ib-global').length
3115+
);
3116+
expect(globalBadges).toBeGreaterThan(0);
3117+
});
3118+
3119+
test('MCP same-name items get Shadowed badge', async ({ page }) => {
3120+
await page.goto(env.baseURL);
3121+
await page.waitForSelector('#loading', { state: 'hidden' });
3122+
3123+
await page.locator(`.s-scope-hdr[data-scope-id="${env.encodedProject}"]`).click();
3124+
await page.waitForTimeout(300);
3125+
await page.click('#inheritToggleBtn');
3126+
await page.waitForTimeout(500);
3127+
3128+
// 'test-server' exists in both global and project — global one should be Shadowed
3129+
const shadowed = await page.evaluate(() =>
3130+
Array.from(document.querySelectorAll('.ib-shadowed')).map(el =>
3131+
el.closest('.item')?.querySelector('.item-name')?.textContent
3132+
).filter(Boolean)
3133+
);
3134+
expect(shadowed).toContain('test-server');
3135+
});
3136+
3137+
test('Command same-name items get Conflict badge', async ({ page }) => {
3138+
await page.goto(env.baseURL);
3139+
await page.waitForSelector('#loading', { state: 'hidden' });
3140+
3141+
await page.locator(`.s-scope-hdr[data-scope-id="${env.encodedProject}"]`).click();
3142+
await page.waitForTimeout(300);
3143+
await page.click('#inheritToggleBtn');
3144+
await page.waitForTimeout(500);
3145+
3146+
// 'deploy' exists in both global and project — should have Conflict badge
3147+
const conflicts = await page.evaluate(() =>
3148+
Array.from(document.querySelectorAll('.ib-conflict')).map(el =>
3149+
el.closest('.item')?.querySelector('.item-name')?.textContent
3150+
).filter(Boolean)
3151+
);
3152+
expect(conflicts).toContain('deploy');
3153+
});
3154+
3155+
test('Agent same-name items get Shadowed badge (project overrides user)', async ({ page }) => {
3156+
await page.goto(env.baseURL);
3157+
await page.waitForSelector('#loading', { state: 'hidden' });
3158+
3159+
await page.locator(`.s-scope-hdr[data-scope-id="${env.encodedProject}"]`).click();
3160+
await page.waitForTimeout(300);
3161+
await page.click('#inheritToggleBtn');
3162+
await page.waitForTimeout(500);
3163+
3164+
// 'code-reviewer' exists in both — global one should be Shadowed
3165+
const shadowed = await page.evaluate(() =>
3166+
Array.from(document.querySelectorAll('.ib-shadowed')).map(el =>
3167+
el.closest('.item')?.querySelector('.item-name')?.textContent
3168+
).filter(Boolean)
3169+
);
3170+
expect(shadowed).toContain('code-reviewer');
3171+
});
3172+
3173+
test('Categories without effectiveRule are dimmed when Show Effective is on', async ({ page }) => {
3174+
await page.goto(env.baseURL);
3175+
await page.waitForSelector('#loading', { state: 'hidden' });
3176+
3177+
await page.locator(`.s-scope-hdr[data-scope-id="${env.encodedProject}"]`).click();
3178+
await page.waitForTimeout(300);
3179+
await page.click('#inheritToggleBtn');
3180+
await page.waitForTimeout(500);
3181+
3182+
// Plan and session pills should be dimmed (f-pill-dim class)
3183+
const dimmedPills = await page.evaluate(() =>
3184+
Array.from(document.querySelectorAll('.f-pill.f-pill-dim')).map(el => el.dataset.filter)
3185+
);
3186+
// plan, rule, session have no effectiveRule
3187+
for (const cat of ['plan', 'rule', 'session']) {
3188+
if (dimmedPills.includes(cat)) {
3189+
expect(dimmedPills).toContain(cat);
3190+
}
3191+
}
3192+
});
3193+
3194+
test('detail panel shows "Why it applies" text', async ({ page }) => {
3195+
await page.goto(env.baseURL);
3196+
await page.waitForSelector('#loading', { state: 'hidden' });
3197+
3198+
await page.locator(`.s-scope-hdr[data-scope-id="${env.encodedProject}"]`).click();
3199+
await page.waitForTimeout(300);
3200+
3201+
// Click first item
3202+
await page.locator('.item').first().click();
3203+
await page.waitForTimeout(300);
3204+
3205+
const whyVisible = await page.evaluate(() => {
3206+
const el = document.getElementById('detailEffective');
3207+
return el && !el.classList.contains('hidden');
3208+
});
3209+
expect(whyVisible).toBe(true);
3210+
3211+
const whyText = await page.evaluate(() =>
3212+
document.getElementById('detailEffectiveText')?.textContent || ''
3213+
);
3214+
expect(whyText.length).toBeGreaterThan(10);
3215+
});
3216+
});
3217+
3218+
test.describe('Move restrictions — per-category destinations', () => {
3219+
let env;
3220+
test.beforeAll(async () => { env = await createTestEnv(); });
3221+
test.afterAll(async () => { await env.cleanup(); });
3222+
3223+
test('plan items have no valid move destinations', async () => {
3224+
const { items } = await (await fetch(`${env.baseURL}/api/scan`)).json();
3225+
const plan = items.find(i => i.category === 'plan');
3226+
if (!plan) return; // skip if no plans
3227+
3228+
const res = await (await fetch(`${env.baseURL}/api/destinations?path=${encodeURIComponent(plan.path)}&category=plan&name=${encodeURIComponent(plan.name)}`)).json();
3229+
expect(res.ok).toBe(true);
3230+
expect(res.destinations).toHaveLength(0);
3231+
});
3232+
3233+
test('rule items have no valid move destinations', async () => {
3234+
const { items } = await (await fetch(`${env.baseURL}/api/scan`)).json();
3235+
const rule = items.find(i => i.category === 'rule');
3236+
if (!rule) return;
3237+
3238+
const res = await (await fetch(`${env.baseURL}/api/destinations?path=${encodeURIComponent(rule.path)}&category=rule&name=${encodeURIComponent(rule.name)}`)).json();
3239+
expect(res.ok).toBe(true);
3240+
expect(res.destinations).toHaveLength(0);
3241+
});
3242+
3243+
test('skill items have valid move destinations', async () => {
3244+
const { items } = await (await fetch(`${env.baseURL}/api/scan`)).json();
3245+
const skill = items.find(i => i.category === 'skill');
3246+
expect(skill).toBeTruthy();
3247+
3248+
const res = await (await fetch(`${env.baseURL}/api/destinations?path=${encodeURIComponent(skill.path)}&category=skill&name=${encodeURIComponent(skill.name)}`)).json();
3249+
expect(res.ok).toBe(true);
3250+
expect(res.destinations.length).toBeGreaterThan(0);
3251+
});
3252+
3253+
test('mcp items have valid move destinations', async () => {
3254+
const { items } = await (await fetch(`${env.baseURL}/api/scan`)).json();
3255+
const mcp = items.find(i => i.category === 'mcp');
3256+
expect(mcp).toBeTruthy();
3257+
3258+
const res = await (await fetch(`${env.baseURL}/api/destinations?path=${encodeURIComponent(mcp.path)}&category=mcp&name=${encodeURIComponent(mcp.name)}`)).json();
3259+
expect(res.ok).toBe(true);
3260+
expect(res.destinations.length).toBeGreaterThan(0);
3261+
});
3262+
3263+
test('command items have valid move destinations', async () => {
3264+
const { items } = await (await fetch(`${env.baseURL}/api/scan`)).json();
3265+
const cmd = items.find(i => i.category === 'command');
3266+
expect(cmd).toBeTruthy();
3267+
3268+
const res = await (await fetch(`${env.baseURL}/api/destinations?path=${encodeURIComponent(cmd.path)}&category=command&name=${encodeURIComponent(cmd.name)}`)).json();
3269+
expect(res.ok).toBe(true);
3270+
expect(res.destinations.length).toBeGreaterThan(0);
3271+
});
3272+
3273+
test('agent items have valid move destinations', async () => {
3274+
const { items } = await (await fetch(`${env.baseURL}/api/scan`)).json();
3275+
const agent = items.find(i => i.category === 'agent');
3276+
expect(agent).toBeTruthy();
3277+
3278+
const res = await (await fetch(`${env.baseURL}/api/destinations?path=${encodeURIComponent(agent.path)}&category=agent&name=${encodeURIComponent(agent.name)}`)).json();
3279+
expect(res.ok).toBe(true);
3280+
expect(res.destinations.length).toBeGreaterThan(0);
3281+
});
3282+
});

0 commit comments

Comments
 (0)