Skip to content
Open
23 changes: 22 additions & 1 deletion packages/lightning-lsp-common/src/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,27 @@ it('isLWCJavascript()', async () => {
expect(await context.isLWCJavascript(document)).toBeTruthy();
});

it('isLWCTypeScript()', async () => {
// workspace root project is ui-global-components
const context = new WorkspaceContext(CORE_PROJECT_ROOT);

// lwc .ts
let document = readAsTextDocument(CORE_PROJECT_ROOT + '/modules/one/app-nav-bar/app-nav-bar.ts');
expect(await context.isLWCTypeScript(document)).toBeTruthy();

// lwc .ts outside workspace root in ui-force-components
document = readAsTextDocument(CORE_ALL_ROOT + '/ui-force-components/modules/force/input-phone/input-phone.ts');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand why this is outside of the namespace root.

Copy link
Contributor Author

@rui-rayqiu rui-rayqiu Feb 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to test that if a ts file that is outside of the VSCode workspace is modified, we do not update tsconfig.json. Here the context workspace root is CORE_PROJECT_ROOT which is ui-global-components so if a file in ui-force-components is modified the return value of isLWCTypeScript should be false. I'll add an inline comment to explain this.

expect(await context.isLWCTypeScript(document)).toBeFalsy();

// lwc .html
document = readAsTextDocument(CORE_PROJECT_ROOT + '/modules/one/app-nav-bar/app-nav-bar.html');
expect(await context.isLWCTypeScript(document)).toBeFalsy();

// lwc .js
document = readAsTextDocument(CORE_PROJECT_ROOT + '/modules/one/app-nav-bar/app-nav-bar.js');
expect(await context.isLWCTypeScript(document)).toBeFalsy();
});

it('configureSfdxProject()', async () => {
const context = new WorkspaceContext('test-workspaces/sfdx-workspace');
const jsconfigPathForceApp = FORCE_APP_ROOT + '/lwc/jsconfig.json';
Expand Down Expand Up @@ -298,7 +319,7 @@ it('configureCoreMulti()', async () => {
// verify newly created jsconfig.json
verifyJsconfigCore(jsconfigPathGlobal);
// verify jsconfig.json is not created when there is a tsconfig.json
expect(fs.existsSync(tsconfigPathForce)).not.toExist();
expect(fs.existsSync(jsconfigPathForce)).not.toExist();
verifyTypingsCore();

fs.removeSync(tsconfigPathForce);
Expand Down
2 changes: 2 additions & 0 deletions packages/lightning-lsp-common/src/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ function languageId(path: string): string {
switch (suffix.substring(1)) {
case 'js':
return 'javascript';
case 'ts':
return 'typescript';
case 'html':
return 'html';
case 'app':
Expand Down
13 changes: 11 additions & 2 deletions packages/lightning-lsp-common/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ async function findNamespaceRoots(root: string, maxDepth = 5): Promise<{ lwc: st
for (const subdir of subdirs) {
// Is a root if any subdir matches a name/name.js with name.js being a module
const basename = path.basename(subdir);
const modulePath = path.join(subdir, basename + '.js');
if (fs.existsSync(modulePath)) {
const modulePathJs = path.join(subdir, basename + '.js');
const modulePathTs = path.join(subdir, basename + '.ts');
if (fs.existsSync(modulePathJs) || fs.existsSync(modulePathTs)) {
// TODO: check contents for: from 'lwc'?
return true;
}
Expand Down Expand Up @@ -257,6 +258,14 @@ export class WorkspaceContext {
return document.languageId === 'javascript' && (await this.isInsideModulesRoots(document));
}

public async isLWCTypeScript(document: TextDocument): Promise<boolean> {
return (
(this.type === WorkspaceType.CORE_ALL || this.type === WorkspaceType.CORE_PARTIAL) &&
document.languageId === 'typescript' &&
(await this.isInsideModulesRoots(document))
);
}

public async isInsideAuraRoots(document: TextDocument): Promise<boolean> {
const file = utils.toResolvedPath(document.uri);
for (const ws of this.workspaceRoots) {
Expand Down
2 changes: 2 additions & 0 deletions packages/lwc-language-server/src/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ function languageId(path: string): string {
switch (suffix.substring(1)) {
case 'js':
return 'javascript';
case 'ts':
return 'typescript';
case 'html':
return 'html';
case 'app':
Expand Down
280 changes: 280 additions & 0 deletions packages/lwc-language-server/src/__tests__/typescript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import { shared } from '@salesforce/lightning-lsp-common';
import { readAsTextDocument } from './test-utils';
import TSConfigPathIndexer from '../typescript/tsconfig-path-indexer';
import { collectImportsForDocument } from '../typescript/imports';
import { TextDocument } from 'vscode-languageserver-textdocument';

const { WorkspaceType } = shared;
const TEST_WORKSPACE_PARENT_DIR = path.resolve('../..');
const CORE_ROOT = path.resolve(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'core-like-workspace', 'coreTS', 'core');

const tsConfigForce = path.resolve(CORE_ROOT, 'ui-force-components', 'tsconfig.json');
const tsConfigGlobal = path.resolve(CORE_ROOT, 'ui-global-components', 'tsconfig.json');

function readTSConfigFile(tsconfigPath: string): object {
if (!fs.pathExistsSync(tsconfigPath)) {
return null;
}
return JSON.parse(fs.readFileSync(tsconfigPath, 'utf8'));
}

function restoreTSConfigFiles(): void {
const tsconfig = {
extends: '../tsconfig.json',
compilerOptions: {
paths: {},
},
};
const tsconfigPaths = [tsConfigForce, tsConfigGlobal];
for (const tsconfigPath of tsconfigPaths) {
fs.writeJSONSync(tsconfigPath, tsconfig, {
spaces: 4,
});
}
}

function createTextDocumentFromString(content: string, uri?: string): TextDocument {
return TextDocument.create(uri ? uri : 'mockUri', 'typescript', 0, content);
}

beforeEach(async () => {
restoreTSConfigFiles();
});

afterEach(() => {
jest.restoreAllMocks();
restoreTSConfigFiles();
});

describe('TSConfigPathIndexer', () => {
describe('new', () => {
it('initializes with the root of a core root dir', () => {
const expectedPath: string = path.resolve('../../test-workspaces/core-like-workspace/coreTS/core');
const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]);
expect(tsconfigPathIndexer.coreModulesWithTSConfig.length).toEqual(2);
expect(tsconfigPathIndexer.coreModulesWithTSConfig[0]).toEqual(path.resolve(expectedPath, 'ui-force-components'));
expect(tsconfigPathIndexer.coreModulesWithTSConfig[1]).toEqual(path.resolve(expectedPath, 'ui-global-components'));
expect(tsconfigPathIndexer.workspaceType).toEqual(WorkspaceType.CORE_ALL);
expect(tsconfigPathIndexer.coreRoot).toEqual(expectedPath);
});

it('initializes with the root of a core project dir', () => {
const expectedPath: string = path.resolve('../../test-workspaces/core-like-workspace/coreTS/core');
const tsconfigPathIndexer = new TSConfigPathIndexer([path.resolve(CORE_ROOT, 'ui-force-components')]);
expect(tsconfigPathIndexer.coreModulesWithTSConfig.length).toEqual(1);
expect(tsconfigPathIndexer.coreModulesWithTSConfig[0]).toEqual(path.resolve(expectedPath, 'ui-force-components'));
expect(tsconfigPathIndexer.workspaceType).toEqual(WorkspaceType.CORE_PARTIAL);
expect(tsconfigPathIndexer.coreRoot).toEqual(expectedPath);
});
});

describe('instance methods', () => {
describe('init', () => {
it('no-op on sfdx workspace root', async () => {
const tsconfigPathIndexer = new TSConfigPathIndexer([path.resolve(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]);
const spy = jest.spyOn(tsconfigPathIndexer, 'componentEntries', 'get');
await tsconfigPathIndexer.init();
expect(spy).not.toHaveBeenCalled();
expect(tsconfigPathIndexer.coreRoot).toBeUndefined();
});

it('generates paths mappings for all modules on core', async () => {
const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]);
await tsconfigPathIndexer.init();
const tsConfigForceObj = readTSConfigFile(tsConfigForce);
expect(tsConfigForceObj).toEqual({
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'],
'force/input-phone': ['./modules/force/input-phone/input-phone'],
},
},
});
const tsConfigGlobalObj = readTSConfigFile(tsConfigGlobal);
expect(tsConfigGlobalObj).toEqual({
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'one/app-nav-bar': ['./modules/one/app-nav-bar/app-nav-bar'],
},
},
});
});

it('removes paths mapping for deleted module on core', async () => {
const oldTSConfig = {
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'force/deleted': ['./modules/force/deleted/deleted'],
'one/deleted': ['../ui-global-components/modules/one/deleted/deleted'],
},
},
};
fs.writeJSONSync(tsConfigForce, oldTSConfig, {
spaces: 4,
});
const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]);
await tsconfigPathIndexer.init();
const tsConfigForceObj = readTSConfigFile(tsConfigForce);
expect(tsConfigForceObj).toEqual({
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'],
'force/input-phone': ['./modules/force/input-phone/input-phone'],
},
},
});
});

it('keep existing path mapping for any js cmp', async () => {
const oldTSConfig = {
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'force/input-phone-js': ['./modules/force/input-phone-js/input-phone-js'],
},
},
};
fs.writeJSONSync(tsConfigForce, oldTSConfig, {
spaces: 4,
});
const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]);
await tsconfigPathIndexer.init();
const tsConfigForceObj = readTSConfigFile(tsConfigForce);
expect(tsConfigForceObj).toEqual({
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'],
'force/input-phone': ['./modules/force/input-phone/input-phone'],
'force/input-phone-js': ['./modules/force/input-phone-js/input-phone-js'],
},
},
});
});

it('update existing path mapping for cross-namespace cmp', async () => {
const oldTSConfig = {
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'one/app-nav-bar': ['../ui-global-components/modules/one/deletedOldPath/deletedOldPath'],
},
},
};
fs.writeJSONSync(tsConfigForce, oldTSConfig, {
spaces: 4,
});
const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]);
await tsconfigPathIndexer.init();
const tsConfigForceObj = readTSConfigFile(tsConfigForce);
expect(tsConfigForceObj).toEqual({
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'],
'force/input-phone': ['./modules/force/input-phone/input-phone'],
'one/app-nav-bar': ['../ui-global-components/modules/one/app-nav-bar/app-nav-bar'],
},
},
});
});
});

describe('updateTSConfigFileForDocument', () => {
it('no-op on sfdx workspace root', async () => {
const tsconfigPathIndexer = new TSConfigPathIndexer([path.resolve(TEST_WORKSPACE_PARENT_DIR, 'test-workspaces', 'sfdx-workspace')]);
await tsconfigPathIndexer.init();
const filePath = path.resolve(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts');
const spy = jest.spyOn(tsconfigPathIndexer as any, 'addNewPathMapping');
await tsconfigPathIndexer.updateTSConfigFileForDocument(readAsTextDocument(filePath));
expect(spy).not.toHaveBeenCalled();
expect(tsconfigPathIndexer.coreRoot).toBeUndefined();
});

it('updates tsconfig for all imports', async () => {
const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]);
await tsconfigPathIndexer.init();
const filePath = path.resolve(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts');
await tsconfigPathIndexer.updateTSConfigFileForDocument(readAsTextDocument(filePath));
const tsConfigForceObj = readTSConfigFile(tsConfigForce);
expect(tsConfigForceObj).toEqual({
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'one/app-nav-bar': ['../ui-global-components/modules/one/app-nav-bar/app-nav-bar'],
'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'],
'force/input-phone': ['./modules/force/input-phone/input-phone'],
},
},
});
});

it('do not update tsconfig for import that is not found', async () => {
const tsconfigPathIndexer = new TSConfigPathIndexer([CORE_ROOT]);
await tsconfigPathIndexer.init();
const fileContent = `
import { util } from 'ns/notFound';
`;
const filePath = path.resolve(CORE_ROOT, 'ui-force-components', 'modules', 'force', 'input-phone', 'input-phone.ts');
await tsconfigPathIndexer.updateTSConfigFileForDocument(createTextDocumentFromString(fileContent, filePath));
const tsConfigForceObj = readTSConfigFile(tsConfigForce);
expect(tsConfigForceObj).toEqual({
extends: '../tsconfig.json',
compilerOptions: {
paths: {
'clients/context-library-lwc': ['./modules/clients/context-library-lwc/context-library-lwc'],
'force/input-phone': ['./modules/force/input-phone/input-phone'],
},
},
});
});
});
});
});

describe('imports', () => {
describe('collectImportsForDocument', () => {
it('should exclude special imports', async () => {
const document = createTextDocumentFromString(`
import {api} from 'lwc';
import {obj1} from './abc';
import {obj2} from '../xyz';
import {obj3} from 'lightning/confirm';
import {obj4} from '@salesforce/label/x';
import {obj5} from 'x.html';
import {obj6} from 'y.css';
import {obj7} from 'namespace/cmpName';
`);
const imports = await collectImportsForDocument(document);
expect(imports.size).toEqual(1);
Copy link

@ravijayaramappa ravijayaramappa Feb 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "lightning/confirm" excluded because it is an off-core component? Saw your comment below about special cases.

expect(imports.has('namespace/cmpName'));
});

it('should work for partial file content', async () => {
const document = createTextDocumentFromString(`
import from
`);
const imports = await collectImportsForDocument(document);
expect(imports.size).toEqual(0);
});

it('dynamic imports', async () => {
const document = createTextDocumentFromString(`
const {
default: myDefault,
foo,
bar,
} = await import("force/wireUtils");
`);
const imports = await collectImportsForDocument(document);
expect(imports.size).toEqual(1);
expect(imports.has('force/wireUtils'));
});
});
});
Loading