diff --git a/packages/ts-plugin/e2e-test/feature/code-fix.test.ts b/packages/ts-plugin/e2e-test/feature/code-fix.test.ts index 8f31b64c..709dbbb2 100644 --- a/packages/ts-plugin/e2e-test/feature/code-fix.test.ts +++ b/packages/ts-plugin/e2e-test/feature/code-fix.test.ts @@ -5,275 +5,296 @@ import { CANNOT_FIND_NAME_ERROR_CODE, PROPERTY_DOES_NOT_EXIST_ERROR_CODES, } from '../../src/language-service/feature/code-fix.js'; -import { createIFF } from '../test-util/fixture.js'; +import { buildStylesImport, buildTSConfigJSON } from '../../src/test/builder.js'; +import { setupFixture } from '../test-util/fixture.js'; import { formatPath, launchTsserver, normalizeCodeFixActions } from '../test-util/tsserver.js'; -test.each([ - { - namedExports: false, - importStatement: "import styles from './a.module.css';", - }, - { - namedExports: true, - importStatement: "import * as styles from './a.module.css';", - }, -])( - 'fixMissingCSSRule inserts a new CSS rule for a missing class property (namedExports: $namedExports)', - async ({ namedExports, importStatement }) => { - const tsserver = launchTsserver(); - const iff = await createIFF({ - 'a.tsx': dedent` - ${importStatement} - import bStyles from './b.module.css'; - styles.a_1; - bStyles.b_2; - `, - 'a.module.css': '', - 'b.module.css': dedent` - .b_1 { - color: red; - } - `, - 'tsconfig.json': dedent` - { - "cmkOptions": { - "enabled": true, - "namedExports": ${namedExports} - } - } - `, - }); - await tsserver.sendUpdateOpen({ - openFiles: [{ file: iff.paths['tsconfig.json'] }], - }); +const tsserver = launchTsserver(); - const res1 = await tsserver.sendGetCodeFixes({ - errorCodes: [PROPERTY_DOES_NOT_EXIST_ERROR_CODES[0]], - file: iff.paths['a.tsx'], - startLine: 3, - startOffset: 11, - endLine: 3, - endOffset: 11, - }); - expect(normalizeCodeFixActions(res1.body!)).toStrictEqual( - normalizeCodeFixActions([ - { - fixName: 'fixMissingCSSRule', - changes: [ - { - fileName: formatPath(iff.paths['a.module.css']), - textChanges: [{ start: { line: 1, offset: 1 }, end: { line: 1, offset: 1 }, newText: '\n.a_1 {\n \n}' }], - }, - ], - }, - ]), - ); +describe.each([{ namedExports: false }, { namedExports: true }])('namedExports: $namedExports', ({ namedExports }) => { + describe('fixMissingCSSRule', () => { + test.each([ + { errorCode: PROPERTY_DOES_NOT_EXIST_ERROR_CODES[0] }, + { errorCode: PROPERTY_DOES_NOT_EXIST_ERROR_CODES[1] }, + ])('inserts a new CSS rule into an empty CSS module for diagnostic $errorCode', async ({ errorCode }) => { + const { iff, getLoc } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ cmkOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_1; + `, + 'a.module.css': '', + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); - const res2 = await tsserver.sendGetCodeFixes({ - errorCodes: [PROPERTY_DOES_NOT_EXIST_ERROR_CODES[1]], - file: iff.paths['a.tsx'], - startLine: 3, - startOffset: 11, - endLine: 3, - endOffset: 11, + const loc = getLoc('index.ts', 'a_1'); + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [errorCode], + file: iff.paths['index.ts'], + startLine: loc.line, + startOffset: loc.offset, + endLine: loc.line, + endOffset: loc.offset, + }); + + expect(normalizeCodeFixActions(res.body!)).toStrictEqual( + normalizeCodeFixActions([ + { + fixName: 'fixMissingCSSRule', + changes: [ + { + fileName: formatPath(iff.paths['a.module.css']), + textChanges: [ + { start: { line: 1, offset: 1 }, end: { line: 1, offset: 1 }, newText: '\n.a_1 {\n \n}' }, + ], + }, + ], + }, + ]), + ); }); - expect(normalizeCodeFixActions(res2.body!)).toStrictEqual( - normalizeCodeFixActions([ - { - fixName: 'fixMissingCSSRule', - changes: [ - { - fileName: formatPath(iff.paths['a.module.css']), - textChanges: [{ start: { line: 1, offset: 1 }, end: { line: 1, offset: 1 }, newText: '\n.a_1 {\n \n}' }], - }, - ], - }, - ]), - ); - const res3 = await tsserver.sendGetCodeFixes({ - errorCodes: [PROPERTY_DOES_NOT_EXIST_ERROR_CODES[0]], - file: iff.paths['a.tsx'], - startLine: 4, - startOffset: 12, - endLine: 4, - endOffset: 12, + test('appends a new CSS rule to a non-empty CSS module', async () => { + const { iff, getLoc } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ cmkOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + styles.a_2; + `, + 'a.module.css': dedent` + .a_1 { + color: red; + } + `, + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + + const loc = getLoc('index.ts', 'a_2'); + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [PROPERTY_DOES_NOT_EXIST_ERROR_CODES[0]], + file: iff.paths['index.ts'], + startLine: loc.line, + startOffset: loc.offset, + endLine: loc.line, + endOffset: loc.offset, + }); + + expect(normalizeCodeFixActions(res.body!)).toStrictEqual( + normalizeCodeFixActions([ + { + fixName: 'fixMissingCSSRule', + changes: [ + { + fileName: formatPath(iff.paths['a.module.css']), + textChanges: [ + { start: { line: 3, offset: 2 }, end: { line: 3, offset: 2 }, newText: '\n.a_2 {\n \n}' }, + ], + }, + ], + }, + ]), + ); }); - expect(normalizeCodeFixActions(res3.body!)).toStrictEqual( - normalizeCodeFixActions([ - { - fixName: 'fixMissingCSSRule', - changes: [ - { - fileName: formatPath(iff.paths['b.module.css']), - textChanges: [{ start: { line: 3, offset: 2 }, end: { line: 3, offset: 2 }, newText: '\n.b_2 {\n \n}' }], - }, - ], - }, - ]), - ); - }, -); -test.each([ - { - name: 'auto-import inserts default import statement if namedExports is false', - namedExports: false, - importStatement: `import styles from "./a.module.css";`, - }, - { - name: 'auto-import inserts namespace import statement if namedExports is true', - namedExports: true, - importStatement: `import * as styles from "./a.module.css";`, - }, -])('$name', async ({ namedExports, importStatement }) => { - const tsserver = launchTsserver(); - const iff = await createIFF({ - 'index.ts': dedent` - styles; - `, - 'a.module.css': '', - 'tsconfig.json': dedent` - { - "cmkOptions": { - "enabled": true, - "namedExports": ${namedExports} - } - } - `, - }); - await tsserver.sendUpdateOpen({ - openFiles: [{ file: iff.paths['tsconfig.json'] }], - }); - const res = await tsserver.sendGetCodeFixes({ - errorCodes: [CANNOT_FIND_NAME_ERROR_CODE], - file: iff.paths['index.ts'], - startLine: 1, - startOffset: 1, - endLine: 1, - endOffset: 7, + test('inserts the rule into the CSS module bound to the accessed identifier', async () => { + const { iff, getLoc } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ cmkOptions: { namedExports } }), + 'index.ts': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + ${buildStylesImport('./b.module.css', { namedExports, name: 'bStyles' })} + bStyles.b_1; + `, + 'a.module.css': '', + 'b.module.css': '', + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + + const loc = getLoc('index.ts', 'b_1'); + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [PROPERTY_DOES_NOT_EXIST_ERROR_CODES[0]], + file: iff.paths['index.ts'], + startLine: loc.line, + startOffset: loc.offset, + endLine: loc.line, + endOffset: loc.offset, + }); + + expect(normalizeCodeFixActions(res.body!)).toStrictEqual( + normalizeCodeFixActions([ + { + fixName: 'fixMissingCSSRule', + changes: [ + { + fileName: formatPath(iff.paths['b.module.css']), + textChanges: [ + { start: { line: 1, offset: 1 }, end: { line: 1, offset: 1 }, newText: '\n.b_1 {\n \n}' }, + ], + }, + ], + }, + ]), + ); + }); }); - expect(normalizeCodeFixActions(res.body!)).toStrictEqual( - normalizeCodeFixActions([ - { - fixName: 'import', - changes: [ + + describe('auto-import', () => { + test('inserts the import statement when accepted', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ cmkOptions: { namedExports } }), + 'index.ts': `styles;`, + 'a.module.css': '', + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + + const range = getRange('index.ts', 'styles'); + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [CANNOT_FIND_NAME_ERROR_CODE], + file: iff.paths['index.ts'], + startLine: range.start.line, + startOffset: range.start.offset, + endLine: range.end.line, + endOffset: range.end.offset, + }); + + const importStatement = buildStylesImport('./a.module.css', { namedExports, quote: 'double' }); + expect(normalizeCodeFixActions(res.body!)).toStrictEqual( + normalizeCodeFixActions([ { - fileName: formatPath(iff.paths['index.ts']), - textChanges: [ + fixName: 'import', + changes: [ { - start: { line: 1, offset: 1 }, - end: { line: 1, offset: 1 }, - newText: `${importStatement}${ts.sys.newLine}${ts.sys.newLine}`, + fileName: formatPath(iff.paths['index.ts']), + textChanges: [ + { + start: { line: 1, offset: 1 }, + end: { line: 1, offset: 1 }, + newText: `${importStatement}${ts.sys.newLine}${ts.sys.newLine}`, + }, + ], }, ], }, - ], - }, - ]), - ); -}); + ]), + ); + }); -test('auto-import excludes generated files from suggestions', async () => { - const tsserver = launchTsserver(); - const iff = await createIFF({ - 'index.ts': dedent` - styles; - `, - 'generated/a.module.css.d.ts': dedent` - const styles: {}; - export default styles; - `, - 'tsconfig.json': dedent` - { - "cmkOptions": { - "enabled": true, - "dtsOutDir": "generated" - } - } - `, - }); - await tsserver.sendUpdateOpen({ - openFiles: [{ file: iff.paths['tsconfig.json'] }], - }); - const res = await tsserver.sendGetCodeFixes({ - errorCodes: [CANNOT_FIND_NAME_ERROR_CODE], - file: iff.paths['index.ts'], - startLine: 1, - startOffset: 1, - endLine: 1, - endOffset: 7, + test('excludes generated files from suggestions', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ + cmkOptions: { namedExports, dtsOutDir: 'generated' }, + }), + 'index.ts': `styles;`, + 'generated/a.module.css.d.ts': dedent` + const styles: {}; + export default styles; + `, + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + + const range = getRange('index.ts', 'styles'); + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [CANNOT_FIND_NAME_ERROR_CODE], + file: iff.paths['index.ts'], + startLine: range.start.line, + startOffset: range.start.offset, + endLine: range.end.line, + endOffset: range.end.offset, + }); + + expect(normalizeCodeFixActions(res.body!)).toStrictEqual([]); + }); }); - expect(normalizeCodeFixActions(res.body!)).toStrictEqual([]); }); -describe('auto-import suggests named exports instead of namespace import when prioritizeNamedImports is true', async () => { - const tsserver = launchTsserver(); - const iff = await createIFF({ - 'index.ts': dedent` - styles; - a_1; - `, - 'a.module.css': dedent` - .a_1 { color: red; } - `, - 'tsconfig.json': dedent` - { - "cmkOptions": { - "enabled": true, - "namedExports": true, - "prioritizeNamedImports": true - } - } - `, - }); - await tsserver.sendUpdateOpen({ - openFiles: [{ file: iff.paths['tsconfig.json'] }], +describe('named import code fix (namedExports: true)', () => { + describe('prioritizeNamedImports: false', () => { + test('omits the named import code fix', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ + cmkOptions: { namedExports: true, prioritizeNamedImports: false }, + }), + 'index.ts': `a_1;`, + 'a.module.css': `.a_1 { color: red; }`, + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + + const range = getRange('index.ts', 'a_1'); + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [CANNOT_FIND_NAME_ERROR_CODE], + file: iff.paths['index.ts'], + startLine: range.start.line, + startOffset: range.start.offset, + endLine: range.end.line, + endOffset: range.end.offset, + }); + + expect(normalizeCodeFixActions(res.body!)).toStrictEqual([]); + }); }); - test.each([ - { - name: 'styles', - file: iff.paths['index.ts'], - startLine: 1, - startOffset: 1, - endLine: 1, - endOffset: 7, - expected: [], - }, - { - name: 'a_1', - file: iff.paths['index.ts'], - startLine: 2, - startOffset: 1, - endLine: 2, - endOffset: 4, - expected: [ - { - fixName: 'import', - changes: [ - { - fileName: formatPath(iff.paths['index.ts']), - textChanges: [ - { - start: { line: 1, offset: 1 }, - end: { line: 1, offset: 1 }, - newText: `import { a_1 } from "./a.module.css";${ts.sys.newLine}${ts.sys.newLine}`, - }, - ], - }, - ], - }, - ], - }, - ])('$name', async ({ file, startLine, startOffset, endLine, endOffset, expected }) => { - const res = await tsserver.sendGetCodeFixes({ - errorCodes: [2304], - file, - startLine, - startOffset, - endLine, - endOffset, + + describe('prioritizeNamedImports: true', () => { + test('omits the default styles binding code fix', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ + cmkOptions: { namedExports: true, prioritizeNamedImports: true }, + }), + 'index.ts': `styles;`, + 'a.module.css': `.a_1 { color: red; }`, + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + + const range = getRange('index.ts', 'styles'); + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [CANNOT_FIND_NAME_ERROR_CODE], + file: iff.paths['index.ts'], + startLine: range.start.line, + startOffset: range.start.offset, + endLine: range.end.line, + endOffset: range.end.offset, + }); + + expect(normalizeCodeFixActions(res.body!)).toStrictEqual([]); + }); + + test('suggests a named import code fix', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ + cmkOptions: { namedExports: true, prioritizeNamedImports: true }, + }), + 'index.ts': `a_1;`, + 'a.module.css': `.a_1 { color: red; }`, + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + + const range = getRange('index.ts', 'a_1'); + const res = await tsserver.sendGetCodeFixes({ + errorCodes: [CANNOT_FIND_NAME_ERROR_CODE], + file: iff.paths['index.ts'], + startLine: range.start.line, + startOffset: range.start.offset, + endLine: range.end.line, + endOffset: range.end.offset, + }); + + expect(normalizeCodeFixActions(res.body!)).toStrictEqual( + normalizeCodeFixActions([ + { + fixName: 'import', + changes: [ + { + fileName: formatPath(iff.paths['index.ts']), + textChanges: [ + { + start: { line: 1, offset: 1 }, + end: { line: 1, offset: 1 }, + newText: `import { a_1 } from "./a.module.css";${ts.sys.newLine}${ts.sys.newLine}`, + }, + ], + }, + ], + }, + ]), + ); }); - expect(normalizeCodeFixActions(res.body!)).toStrictEqual(normalizeCodeFixActions(expected)); }); }); diff --git a/packages/ts-plugin/e2e-test/feature/completion.test.ts b/packages/ts-plugin/e2e-test/feature/completion.test.ts index e9941a9b..c0ed3e3a 100644 --- a/packages/ts-plugin/e2e-test/feature/completion.test.ts +++ b/packages/ts-plugin/e2e-test/feature/completion.test.ts @@ -2,225 +2,237 @@ import { join } from '@css-modules-kit/core'; import dedent from 'dedent'; import ts from 'typescript'; import { describe, expect, test } from 'vite-plus/test'; -import { createIFF } from '../test-util/fixture.js'; +import { buildStylesImport, buildTSConfigJSON } from '../../src/test/builder.js'; +import { setupFixture } from '../test-util/fixture.js'; import { launchTsserver, normalizeCompletionDetails, normalizeCompletionEntry } from '../test-util/tsserver.js'; const reactDtsPath = join(require.resolve('@types/react/package.json'), '../index.d.ts'); +const tsserver = launchTsserver(); -describe('Completion', async () => { - const tsserver = launchTsserver(); - const iff = await createIFF({ - 'a.tsx': dedent` - styles; - const jsx =
; - `, - 'b.tsx': dedent` - import styles from './b.module.css'; - const jsx = ; - `, - 'a.module.css': '', - 'b.module.css': '', - // Generated files should be excluded from import statement suggestions - 'generated/generated.module.css.d.ts': dedent` - const styles: {}; - export default styles; - `, - 'tsconfig.json': dedent` - { - "compilerOptions": { - "jsx": "react-jsx", - "types": ["${reactDtsPath}"] +describe.each([{ namedExports: false }, { namedExports: true }])('namedExports: $namedExports', ({ namedExports }) => { + describe('styles binding suggestion', () => { + test('prioritizes the CSS module corresponding to the current component file', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ cmkOptions: { namedExports } }), + 'a.tsx': `styles;`, + 'a.module.css': '', + 'b.module.css': '', + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['a.tsx'] }] }); + await tsserver.sendConfigure({ + preferences: { + includeCompletionsForModuleExports: true, + quotePreference: 'single', }, - "cmkOptions": { - "enabled": true, - "dtsOutDir": "generated" - } - } - `, - }); - await tsserver.sendUpdateOpen({ - openFiles: [{ file: iff.paths['tsconfig.json'] }], - }); - await tsserver.sendConfigure({ - preferences: { - includeCompletionsForModuleExports: true, - includeCompletionsWithSnippetText: true, - includeCompletionsWithInsertText: true, - jsxAttributeCompletionStyle: 'auto', - quotePreference: 'single', - }, - }); - test.each([ - { - name: 'styles', - entryName: 'styles', - file: iff.paths['a.tsx'], - line: 1, - offset: 7, - expected: [ - { name: 'styles', sortText: '0', source: './a.module.css' }, - { name: 'styles', sortText: '16', source: './b.module.css' }, - ], - }, - { - name: "className with `quotePreference: 'double'`", - entryName: 'className', - quotePreference: 'double' as const, - file: iff.paths['a.tsx'], - line: 2, - offset: 27, - expected: [{ name: 'className', insertText: 'className={$1}', sortText: expect.anything() }], - }, - { - name: "className with `quotePreference: 'single'`", - entryName: 'className', - quotePreference: 'single' as const, - file: iff.paths['b.tsx'], - line: 2, - offset: 27, - expected: [{ name: 'className', insertText: 'className={$1}', sortText: expect.anything() }], - }, - ])('Completions for $name', async ({ entryName, quotePreference, file, line, offset, expected }) => { - await tsserver.sendConfigure({ - preferences: { - quotePreference: quotePreference ?? 'auto', - }, - }); - const res = await tsserver.sendCompletionInfo({ - file, - line, - offset, + }); + + const res = await tsserver.sendCompletionInfo({ + file: iff.paths['a.tsx'], + ...getRange('a.tsx', 'styles').end, + }); + + expect( + normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === 'styles') ?? []), + ).toStrictEqual( + normalizeCompletionEntry([ + { name: 'styles', sortText: '0', source: './a.module.css' }, + { name: 'styles', sortText: '16', source: './b.module.css' }, + ]), + ); }); - expect(normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === entryName) ?? [])).toStrictEqual( - normalizeCompletionEntry(expected), - ); - }); -}); -test.each([ - { - name: 'auto-import inserts default import statement if namedExports is false', - namedExports: false, - importStatement: `import styles from './a.module.css';`, - }, - { - name: 'auto-import inserts namespace import statement if namedExports is true', - namedExports: true, - importStatement: `import * as styles from './a.module.css';`, - }, -])('$name', async ({ namedExports, importStatement }) => { - const tsserver = launchTsserver(); - const iff = await createIFF({ - 'index.ts': dedent` - styles; - `, - 'a.module.css': '', - 'tsconfig.json': dedent` - { - "cmkOptions": { - "enabled": true, - "namedExports": ${namedExports} - } - } - `, - }); - await tsserver.sendUpdateOpen({ - openFiles: [{ file: iff.paths['tsconfig.json'] }], - }); - await tsserver.sendConfigure({ - preferences: { quotePreference: 'single' }, - }); - const res = await tsserver.sendCompletionDetails({ - file: iff.paths['index.ts'], - line: 1, - offset: 7, - entryNames: [ - { - name: 'styles', - source: './a.module.css', - data: { - exportName: ts.InternalSymbolName.Default, - fileName: iff.paths['a.module.css'], - moduleSpecifier: './a.module.css', + test('excludes generated files from suggestions', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ + cmkOptions: { namedExports, dtsOutDir: 'generated' }, + }), + 'a.tsx': `styles;`, + 'a.module.css': '', + 'generated/b.module.css.d.ts': dedent` + const styles: {}; + export default styles; + `, + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['a.tsx'] }] }); + await tsserver.sendConfigure({ + preferences: { + includeCompletionsForModuleExports: true, + quotePreference: 'single', }, - }, - ], - }); - expect(normalizeCompletionDetails(res.body!)).toStrictEqual([ - { - codeActions: [ + }); + + const res = await tsserver.sendCompletionInfo({ + file: iff.paths['a.tsx'], + ...getRange('a.tsx', 'styles').end, + }); + + expect( + normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === 'styles') ?? []), + ).toStrictEqual(normalizeCompletionEntry([{ name: 'styles', sortText: '0', source: './a.module.css' }])); + }); + + test('inserts the import statement when accepted', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ cmkOptions: { namedExports } }), + 'index.ts': `styles;`, + 'a.module.css': '', + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + await tsserver.sendConfigure({ + preferences: { quotePreference: 'single' }, + }); + + const res = await tsserver.sendCompletionDetails({ + file: iff.paths['index.ts'], + ...getRange('index.ts', 'styles').end, + entryNames: [ + { + name: 'styles', + source: './a.module.css', + data: { + exportName: ts.InternalSymbolName.Default, + fileName: iff.paths['a.module.css'], + moduleSpecifier: './a.module.css', + }, + }, + ], + }); + + const importStatement = buildStylesImport('./a.module.css', { namedExports }); + expect(normalizeCompletionDetails(res.body!)).toStrictEqual([ { - changes: [ + codeActions: [ { - fileName: iff.paths['index.ts'], - textChanges: [ + changes: [ { - start: { line: 1, offset: 1 }, - end: { line: 1, offset: 1 }, - newText: `${importStatement}${ts.sys.newLine}${ts.sys.newLine}`, + fileName: iff.paths['index.ts'], + textChanges: [ + { + start: { line: 1, offset: 1 }, + end: { line: 1, offset: 1 }, + newText: `${importStatement}${ts.sys.newLine}${ts.sys.newLine}`, + }, + ], }, ], }, ], }, - ], - }, - ]); -}); - -describe('auto-import suggests named exports instead of namespace import when prioritizeNamedImports is true', async () => { - const tsserver = launchTsserver(); - const iff = await createIFF({ - 'index.ts': dedent` - styles; - a_1; - `, - 'a.module.css': dedent` - .a_1 { color: red; } - `, - 'tsconfig.json': dedent` - { - "cmkOptions": { - "enabled": true, - "namedExports": true, - "prioritizeNamedImports": true - } - } - `, + ]); + }); }); - await tsserver.sendUpdateOpen({ - openFiles: [{ file: iff.paths['tsconfig.json'] }], + + describe('className attribute snippet', () => { + test.each([{ quotePreference: 'single' as const }, { quotePreference: 'double' as const }])( + 'completes as className={$$1} with quotePreference: $quotePreference', + async ({ quotePreference }) => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ + compilerOptions: { jsx: 'react-jsx', types: [reactDtsPath] }, + cmkOptions: { namedExports }, + }), + 'a.tsx': dedent` + ${buildStylesImport('./a.module.css', { namedExports })} + const jsx = ; + `, + 'a.module.css': '', + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['a.tsx'] }] }); + await tsserver.sendConfigure({ + preferences: { + includeCompletionsWithSnippetText: true, + includeCompletionsWithInsertText: true, + jsxAttributeCompletionStyle: 'auto', + quotePreference, + }, + }); + + const res = await tsserver.sendCompletionInfo({ + file: iff.paths['a.tsx'], + ...getRange('a.tsx', 'className').end, + }); + + expect( + normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === 'className') ?? []), + ).toStrictEqual( + normalizeCompletionEntry([{ name: 'className', insertText: 'className={$1}', sortText: expect.anything() }]), + ); + }, + ); }); - await tsserver.sendConfigure({ - preferences: { - includeCompletionsForModuleExports: true, - }, +}); + +describe('named token completion (namedExports: true)', () => { + describe('prioritizeNamedImports: false', () => { + test('omits named tokens from suggestions', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ + cmkOptions: { namedExports: true, prioritizeNamedImports: false }, + }), + 'index.ts': `a_1;`, + 'a.module.css': `.a_1 { color: red; }`, + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + await tsserver.sendConfigure({ + preferences: { includeCompletionsForModuleExports: true }, + }); + + const res = await tsserver.sendCompletionInfo({ + file: iff.paths['index.ts'], + ...getRange('index.ts', 'a_1').end, + }); + + expect(normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === 'a_1') ?? [])).toStrictEqual( + [], + ); + }); }); - test.each([ - { - name: 'styles', - entryName: 'styles', - file: iff.paths['index.ts'], - line: 1, - offset: 7, - expected: [], - }, - { - name: 'a_1', - entryName: 'a_1', - file: iff.paths['index.ts'], - line: 2, - offset: 4, - expected: [{ name: 'a_1', sortText: '16', source: './a.module.css' }], - }, - ])('Completions for $name', async ({ entryName, file, line, offset, expected }) => { - const res = await tsserver.sendCompletionInfo({ - file, - line, - offset, + + describe('prioritizeNamedImports: true', () => { + test('omits the default styles binding from suggestions', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ + cmkOptions: { namedExports: true, prioritizeNamedImports: true }, + }), + 'index.ts': `styles;`, + 'a.module.css': `.a_1 { color: red; }`, + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + await tsserver.sendConfigure({ + preferences: { includeCompletionsForModuleExports: true }, + }); + + const res = await tsserver.sendCompletionInfo({ + file: iff.paths['index.ts'], + ...getRange('index.ts', 'styles').end, + }); + + expect( + normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === 'styles') ?? []), + ).toStrictEqual([]); + }); + + test('suggests named token bindings', async () => { + const { iff, getRange } = await setupFixture({ + 'tsconfig.json': buildTSConfigJSON({ + cmkOptions: { namedExports: true, prioritizeNamedImports: true }, + }), + 'index.ts': `a_1;`, + 'a.module.css': `.a_1 { color: red; }`, + }); + await tsserver.sendUpdateOpen({ openFiles: [{ file: iff.paths['index.ts'] }] }); + await tsserver.sendConfigure({ + preferences: { includeCompletionsForModuleExports: true }, + }); + + const res = await tsserver.sendCompletionInfo({ + file: iff.paths['index.ts'], + ...getRange('index.ts', 'a_1').end, + }); + + expect(normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === 'a_1') ?? [])).toStrictEqual( + normalizeCompletionEntry([{ name: 'a_1', sortText: '16', source: './a.module.css' }]), + ); }); - expect(normalizeCompletionEntry(res.body?.entries.filter((entry) => entry.name === entryName) ?? [])).toStrictEqual( - normalizeCompletionEntry(expected), - ); }); }); diff --git a/packages/ts-plugin/src/test/builder.ts b/packages/ts-plugin/src/test/builder.ts index 1368b6bb..556074b5 100644 --- a/packages/ts-plugin/src/test/builder.ts +++ b/packages/ts-plugin/src/test/builder.ts @@ -13,6 +13,14 @@ export function buildTSConfigJSON(args?: TSConfig): string { }); } -export function buildStylesImport(specifier: string, { namedExports }: { namedExports: boolean }): string { - return namedExports ? `import * as styles from '${specifier}';` : `import styles from '${specifier}';`; +interface BuildStylesImportOptions { + namedExports: boolean; + quote?: 'single' | 'double'; + name?: string; +} + +export function buildStylesImport(specifier: string, options: BuildStylesImportOptions): string { + const { namedExports, quote = 'single', name = 'styles' } = options; + const q = quote === 'single' ? "'" : '"'; + return namedExports ? `import * as ${name} from ${q}${specifier}${q};` : `import ${name} from ${q}${specifier}${q};`; }