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
80 changes: 79 additions & 1 deletion src/__tests__/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('add command', () => {
it('shows help with --help', () => {
const { stdout, exitCode } = invokeCli(['add', '--help']);
expect(exitCode).toBe(0);
expect(stdout).toContain('Install a skill from a GitHub repository');
expect(stdout).toContain('Install a skill from a Git repository');
expect(stdout).toContain('--force');
expect(stdout).toContain('--yes');
expect(stdout).toContain('--all');
Expand Down Expand Up @@ -75,4 +75,82 @@ describe('add command', () => {
expect(exitCode).toBe(2);
expect(stderr).toContain('Invalid --path value');
});

describe('URL input', () => {
it('accepts a full GitHub URL', () => {
// Should parse the URL and proceed past format validation (fails at network, not parsing)
const { stdout } = invokeCli([
'--json',
'add',
'https://github.com/owner/repo',
'--yes',
'--global',
]);
const json = JSON.parse(stdout);
const hasFormatError = json.errors?.some(
(e: string) => e.includes('Invalid format') || e.includes('Unsupported URL'),
);
expect(hasFormatError).not.toBe(true);
}, 30_000);

it('accepts a full GitLab URL', () => {
// Should parse URL and proceed past format validation
const { stdout } = invokeCli([
'--json',
'add',
'https://gitlab.com/owner/repo',
'--yes',
'--global',
]);
const json = JSON.parse(stdout);
const hasFormatError = json.errors?.some(
(e: string) => e.includes('Invalid format') || e.includes('Unsupported URL'),
);
expect(hasFormatError).not.toBe(true);
}, 30_000);

it('accepts a full Bitbucket URL', () => {
const { stdout } = invokeCli([
'--json',
'add',
'https://bitbucket.org/owner/repo',
'--yes',
'--global',
]);
const json = JSON.parse(stdout);
const hasFormatError = json.errors?.some(
(e: string) => e.includes('Invalid format') || e.includes('Unsupported URL'),
);
expect(hasFormatError).not.toBe(true);
}, 30_000);

it('rejects a Codeberg URL (no giget built-in support)', () => {
const { exitCode, stderr } = invokeCli(['add', 'https://codeberg.org/owner/repo']);
expect(exitCode).toBe(2);
expect(stderr).toContain('Unsupported URL');
});

it('rejects an unknown host URL', () => {
const { exitCode, stderr } = invokeCli([
'add',
'https://unknown-host.example.com/owner/repo',
]);
expect(exitCode).toBe(2);
expect(stderr).toContain('Unsupported URL');
});

it('rejects a URL with missing repo', () => {
const { exitCode, stderr } = invokeCli(['add', 'https://github.com/onlyowner']);
expect(exitCode).toBe(2);
expect(stderr).toContain('Unsupported URL');
});

it('outputs valid JSON error for unsupported URL with --json flag', () => {
const { stdout, exitCode } = invokeCli(['--json', 'add', 'https://unknown.example.com/a/b']);
expect(exitCode).toBe(2);
const json = JSON.parse(stdout);
expect(json.success).toBe(false);
expect(json.errors.some((e: string) => e.includes('Unsupported URL'))).toBe(true);
});
});
});
118 changes: 118 additions & 0 deletions src/__tests__/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,124 @@ describe('manifest', () => {
});
});

describe('provider field', () => {
it('reads manifest with provider field', () => {
const manifest = {
version: MANIFEST_VERSION,
name: 'test-skill',
provider: 'gitlab',
owner: 'test-owner',
repo: 'test-repo',
path: '.',
branch: 'main',
sha: 'fc6274d15fa3ae2ab983129fb037999f264ba9a7',
};

writeFileSync(join(testDir, MANIFEST_FILENAME), JSON.stringify(manifest));

const result = readManifest(testDir);

expect(result).not.toBeNull();
expect(result!.provider).toBe('gitlab');
});

it('reads manifest without provider field (backwards compatible)', () => {
const manifest = {
version: MANIFEST_VERSION,
name: 'test-skill',
owner: 'test-owner',
repo: 'test-repo',
path: '.',
branch: 'main',
sha: 'fc6274d15fa3ae2ab983129fb037999f264ba9a7',
};

writeFileSync(join(testDir, MANIFEST_FILENAME), JSON.stringify(manifest));

const result = readManifest(testDir);

expect(result).not.toBeNull();
expect(result!.provider).toBeUndefined();
});

it('rejects manifest with invalid provider value', () => {
const manifest = {
version: MANIFEST_VERSION,
name: 'test-skill',
provider: 'gitlab; rm -rf /', // contains spaces and shell metacharacters
owner: 'test-owner',
repo: 'test-repo',
path: '.',
branch: 'main',
sha: 'fc6274d15fa3ae2ab983129fb037999f264ba9a7',
};

writeFileSync(join(testDir, MANIFEST_FILENAME), JSON.stringify(manifest));

const result = readManifest(testDir);

expect(result).toBeNull();
});

it('healManifest preserves provider field', () => {
const manifest = {
version: 1,
provider: 'gitlab',
owner: 'test-owner',
repo: 'test-repo',
path: '.',
branch: 'main',
sha: 'fc6274d15fa3ae2ab983129fb037999f264ba9a7',
};

writeFileSync(join(testDir, MANIFEST_FILENAME), JSON.stringify(manifest));

const result = healManifest(testDir);

expect(result).not.toBeNull();
expect(result!.provider).toBe('gitlab');
});

it('healManifest drops invalid provider field', () => {
const manifest = {
version: 1,
provider: 'gitlab; rm -rf /',
owner: 'test-owner',
repo: 'test-repo',
path: '.',
branch: 'main',
sha: 'fc6274d15fa3ae2ab983129fb037999f264ba9a7',
};

writeFileSync(join(testDir, MANIFEST_FILENAME), JSON.stringify(manifest));

const result = healManifest(testDir);

expect(result).not.toBeNull();
expect(result!.provider).toBeUndefined();
});

it('writeManifest round-trips provider field', () => {
const manifest: SkillManifest = {
version: MANIFEST_VERSION,
name: 'test-skill',
provider: 'bitbucket',
owner: 'test-owner',
repo: 'test-repo',
path: '.',
branch: 'main',
sha: 'fc6274d15fa3ae2ab983129fb037999f264ba9a7',
};

writeManifest(testDir, manifest);

const result = readManifest(testDir);

expect(result).not.toBeNull();
expect(result!.provider).toBe('bitbucket');
});
});

describe('key matching between entry and manifest', () => {
// These tests verify that keys built from parsed skillfish.json entries
// match keys built from per-skill manifests - critical for removal logic
Expand Down
Loading