import { describe, it, expect } from 'vitest'; import { extractVueScript, extractTemplateComponents, extractScriptEmitCalls, extractComponentEventBindings, extractNativeElementEventHandlers, } from '../../src/core/ingestion/vue-sfc-extractor.js'; describe('extractVueScript', () => { it('extracts `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.isSetup).toBe(true); expect(result!.scriptContent).toContain("import { ref } from 'vue'"); expect(result!.scriptContent).toContain('const count = ref(0)'); // Line 0-3 is template + blank, line 4 is `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.isSetup).toBe(false); expect(result!.scriptContent).toContain('export default'); }); it('combines both blocks when `; const result = extractVueScript(vue); expect(result).not.toBeNull(); // F90: both blocks are combined expect(result!.isSetup).toBe(true); expect(result!.scriptContent).toContain("const name = ref('test')"); expect(result!.scriptContent).toContain('inheritAttrs'); }); it('returns lang from the script block', () => { const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.lang).toBe('js'); }); it('returns empty lang when no lang attribute', () => { const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.lang).toBe(''); }); it('jsx lang triggers JS grammar (maps to lang=js)', () => { const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.lang).toBe('js'); }); it('ts lang returns empty (only js/jsx triggers JS grammar)', () => { const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.lang).toBe(''); }); it('mixed js + ts blocks return empty lang (TypeScript wins)', () => { const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.lang).toBe(''); }); it('isSetup is true when at least one block is setup', () => { const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.isSetup).toBe(true); // ts blocks — lang should be empty expect(result!.lang).toBe(''); }); it('returns null for .vue files with no `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.scriptContent).toContain('NoLang'); expect(result!.isSetup).toBe(false); }); it('handles `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.isSetup).toBe(true); expect(result!.scriptContent).toContain('const x = 1'); }); it('computes correct lineOffset for script at top of file', () => { const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); // `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.isSetup).toBe(true); expect(result!.scriptContent).toContain("import { ref } from 'vue'"); }); }); describe('extractTemplateComponents', () => { it('finds PascalCase component tags', () => { const vue = ` `; const components = extractTemplateComponents(vue); expect(components).toContain('MyButton'); expect(components).toContain('AppHeader'); expect(components).not.toContain('div'); expect(components).not.toContain('span'); }); it('returns empty array when no template', () => { const vue = ` `; expect(extractTemplateComponents(vue)).toEqual([]); }); it('deduplicates repeated component usage', () => { const vue = ` `; const components = extractTemplateComponents(vue); expect(components.filter((c) => c === 'MyButton')).toHaveLength(1); }); it('ignores HTML elements and lowercase tags', () => { const vue = ` `; const components = extractTemplateComponents(vue); expect(components).toEqual(['MyComponent']); }); it('treats kebab-case component tags as component candidates', () => { const vue = ``; const components = extractTemplateComponents(vue); expect(components).toContain('PostList'); expect(components).toContain('UserCard'); }); }); describe('extractScriptEmitCalls', () => { it('extracts bare emit() event names', () => { const vue = ``; expect(extractScriptEmitCalls(vue).map((c) => c.eventName)).toEqual(['select']); }); it('ignores property emits and commented/string emit text', () => { const vue = ``; expect(extractScriptEmitCalls(vue).map((c) => c.eventName)).toEqual(['actual']); }); }); describe('extractComponentEventBindings', () => { it('captures kebab-case component event bindings', () => { const vue = ``; expect(extractComponentEventBindings(vue)).toEqual([ { componentName: 'PostList', eventName: 'select', handlerName: 'onPostSelected' }, ]); }); it('captures hyphenated event names (@user-loaded)', () => { const vue = ``; const bindings = extractComponentEventBindings(vue); expect(bindings).toContainEqual({ componentName: 'UserCard', eventName: 'user-loaded', handlerName: 'onUserLoaded', }); }); it('captures update:model-value style event names', () => { const vue = ``; const bindings = extractComponentEventBindings(vue); expect(bindings).toContainEqual({ componentName: 'MyInput', eventName: 'update:model-value', handlerName: 'onChange', }); }); }); describe('extractNativeElementEventHandlers', () => { it('captures handlers from native elements', () => { const vue = ``; const handlers = extractNativeElementEventHandlers(vue); expect(handlers).toContain('handleSave'); expect(handlers).toContain('onSubmit'); }); it('does not emit handlers for kebab-case component tags', () => { // is a Vue component, not a native element. // The NATIVE_TAG_RE negative lookahead must prevent matching `post` as a native tag. const vue = ``; const handlers = extractNativeElementEventHandlers(vue); expect(handlers).not.toContain('onSelect'); expect(handlers).toContain('handleClick'); }); }); describe('extractScriptEmitCalls — Options API this.$emit', () => { it('captures this.$emit() in Options API components', () => { const vue = ``; const events = extractScriptEmitCalls(vue).map((c) => c.eventName); expect(events).toContain('save'); expect(events).toContain('update:modelValue'); }); it('does NOT capture socket.emit() or eventBus.emit() as component events', () => { const vue = ``; const events = extractScriptEmitCalls(vue).map((c) => c.eventName); expect(events).toEqual(['actual']); expect(events).not.toContain('message'); expect(events).not.toContain('data'); }); it('captures update:modelValue style event names with colon', () => { const vue = ``; const events = extractScriptEmitCalls(vue).map((c) => c.eventName); expect(events).toContain('update:modelValue'); expect(events).toContain('user-loaded'); }); }); // --------------------------------------------------------------------------- // Case-insensitive script-tag matching (CodeQL js/bad-tag-filter, PR #1330) // --------------------------------------------------------------------------- describe('extractVueScript — case-insensitive script-tag matching', () => { it('extracts content from (uppercase)', () => { // HTML tag names are case-insensitive per the spec; browsers and // Vue's SFC parser accept any case. The extractor MUST mirror that // — a strict lowercase regex would miss valid SFC content and // re-open the CodeQL js/bad-tag-filter alert PR #1330 closed. const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.scriptContent).toContain("const greeting = 'hi'"); }); it('extracts content from mixed-case ', () => { const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.scriptContent).toContain("name: 'Mixed'"); }); it('handles whitespace AND uppercase together: ', () => { const vue = ` `; const result = extractVueScript(vue); expect(result).not.toBeNull(); expect(result!.scriptContent).toContain('const x = 1'); }); });