yishentu--claudian
779 行
22 KiB
TypeScript
779 行
22 KiB
TypeScript
import { extractFirstParagraph, parseSlashCommandContent, serializeCommand, serializeSlashCommandMarkdown, validateCommandName, yamlString } from '@/utils/slashCommand';
|
|
|
|
describe('parseSlashCommandContent', () => {
|
|
describe('basic parsing', () => {
|
|
it('should parse command with full frontmatter', () => {
|
|
const content = `---
|
|
description: Review code for issues
|
|
argument-hint: "[file] [focus]"
|
|
allowed-tools:
|
|
- Read
|
|
- Grep
|
|
model: claude-sonnet-4-5
|
|
---
|
|
Review this code: $ARGUMENTS`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Review code for issues');
|
|
expect(parsed.argumentHint).toBe('[file] [focus]');
|
|
expect(parsed.allowedTools).toEqual(['Read', 'Grep']);
|
|
expect(parsed.model).toBe('claude-sonnet-4-5');
|
|
expect(parsed.promptContent).toBe('Review this code: $ARGUMENTS');
|
|
});
|
|
|
|
it('should parse command with minimal frontmatter', () => {
|
|
const content = `---
|
|
description: Simple command
|
|
---
|
|
Do something`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Simple command');
|
|
expect(parsed.argumentHint).toBeUndefined();
|
|
expect(parsed.allowedTools).toBeUndefined();
|
|
expect(parsed.model).toBeUndefined();
|
|
expect(parsed.promptContent).toBe('Do something');
|
|
});
|
|
|
|
it('should handle content without frontmatter', () => {
|
|
const content = 'Just a prompt without frontmatter';
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBeUndefined();
|
|
expect(parsed.promptContent).toBe('Just a prompt without frontmatter');
|
|
});
|
|
|
|
it('should handle inline array syntax for allowed-tools', () => {
|
|
const content = `---
|
|
allowed-tools: [Read, Write, Bash]
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.allowedTools).toEqual(['Read', 'Write', 'Bash']);
|
|
});
|
|
|
|
it('should handle quoted values', () => {
|
|
const content = `---
|
|
description: "Value with: colon"
|
|
argument-hint: 'Single quoted'
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Value with: colon');
|
|
expect(parsed.argumentHint).toBe('Single quoted');
|
|
});
|
|
});
|
|
|
|
describe('block scalar support', () => {
|
|
it('should parse literal block scalar (|) for description', () => {
|
|
const content = `---
|
|
description: |
|
|
Records a checkpoint of progress in the daily note.
|
|
Includes timestamp and current task status.
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Records a checkpoint of progress in the daily note.\nIncludes timestamp and current task status.');
|
|
expect(parsed.promptContent).toBe('Prompt');
|
|
});
|
|
|
|
it('should parse folded block scalar (>) for description', () => {
|
|
const content = `---
|
|
description: >
|
|
Records a checkpoint of progress
|
|
in the daily note.
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Records a checkpoint of progress in the daily note.');
|
|
expect(parsed.promptContent).toBe('Prompt');
|
|
});
|
|
|
|
it('should produce different output for | vs > block scalars', () => {
|
|
const literalContent = `---
|
|
description: |
|
|
Line one
|
|
Line two
|
|
---
|
|
Prompt`;
|
|
const foldedContent = `---
|
|
description: >
|
|
Line one
|
|
Line two
|
|
---
|
|
Prompt`;
|
|
|
|
const literal = parseSlashCommandContent(literalContent);
|
|
const folded = parseSlashCommandContent(foldedContent);
|
|
|
|
expect(literal.description).toBe('Line one\nLine two');
|
|
expect(folded.description).toBe('Line one Line two');
|
|
expect(literal.description).not.toBe(folded.description);
|
|
});
|
|
|
|
it('should preserve paragraph breaks in folded block scalar (>)', () => {
|
|
const content = `---
|
|
description: >
|
|
First paragraph here.
|
|
|
|
Second paragraph after empty line.
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('First paragraph here.\n\nSecond paragraph after empty line.');
|
|
expect(parsed.promptContent).toBe('Prompt');
|
|
});
|
|
|
|
it('should parse literal block scalar for argument-hint', () => {
|
|
const content = `---
|
|
argument-hint: |
|
|
[task-name]
|
|
[optional-notes]
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.argumentHint).toBe('[task-name]\n[optional-notes]');
|
|
});
|
|
|
|
it('should handle empty lines in literal block scalar', () => {
|
|
const content = `---
|
|
description: |
|
|
First paragraph here.
|
|
|
|
Second paragraph after empty line.
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('First paragraph here.\n\nSecond paragraph after empty line.');
|
|
});
|
|
|
|
it('should parse block scalar with other fields', () => {
|
|
const content = `---
|
|
description: |
|
|
Multi-line description
|
|
with multiple lines
|
|
model: claude-sonnet-4-5
|
|
allowed-tools:
|
|
- Read
|
|
- Write
|
|
---
|
|
Prompt content`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Multi-line description\nwith multiple lines');
|
|
expect(parsed.model).toBe('claude-sonnet-4-5');
|
|
expect(parsed.allowedTools).toEqual(['Read', 'Write']);
|
|
expect(parsed.promptContent).toBe('Prompt content');
|
|
});
|
|
|
|
it('should handle block scalar at end of frontmatter', () => {
|
|
const content = `---
|
|
model: claude-haiku-4-5
|
|
description: |
|
|
Last field in frontmatter
|
|
with multiple lines
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Last field in frontmatter\nwith multiple lines');
|
|
expect(parsed.model).toBe('claude-haiku-4-5');
|
|
});
|
|
|
|
it('should preserve indentation within block scalar content', () => {
|
|
const content = `---
|
|
description: |
|
|
Code example:
|
|
- Step 1
|
|
- Step 2
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Code example:\n - Step 1\n - Step 2');
|
|
});
|
|
|
|
it('should handle single-line block scalar', () => {
|
|
const content = `---
|
|
description: |
|
|
Just one line
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Just one line');
|
|
});
|
|
|
|
it('should not confuse pipe in quoted string with block scalar', () => {
|
|
const content = `---
|
|
description: "Contains | pipe character"
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Contains | pipe character');
|
|
});
|
|
|
|
it('should handle multiple block scalars in same frontmatter', () => {
|
|
const content = `---
|
|
description: |
|
|
First block scalar
|
|
with multiple lines
|
|
argument-hint: |
|
|
Second block scalar
|
|
also multi-line
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('First block scalar\nwith multiple lines');
|
|
expect(parsed.argumentHint).toBe('Second block scalar\nalso multi-line');
|
|
});
|
|
|
|
it('should handle CRLF line endings in block scalar', () => {
|
|
const content = '---\r\ndescription: |\r\n Line one\r\n Line two\r\n---\r\nPrompt';
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Line one\nLine two');
|
|
expect(parsed.promptContent).toBe('Prompt');
|
|
});
|
|
});
|
|
|
|
describe('block scalar edge cases', () => {
|
|
it('should handle empty block scalar followed by another field', () => {
|
|
const content = `---
|
|
description: |
|
|
model: claude-sonnet-4-5
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
// Empty block scalar yields no description (semantically same as absent)
|
|
expect(parsed.description).toBeUndefined();
|
|
expect(parsed.model).toBe('claude-sonnet-4-5');
|
|
});
|
|
|
|
it('should handle block scalar with only empty lines before next field', () => {
|
|
const content = `---
|
|
description: |
|
|
|
|
model: claude-sonnet-4-5
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
// Empty lines followed by unindented field should end the block scalar
|
|
expect(parsed.model).toBe('claude-sonnet-4-5');
|
|
});
|
|
|
|
it('should handle strip chomping indicator (|-)', () => {
|
|
const content = `---
|
|
description: |-
|
|
No trailing newline here
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
// Chomping indicator is recognized and parsed as block scalar
|
|
expect(parsed.description).toBe('No trailing newline here');
|
|
});
|
|
|
|
it('should handle keep chomping indicator (|+)', () => {
|
|
const content = `---
|
|
description: |+
|
|
Keep indicator recognized
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
// Keep chomping indicator is recognized
|
|
expect(parsed.description).toBe('Keep indicator recognized');
|
|
});
|
|
|
|
it('should handle folded with strip chomping (>-)', () => {
|
|
const content = `---
|
|
description: >-
|
|
Folded with strip
|
|
chomping indicator
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Folded with strip chomping indicator');
|
|
});
|
|
|
|
it('should not enable block scalar for unsupported keys', () => {
|
|
const content = `---
|
|
notes: |
|
|
This should not be parsed as block scalar
|
|
description: Regular description
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
// notes is not a supported key, so | is treated as the value
|
|
// description should be parsed normally
|
|
expect(parsed.description).toBe('Regular description');
|
|
});
|
|
|
|
it('should handle allowed-tools with block scalar indicator gracefully', () => {
|
|
const content = `---
|
|
allowed-tools: |
|
|
Read
|
|
Write
|
|
description: Test
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
// allowed-tools doesn't support block scalar, so | becomes the value
|
|
// The Read/Write lines are ignored as they're not valid YAML keys
|
|
expect(parsed.description).toBe('Test');
|
|
});
|
|
|
|
it('should preserve unicode content in block scalar', () => {
|
|
const content = `---
|
|
description: |
|
|
Hello 世界
|
|
Émoji: 🎉
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Hello 世界\nÉmoji: 🎉');
|
|
});
|
|
|
|
it('should preserve relative indentation in deeply nested content', () => {
|
|
const content = `---
|
|
description: |
|
|
Level 1
|
|
Level 2
|
|
Level 3
|
|
Level 4
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Level 1\n Level 2\n Level 3\n Level 4');
|
|
});
|
|
|
|
it('should preserve colons in block scalar content', () => {
|
|
const content = `---
|
|
description: |
|
|
key: value
|
|
another: pair
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('key: value\nanother: pair');
|
|
});
|
|
|
|
it('should preserve comment-like content (# lines)', () => {
|
|
const content = `---
|
|
description: |
|
|
# This looks like a YAML comment
|
|
But it is preserved as content
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('# This looks like a YAML comment\nBut it is preserved as content');
|
|
});
|
|
|
|
it('should preserve trailing whitespace in block scalar lines', () => {
|
|
// Use explicit string to ensure trailing spaces are preserved
|
|
const content = '---\ndescription: |\n Line with trailing spaces \n Normal line\n---\nPrompt';
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
expect(parsed.description).toBe('Line with trailing spaces \nNormal line');
|
|
});
|
|
|
|
it('should preserve leading empty lines in block scalar', () => {
|
|
const content = `---
|
|
description: |
|
|
|
|
Content after empty line
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
|
|
// Leading empty lines are preserved per YAML spec
|
|
expect(parsed.description).toBe('\nContent after empty line');
|
|
});
|
|
});
|
|
|
|
describe('skill fields', () => {
|
|
it('should parse kebab-case disable-model-invocation', () => {
|
|
const content = `---
|
|
description: A skill
|
|
disable-model-invocation: true
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.disableModelInvocation).toBe(true);
|
|
});
|
|
|
|
it('should parse kebab-case user-invocable', () => {
|
|
const content = `---
|
|
description: A skill
|
|
user-invocable: false
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.userInvocable).toBe(false);
|
|
});
|
|
|
|
it('should parse camelCase disableModelInvocation (backwards compat)', () => {
|
|
const content = `---
|
|
description: A skill
|
|
disableModelInvocation: true
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.disableModelInvocation).toBe(true);
|
|
});
|
|
|
|
it('should parse camelCase userInvocable (backwards compat)', () => {
|
|
const content = `---
|
|
description: A skill
|
|
userInvocable: false
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.userInvocable).toBe(false);
|
|
});
|
|
|
|
it('should prefer kebab-case over camelCase when both present', () => {
|
|
const content = `---
|
|
disable-model-invocation: true
|
|
disableModelInvocation: false
|
|
user-invocable: false
|
|
userInvocable: true
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.disableModelInvocation).toBe(true);
|
|
expect(parsed.userInvocable).toBe(false);
|
|
});
|
|
|
|
it('should parse context string', () => {
|
|
const content = `---
|
|
description: A skill
|
|
context: fork
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.context).toBe('fork');
|
|
});
|
|
|
|
it('should parse agent string', () => {
|
|
const content = `---
|
|
description: A skill
|
|
agent: code-reviewer
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.agent).toBe('code-reviewer');
|
|
});
|
|
|
|
it('should parse all skill fields together', () => {
|
|
const content = `---
|
|
description: Full skill
|
|
disableModelInvocation: true
|
|
userInvocable: true
|
|
context: fork
|
|
agent: code-reviewer
|
|
model: sonnet
|
|
---
|
|
Do the thing`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.description).toBe('Full skill');
|
|
expect(parsed.disableModelInvocation).toBe(true);
|
|
expect(parsed.userInvocable).toBe(true);
|
|
expect(parsed.context).toBe('fork');
|
|
expect(parsed.agent).toBe('code-reviewer');
|
|
expect(parsed.model).toBe('sonnet');
|
|
expect(parsed.promptContent).toBe('Do the thing');
|
|
});
|
|
|
|
it('should return undefined for missing skill fields', () => {
|
|
const content = `---
|
|
description: Simple command
|
|
---
|
|
Prompt`;
|
|
|
|
const parsed = parseSlashCommandContent(content);
|
|
expect(parsed.disableModelInvocation).toBeUndefined();
|
|
expect(parsed.userInvocable).toBeUndefined();
|
|
expect(parsed.context).toBeUndefined();
|
|
expect(parsed.agent).toBeUndefined();
|
|
expect(parsed.hooks).toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('yamlString', () => {
|
|
it('returns plain value for simple strings', () => {
|
|
expect(yamlString('hello world')).toBe('hello world');
|
|
});
|
|
|
|
it('quotes strings with colons', () => {
|
|
expect(yamlString('key: value')).toBe('"key: value"');
|
|
});
|
|
|
|
it('quotes strings with hash', () => {
|
|
expect(yamlString('has # comment')).toBe('"has # comment"');
|
|
});
|
|
|
|
it('quotes strings with newlines', () => {
|
|
expect(yamlString('line1\nline2')).toBe('"line1\nline2"');
|
|
});
|
|
|
|
it('quotes strings starting with space', () => {
|
|
expect(yamlString(' leading')).toBe('" leading"');
|
|
});
|
|
|
|
it('quotes strings ending with space', () => {
|
|
expect(yamlString('trailing ')).toBe('"trailing "');
|
|
});
|
|
|
|
it('escapes double quotes inside quoted strings', () => {
|
|
expect(yamlString('has "quotes" inside: yes')).toBe('"has \\"quotes\\" inside: yes"');
|
|
});
|
|
});
|
|
|
|
describe('serializeCommand', () => {
|
|
it('strips frontmatter from content before serializing', () => {
|
|
const result = serializeCommand({
|
|
id: 'cmd-test',
|
|
name: 'test',
|
|
description: 'Test',
|
|
content: '---\ndescription: old\n---\nBody text',
|
|
});
|
|
|
|
// Should use the SlashCommand's description, not the one in content frontmatter
|
|
expect(result).toContain('description: Test');
|
|
expect(result).toContain('Body text');
|
|
expect(result).not.toContain('description: old');
|
|
});
|
|
|
|
it('handles content without frontmatter', () => {
|
|
const result = serializeCommand({
|
|
id: 'cmd-test',
|
|
name: 'test',
|
|
description: 'Simple',
|
|
content: 'Just a prompt',
|
|
});
|
|
|
|
expect(result).toContain('description: Simple');
|
|
expect(result).toContain('Just a prompt');
|
|
});
|
|
});
|
|
|
|
describe('serializeSlashCommandMarkdown', () => {
|
|
it('serializes all fields in kebab-case', () => {
|
|
const result = serializeSlashCommandMarkdown({
|
|
name: 'my-skill',
|
|
description: 'Test command',
|
|
argumentHint: '[file]',
|
|
allowedTools: ['Read', 'Grep'],
|
|
model: 'claude-sonnet-4-5',
|
|
disableModelInvocation: true,
|
|
userInvocable: false,
|
|
context: 'fork',
|
|
agent: 'code-reviewer',
|
|
}, 'Do the thing');
|
|
|
|
expect(result).toContain('name: my-skill');
|
|
expect(result).toContain('description: Test command');
|
|
expect(result).toContain('argument-hint: "[file]"');
|
|
expect(result).toContain('allowed-tools:');
|
|
expect(result).toContain(' - Read');
|
|
expect(result).toContain(' - Grep');
|
|
expect(result).toContain('model: claude-sonnet-4-5');
|
|
expect(result).toContain('disable-model-invocation: true');
|
|
expect(result).toContain('user-invocable: false');
|
|
expect(result).toContain('context: fork');
|
|
expect(result).toContain('agent: code-reviewer');
|
|
expect(result).toContain('Do the thing');
|
|
});
|
|
|
|
it('omits undefined fields', () => {
|
|
const result = serializeSlashCommandMarkdown({
|
|
description: 'Minimal',
|
|
}, 'Prompt');
|
|
|
|
expect(result).toContain('description: Minimal');
|
|
expect(result).not.toContain('name');
|
|
expect(result).not.toContain('argument-hint');
|
|
expect(result).not.toContain('allowed-tools');
|
|
expect(result).not.toContain('model');
|
|
expect(result).not.toContain('disable-model-invocation');
|
|
expect(result).not.toContain('user-invocable');
|
|
expect(result).not.toContain('context');
|
|
expect(result).not.toContain('agent');
|
|
expect(result).not.toContain('hooks');
|
|
});
|
|
|
|
it('serializes hooks as JSON', () => {
|
|
const hooks = { PreToolUse: [{ matcher: 'Bash' }] };
|
|
const result = serializeSlashCommandMarkdown({ hooks }, 'Prompt');
|
|
expect(result).toContain(`hooks: ${JSON.stringify(hooks)}`);
|
|
});
|
|
|
|
it('produces valid frontmatter when no metadata exists', () => {
|
|
const result = serializeSlashCommandMarkdown({}, 'Just a prompt');
|
|
expect(result).toBe('---\n\n---\nJust a prompt');
|
|
});
|
|
|
|
it('round-trips through parse', () => {
|
|
const serialized = serializeSlashCommandMarkdown({
|
|
description: 'Round trip',
|
|
disableModelInvocation: true,
|
|
userInvocable: false,
|
|
context: 'fork',
|
|
agent: 'reviewer',
|
|
}, 'Body text');
|
|
|
|
const parsed = parseSlashCommandContent(serialized);
|
|
expect(parsed.description).toBe('Round trip');
|
|
expect(parsed.disableModelInvocation).toBe(true);
|
|
expect(parsed.userInvocable).toBe(false);
|
|
expect(parsed.context).toBe('fork');
|
|
expect(parsed.agent).toBe('reviewer');
|
|
expect(parsed.promptContent).toBe('Body text');
|
|
});
|
|
});
|
|
|
|
describe('validateCommandName', () => {
|
|
it('accepts valid lowercase names', () => {
|
|
expect(validateCommandName('my-command')).toBeNull();
|
|
expect(validateCommandName('test')).toBeNull();
|
|
expect(validateCommandName('a')).toBeNull();
|
|
expect(validateCommandName('abc123')).toBeNull();
|
|
expect(validateCommandName('my-cmd-2')).toBeNull();
|
|
expect(validateCommandName('a1b2c3')).toBeNull();
|
|
});
|
|
|
|
it('accepts name at exactly 64 characters', () => {
|
|
const name = 'a'.repeat(64);
|
|
expect(validateCommandName(name)).toBeNull();
|
|
});
|
|
|
|
it('rejects empty name', () => {
|
|
expect(validateCommandName('')).not.toBeNull();
|
|
});
|
|
|
|
it('rejects uppercase letters', () => {
|
|
expect(validateCommandName('MyCommand')).not.toBeNull();
|
|
expect(validateCommandName('TEST')).not.toBeNull();
|
|
});
|
|
|
|
it('rejects underscores', () => {
|
|
expect(validateCommandName('my_command')).not.toBeNull();
|
|
});
|
|
|
|
it('rejects slashes', () => {
|
|
expect(validateCommandName('my/command')).not.toBeNull();
|
|
});
|
|
|
|
it('rejects spaces', () => {
|
|
expect(validateCommandName('my command')).not.toBeNull();
|
|
});
|
|
|
|
it('rejects colons', () => {
|
|
expect(validateCommandName('my:command')).not.toBeNull();
|
|
});
|
|
|
|
it('rejects names exceeding 64 characters', () => {
|
|
const name = 'a'.repeat(65);
|
|
expect(validateCommandName(name)).not.toBeNull();
|
|
});
|
|
|
|
it('rejects special characters', () => {
|
|
expect(validateCommandName('cmd!@#')).not.toBeNull();
|
|
expect(validateCommandName('cmd.test')).not.toBeNull();
|
|
});
|
|
|
|
it.each(['true', 'false', 'null', 'yes', 'no', 'on', 'off'])(
|
|
'rejects YAML reserved word "%s"',
|
|
(word) => {
|
|
expect(validateCommandName(word)).not.toBeNull();
|
|
}
|
|
);
|
|
});
|
|
|
|
describe('extractFirstParagraph', () => {
|
|
it('returns the first paragraph from multi-paragraph content', () => {
|
|
expect(extractFirstParagraph('First paragraph.\n\nSecond paragraph.'))
|
|
.toBe('First paragraph.');
|
|
});
|
|
|
|
it('returns single-line content as-is', () => {
|
|
expect(extractFirstParagraph('Only one line')).toBe('Only one line');
|
|
});
|
|
|
|
it('collapses multi-line first paragraph into single line', () => {
|
|
expect(extractFirstParagraph('Line one\nline two\n\nSecond paragraph'))
|
|
.toBe('Line one line two');
|
|
});
|
|
|
|
it('returns undefined for empty content', () => {
|
|
expect(extractFirstParagraph('')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for whitespace-only content', () => {
|
|
expect(extractFirstParagraph(' \n \n ')).toBeUndefined();
|
|
});
|
|
|
|
it('skips leading blank lines', () => {
|
|
expect(extractFirstParagraph('\n\nActual first paragraph.\n\nSecond.'))
|
|
.toBe('Actual first paragraph.');
|
|
});
|
|
});
|