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 = `
Hello
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
expect(result!.scriptContent).toContain("const greeting = 'hi'");
});
it('extracts content from mixed-case ', () => {
const vue = `
Hello
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
expect(result!.scriptContent).toContain("name: 'Mixed'");
});
it('handles whitespace AND uppercase together: ', () => {
const vue = `
Hi
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
expect(result!.scriptContent).toContain('const x = 1');
});
});