import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { TextDocument } from 'vscode-languageserver-textdocument';
import {
scanI18nKeys,
scanRoutes,
scanStoreProperties,
scanCustomDirectives,
scanCustomDirectivesInDocuments,
scanTemplateVars,
setWorkspaceRoots,
getWorkspaceData,
invalidateCache,
} from '../../server/src/workspace-scanner';
// Helper to create temp directories with files
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'nojs-test-'));
}
function writeFile(dir: string, relativePath: string, content: string): void {
const fullPath = path.join(dir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf-8');
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
// Mock TextDocuments
function createMockDocuments(docs: { uri: string; content: string }[]) {
const textDocs = docs.map(d => TextDocument.create(d.uri, 'html', 1, d.content));
return {
get: (uri: string) => textDocs.find(d => d.uri === uri),
all: () => textDocs,
};
}
describe('WorkspaceScanner', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = createTempDir();
invalidateCache();
});
afterEach(() => {
cleanup(tmpDir);
});
describe('scanI18nKeys', () => {
it('scans flat locale JSON files', () => {
writeFile(tmpDir, 'locales/en.json', JSON.stringify({
greeting: 'Hello',
farewell: 'Goodbye',
}));
writeFile(tmpDir, 'locales/pt.json', JSON.stringify({
greeting: 'Olá',
farewell: 'Tchau',
}));
const keys = scanI18nKeys(tmpDir);
expect(keys.length).toBe(4);
const enGreeting = keys.find(k => k.key === 'greeting' && k.locale === 'en');
expect(enGreeting).toBeDefined();
expect(enGreeting!.value).toBe('Hello');
const ptGreeting = keys.find(k => k.key === 'greeting' && k.locale === 'pt');
expect(ptGreeting).toBeDefined();
expect(ptGreeting!.value).toBe('Olá');
});
it('scans namespaced locale JSON files', () => {
writeFile(tmpDir, 'locales/en/common.json', JSON.stringify({
save: 'Save',
cancel: 'Cancel',
}));
writeFile(tmpDir, 'locales/en/nav.json', JSON.stringify({
home: 'Home',
}));
const keys = scanI18nKeys(tmpDir);
expect(keys.length).toBe(3);
expect(keys.find(k => k.key === 'common.save')).toBeDefined();
expect(keys.find(k => k.key === 'nav.home')).toBeDefined();
});
it('flattens nested keys with dot notation', () => {
writeFile(tmpDir, 'locales/en.json', JSON.stringify({
nav: {
home: 'Home',
about: 'About',
},
footer: {
links: {
privacy: 'Privacy Policy',
},
},
}));
const keys = scanI18nKeys(tmpDir);
expect(keys.find(k => k.key === 'nav.home')).toBeDefined();
expect(keys.find(k => k.key === 'nav.about')).toBeDefined();
expect(keys.find(k => k.key === 'footer.links.privacy')).toBeDefined();
});
it('returns empty for missing locales directory', () => {
const keys = scanI18nKeys(tmpDir);
expect(keys).toEqual([]);
});
});
describe('scanRoutes', () => {
it('scans pages directory for routes', () => {
writeFile(tmpDir, 'pages/index.html', '
Home
');
writeFile(tmpDir, 'pages/about.html', 'About
');
writeFile(tmpDir, 'pages/contact.html', 'Contact
');
const routes = scanRoutes(tmpDir);
expect(routes.length).toBe(3);
expect(routes.find(r => r.path === '/')).toBeDefined();
expect(routes.find(r => r.path === '/about')).toBeDefined();
expect(routes.find(r => r.path === '/contact')).toBeDefined();
});
it('handles nested directories', () => {
writeFile(tmpDir, 'pages/index.html', 'Home
');
writeFile(tmpDir, 'pages/blog/index.html', 'Blog
');
writeFile(tmpDir, 'pages/blog/post.html', 'Post
');
const routes = scanRoutes(tmpDir);
expect(routes.find(r => r.path === '/')).toBeDefined();
expect(routes.find(r => r.path === '/blog')).toBeDefined();
expect(routes.find(r => r.path === '/blog/post')).toBeDefined();
});
it('returns empty for missing pages directory', () => {
const routes = scanRoutes(tmpDir);
expect(routes).toEqual([]);
});
});
describe('scanStoreProperties', () => {
it('extracts store names and properties from documents', () => {
const docs = createMockDocuments([{
uri: 'file:///test.html',
content: '',
}]);
const stores = scanStoreProperties(docs as any);
expect(stores.length).toBe(1);
expect(stores[0].storeName).toBe('user');
expect(stores[0].properties).toContain('name');
expect(stores[0].properties).toContain('role');
});
it('handles reversed attribute order', () => {
const docs = createMockDocuments([{
uri: 'file:///test.html',
content: '',
}]);
const stores = scanStoreProperties(docs as any);
expect(stores.length).toBe(1);
expect(stores[0].storeName).toBe('counter');
expect(stores[0].properties).toContain('count');
});
it('deduplicates stores across documents', () => {
const docs = createMockDocuments([
{ uri: 'file:///a.html', content: '' },
{ uri: 'file:///b.html', content: '' },
]);
const stores = scanStoreProperties(docs as any);
expect(stores.length).toBe(1);
});
it('extracts stores from NoJS.config({ stores: { ... } })', () => {
const docs = createMockDocuments([{
uri: 'file:///test.html',
content: '',
}]);
const stores = scanStoreProperties(docs as any);
expect(stores.length).toBe(2);
expect(stores.find(s => s.storeName === 'auth')).toBeDefined();
expect(stores.find(s => s.storeName === 'auth')!.properties).toContain('user');
expect(stores.find(s => s.storeName === 'auth')!.properties).toContain('token');
expect(stores.find(s => s.storeName === 'cart')).toBeDefined();
expect(stores.find(s => s.storeName === 'cart')!.properties).toContain('items');
expect(stores.find(s => s.storeName === 'cart')!.properties).toContain('total');
});
it('does not duplicate config stores already declared via store attribute', () => {
const docs = createMockDocuments([{
uri: 'file:///test.html',
content: '',
}]);
const stores = scanStoreProperties(docs as any);
expect(stores.length).toBe(1);
expect(stores[0].storeName).toBe('auth');
});
});
describe('scanCustomDirectives', () => {
it('finds NoJS.directive() calls in JS files', () => {
writeFile(tmpDir, 'app.js', `
NoJS.directive('tooltip', {
priority: 10,
init(el, name, value) {}
});
NoJS.directive("highlight", {
init(el) {}
});
`);
const directives = scanCustomDirectives(tmpDir);
expect(directives.length).toBe(2);
expect(directives.find(d => d.name === 'tooltip')).toBeDefined();
expect(directives.find(d => d.name === 'highlight')).toBeDefined();
});
it('skips node_modules', () => {
writeFile(tmpDir, 'node_modules/lib/index.js', `NoJS.directive('internal', {});`);
writeFile(tmpDir, 'app.js', `NoJS.directive('custom', {});`);
const directives = scanCustomDirectives(tmpDir);
expect(directives.length).toBe(1);
expect(directives[0].name).toBe('custom');
});
});
describe('scanCustomDirectivesInDocuments', () => {
it('finds directives in inline script blocks', () => {
const docs = createMockDocuments([{
uri: 'file:///test.html',
content: ``,
}]);
const directives = scanCustomDirectivesInDocuments(docs as any);
expect(directives.length).toBe(1);
expect(directives[0].name).toBe('autofocus');
});
});
describe('scanTemplateVars', () => {
it('extracts var from template declarations', () => {
const text = '';
const vars = scanTemplateVars(text);
expect(vars.length).toBe(1);
expect(vars[0].templateId).toBe('card');
expect(vars[0].varNames).toContain('item');
});
it('extracts var-* from use elements referencing a template', () => {
const text = `
`;
const vars = scanTemplateVars(text);
expect(vars.length).toBe(1);
expect(vars[0].templateId).toBe('userCard');
expect(vars[0].varNames).toContain('user');
expect(vars[0].varNames).toContain('name');
expect(vars[0].varNames).toContain('role');
});
it('returns empty for templates without vars', () => {
const text = 'Hello
';
const vars = scanTemplateVars(text);
expect(vars.length).toBe(0);
});
});
describe('getWorkspaceData (cache)', () => {
it('returns cached data on subsequent calls', () => {
writeFile(tmpDir, 'locales/en.json', JSON.stringify({ hello: 'Hello' }));
setWorkspaceRoots([tmpDir]);
const docs = createMockDocuments([]);
const data1 = getWorkspaceData(docs as any);
const data2 = getWorkspaceData(docs as any);
expect(data1).toBe(data2); // same object reference = cached
});
it('invalidates cache when requested', () => {
writeFile(tmpDir, 'locales/en.json', JSON.stringify({ hello: 'Hello' }));
setWorkspaceRoots([tmpDir]);
const docs = createMockDocuments([]);
const data1 = getWorkspaceData(docs as any);
invalidateCache();
const data2 = getWorkspaceData(docs as any);
expect(data1).not.toBe(data2); // different object = re-scanned
expect(data2.i18nKeys.length).toBe(1);
});
});
});