Add tests

master
Brad Cornes 2023-08-29 10:37:51 +01:00
parent 0964dbfe4a
commit bac7e2e564
48 changed files with 7924 additions and 11 deletions

1995
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,9 @@
"clean": "rimraf bin", "clean": "rimraf bin",
"hashbang": "node scripts/hashbang.mjs", "hashbang": "node scripts/hashbang.mjs",
"create-notices-file": "node scripts/createNoticesFile.mjs", "create-notices-file": "node scripts/createNoticesFile.mjs",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build",
"test": "vitest",
"test:prepare": "node tests/prepare.js"
}, },
"bin": { "bin": {
"tailwindcss-language-server": "./bin/tailwindcss-language-server" "tailwindcss-language-server": "./bin/tailwindcss-language-server"
@ -65,7 +67,9 @@
"stack-trace": "0.0.10", "stack-trace": "0.0.10",
"tailwindcss": "3.3.0", "tailwindcss": "3.3.0",
"typescript": "4.6.4", "typescript": "4.6.4",
"vitest": "0.34.2",
"vscode-css-languageservice": "5.4.1", "vscode-css-languageservice": "5.4.1",
"vscode-jsonrpc": "8.1.0",
"vscode-languageserver": "8.0.2", "vscode-languageserver": "8.0.2",
"vscode-languageserver-textdocument": "1.0.7", "vscode-languageserver-textdocument": "1.0.7",
"vscode-uri": "3.0.2" "vscode-uri": "3.0.2"

View File

@ -1200,10 +1200,13 @@ async function createProjectService(
URI.file(path.resolve(path.dirname(URI.parse(document.uri).fsPath), linkPath)).toString() URI.file(path.resolve(path.dirname(URI.parse(document.uri).fsPath), linkPath)).toString()
) )
}, },
provideDiagnostics: debounce((document: TextDocument) => { provideDiagnostics: debounce(
if (!state.enabled) return (document: TextDocument) => {
provideDiagnostics(state, document) if (!state.enabled) return
}, 500), provideDiagnostics(state, document)
},
params.initializationOptions?.testMode ? 0 : 500
),
provideDiagnosticsForce: (document: TextDocument) => { provideDiagnosticsForce: (document: TextDocument) => {
if (!state.enabled) return if (!state.enabled) return
provideDiagnostics(state, document) provideDiagnostics(state, document)

View File

@ -0,0 +1,38 @@
import { test, expect } from 'vitest'
import * as fs from 'node:fs/promises'
import { withFixture } from '../common'
withFixture('basic', (c) => {
function testFixture(fixture) {
test(fixture, async () => {
fixture = await fs.readFile(`tests/code-actions/${fixture}.json`, 'utf8')
let { code, expected, language = 'html' } = JSON.parse(fixture)
let promise = new Promise((resolve) => {
c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => {
resolve(diagnostics)
})
})
let textDocument = await c.openDocument({ text: code, lang: language })
let diagnostics = await promise
let res = await c.sendRequest('textDocument/codeAction', {
textDocument,
context: {
diagnostics,
},
})
// console.log(JSON.stringify(res))
expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', textDocument.uri))
expect(res).toEqual(expected)
})
}
testFixture('conflict')
testFixture('invalid-theme')
testFixture('invalid-screen')
})

View File

@ -0,0 +1,36 @@
import { test, expect } from 'vitest'
import { withFixture } from '../common'
import * as fs from 'node:fs/promises'
withFixture('v2-jit', (c) => {
function testFixture(fixture) {
test(fixture, async () => {
fixture = await fs.readFile(`tests/code-actions/${fixture}.json`, 'utf8')
let { code, expected, language = 'html' } = JSON.parse(fixture)
let promise = new Promise((resolve) => {
c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => {
resolve(diagnostics)
})
})
let textDocument = await c.openDocument({ text: code, lang: language })
let diagnostics = await promise
let res = await c.sendRequest('textDocument/codeAction', {
textDocument,
context: {
diagnostics,
},
})
// console.log(JSON.stringify(res))
expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', textDocument.uri))
expect(res).toEqual(expected)
})
}
testFixture('variant-order')
})

View File

@ -0,0 +1,39 @@
import { test, expect } from 'vitest'
import { withFixture } from '../common'
import * as fs from 'node:fs/promises'
withFixture('v2', (c) => {
function testFixture(fixture) {
test(fixture, async () => {
fixture = await fs.readFile(`tests/code-actions/${fixture}.json`, 'utf8')
let { code, expected, language = 'html' } = JSON.parse(fixture)
let promise = new Promise((resolve) => {
c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => {
resolve(diagnostics)
})
})
let textDocument = await c.openDocument({ text: code, lang: language })
let diagnostics = await promise
let res = await c.sendRequest('textDocument/codeAction', {
textDocument,
context: {
diagnostics,
},
})
// console.log(JSON.stringify(res))
expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', textDocument.uri))
expect(res).toEqual(expected)
})
}
// testFixture('conflict')
testFixture('invalid-theme')
testFixture('invalid-screen')
testFixture('invalid-variant')
})

View File

@ -0,0 +1,161 @@
{
"code": "<div class=\"lowercase uppercase\">",
"expected": [
{
"title": "Delete 'uppercase'",
"kind": "quickfix",
"diagnostics": [
{
"code": "cssConflict",
"className": {
"className": "lowercase",
"classList": {
"classList": "lowercase uppercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 21 }
}
},
"otherClassNames": [
{
"className": "uppercase",
"classList": {
"classList": "lowercase uppercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": {
"start": { "line": 0, "character": 22 },
"end": { "line": 0, "character": 31 }
}
}
],
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 21 }
},
"severity": 2,
"message": "'lowercase' applies the same CSS properties as 'uppercase'.",
"relatedInformation": [
{
"message": "uppercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 22 },
"end": { "line": 0, "character": 31 }
}
}
}
]
}
],
"edit": {
"changes": {
"{{URI}}": [
{
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
},
"newText": "lowercase"
}
]
}
}
},
{
"title": "Delete 'lowercase'",
"kind": "quickfix",
"diagnostics": [
{
"code": "cssConflict",
"className": {
"className": "uppercase",
"classList": {
"classList": "lowercase uppercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": {
"start": { "line": 0, "character": 22 },
"end": { "line": 0, "character": 31 }
}
},
"otherClassNames": [
{
"className": "lowercase",
"classList": {
"classList": "lowercase uppercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 21 }
}
}
],
"range": {
"start": { "line": 0, "character": 22 },
"end": { "line": 0, "character": 31 }
},
"severity": 2,
"message": "'uppercase' applies the same CSS properties as 'lowercase'.",
"relatedInformation": [
{
"message": "lowercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 21 }
}
}
}
]
}
],
"edit": {
"changes": {
"{{URI}}": [
{
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
},
"newText": "uppercase"
}
]
}
}
}
]
}

View File

@ -0,0 +1,35 @@
{
"code": "@screen small",
"language": "css",
"expected": [
{
"title": "Replace with 'sm'",
"kind": "quickfix",
"diagnostics": [
{
"code": "invalidScreen",
"range": {
"start": { "line": 0, "character": 8 },
"end": { "line": 0, "character": 13 }
},
"severity": 1,
"message": "The screen 'small' does not exist in your theme config. Did you mean 'sm'?",
"suggestions": ["sm"]
}
],
"edit": {
"changes": {
"{{URI}}": [
{
"range": {
"start": { "line": 0, "character": 8 },
"end": { "line": 0, "character": 13 }
},
"newText": "sm"
}
]
}
}
}
]
}

View File

@ -0,0 +1,35 @@
{
"code": ".test { color: theme(colors.red.901) }",
"language": "css",
"expected": [
{
"title": "Replace with 'colors.red.900'",
"kind": "quickfix",
"diagnostics": [
{
"code": "invalidConfigPath",
"range": {
"start": { "line": 0, "character": 21 },
"end": { "line": 0, "character": 35 }
},
"severity": 1,
"message": "'colors.red.901' does not exist in your theme config. Did you mean 'colors.red.900'?",
"suggestions": ["colors.red.900"]
}
],
"edit": {
"changes": {
"{{URI}}": [
{
"range": {
"start": { "line": 0, "character": 21 },
"end": { "line": 0, "character": 35 }
},
"newText": "colors.red.900"
}
]
}
}
}
]
}

View File

@ -0,0 +1,35 @@
{
"code": "@variants hoover",
"language": "css",
"expected": [
{
"title": "Replace with 'hover'",
"kind": "quickfix",
"diagnostics": [
{
"code": "invalidVariant",
"range": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 16 }
},
"severity": 1,
"message": "The variant 'hoover' does not exist. Did you mean 'hover'?",
"suggestions": ["hover"]
}
],
"edit": {
"changes": {
"{{URI}}": [
{
"range": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 16 }
},
"newText": "hover"
}
]
}
}
}
]
}

View File

@ -0,0 +1,34 @@
{
"code": "<div class=\"hover:focus:uppercase\">",
"expected": [
{
"title": "Replace with 'focus:hover:uppercase'",
"kind": "quickfix",
"diagnostics": [
{
"code": "recommendedVariantOrder",
"suggestions": ["focus:hover:uppercase"],
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 33 }
},
"severity": 2,
"message": "Variants are not in the recommended order, which may cause unexpected CSS output."
}
],
"edit": {
"changes": {
"{{URI}}": [
{
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 33 }
},
"newText": "focus:hover:uppercase"
}
]
}
}
}
]
}

View File

@ -0,0 +1,75 @@
import { test, expect } from 'vitest'
import { withFixture } from '../common'
withFixture('basic', (c) => {
async function testColors(name, { text, expected }) {
test.concurrent(name, async () => {
let textDocument = await c.openDocument({ text })
let res = await c.sendRequest('textDocument/documentColor', {
textDocument,
})
expect(res).toEqual(expected)
})
}
testColors('simple', {
text: '<div class="bg-red-500">',
expected: [
{
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 22 } },
color: {
red: 0.9372549019607843,
green: 0.26666666666666666,
blue: 0.26666666666666666,
alpha: 1,
},
},
],
})
testColors('opacity modifier', {
text: '<div class="bg-red-500/20">',
expected: [
{
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 25 } },
color: {
red: 0.9372549019607843,
green: 0.26666666666666666,
blue: 0.26666666666666666,
alpha: 0.2,
},
},
],
})
testColors('arbitrary value', {
text: '<div class="bg-[red]">',
expected: [
{
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 20 } },
color: {
red: 1,
green: 0,
blue: 0,
alpha: 1,
},
},
],
})
testColors('arbitrary value and opacity modifier', {
text: '<div class="bg-[red]/[0.33]">',
expected: [
{
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 27 } },
color: {
red: 1,
green: 0,
blue: 0,
alpha: 0.33,
},
},
],
})
})

View File

@ -0,0 +1,108 @@
import { test, expect } from 'vitest'
import { withFixture } from '../common'
withFixture('basic', (c) => {
test.concurrent('theme color', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-red-500">' })
let res = await c.sendRequest('textDocument/colorPresentation', {
color: { red: 1, green: 0, blue: 0, alpha: 1 },
textDocument,
range: {
start: { line: 0, character: 12 },
end: { line: 0, character: 22 },
},
})
expect(res).toEqual([])
})
test.concurrent('arbitrary named color', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-[red]">' })
let res = await c.sendRequest('textDocument/colorPresentation', {
color: { red: 1, green: 0, blue: 0, alpha: 1 },
textDocument,
range: {
start: { line: 0, character: 12 },
end: { line: 0, character: 20 },
},
})
expect(res).toEqual([
{ label: 'bg-[#ff0000]' },
{ label: 'bg-[rgb(255,0,0)]' },
{ label: 'bg-[hsl(0,100%,50%)]' },
])
})
test.concurrent('arbitrary short hex color', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-[#f00]">' })
let res = await c.sendRequest('textDocument/colorPresentation', {
color: { red: 1, green: 0, blue: 0, alpha: 1 },
textDocument,
range: {
start: { line: 0, character: 12 },
end: { line: 0, character: 21 },
},
})
expect(res).toEqual([
{ label: 'bg-[#f00]' },
{ label: 'bg-[rgb(255,0,0)]' },
{ label: 'bg-[hsl(0,100%,50%)]' },
])
})
test.concurrent('arbitrary hex color', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-[#ff0000]">' })
let res = await c.sendRequest('textDocument/colorPresentation', {
color: { red: 1, green: 0, blue: 0, alpha: 1 },
textDocument,
range: {
start: { line: 0, character: 12 },
end: { line: 0, character: 24 },
},
})
expect(res).toEqual([
{ label: 'bg-[#ff0000]' },
{ label: 'bg-[rgb(255,0,0)]' },
{ label: 'bg-[hsl(0,100%,50%)]' },
])
})
test.concurrent('arbitrary rgb color', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-[rgb(255,0,0)]">' })
let res = await c.sendRequest('textDocument/colorPresentation', {
color: { red: 1, green: 0, blue: 0, alpha: 1 },
textDocument,
range: {
start: { line: 0, character: 12 },
end: { line: 0, character: 29 },
},
})
expect(res).toEqual([
{ label: 'bg-[#ff0000]' },
{ label: 'bg-[rgb(255,0,0)]' },
{ label: 'bg-[hsl(0,100%,50%)]' },
])
})
test.concurrent('arbitrary hsl color', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-[hsl(0,100%,50%)]">' })
let res = await c.sendRequest('textDocument/colorPresentation', {
color: { red: 1, green: 0, blue: 0, alpha: 1 },
textDocument,
range: {
start: { line: 0, character: 12 },
end: { line: 0, character: 32 },
},
})
expect(res).toEqual([
{ label: 'bg-[#ff0000]' },
{ label: 'bg-[rgb(255,0,0)]' },
{ label: 'bg-[hsl(0,100%,50%)]' },
])
})
})

View File

@ -0,0 +1,231 @@
import * as path from 'node:path'
import * as cp from 'node:child_process'
import * as rpc from 'vscode-jsonrpc'
import { beforeAll } from 'vitest'
let settings = {}
let initPromise
let childProcess
let docSettings = new Map()
async function init(fixture) {
childProcess = cp.fork('./bin/tailwindcss-language-server', { silent: true })
const capabilities = {
textDocument: {
codeAction: { dynamicRegistration: true },
codeLens: { dynamicRegistration: true },
colorProvider: { dynamicRegistration: true },
completion: {
completionItem: {
commitCharactersSupport: true,
documentationFormat: ['markdown', 'plaintext'],
snippetSupport: true,
},
completionItemKind: {
valueSet: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
25,
],
},
contextSupport: true,
dynamicRegistration: true,
},
definition: { dynamicRegistration: true },
documentHighlight: { dynamicRegistration: true },
documentLink: { dynamicRegistration: true },
documentSymbol: {
dynamicRegistration: true,
symbolKind: {
valueSet: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
25, 26,
],
},
},
formatting: { dynamicRegistration: true },
hover: {
contentFormat: ['markdown', 'plaintext'],
dynamicRegistration: true,
},
implementation: { dynamicRegistration: true },
onTypeFormatting: { dynamicRegistration: true },
publishDiagnostics: { relatedInformation: true },
rangeFormatting: { dynamicRegistration: true },
references: { dynamicRegistration: true },
rename: { dynamicRegistration: true },
signatureHelp: {
dynamicRegistration: true,
signatureInformation: { documentationFormat: ['markdown', 'plaintext'] },
},
synchronization: {
didSave: true,
dynamicRegistration: true,
willSave: true,
willSaveWaitUntil: true,
},
typeDefinition: { dynamicRegistration: true },
},
workspace: {
applyEdit: true,
configuration: true,
didChangeConfiguration: { dynamicRegistration: true },
didChangeWatchedFiles: { dynamicRegistration: true },
executeCommand: { dynamicRegistration: true },
symbol: {
dynamicRegistration: true,
symbolKind: {
valueSet: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
25, 26,
],
},
},
workspaceEdit: { documentChanges: true },
workspaceFolders: true,
},
}
let connection = rpc.createMessageConnection(
new rpc.StreamMessageReader(childProcess.stdout),
new rpc.StreamMessageWriter(childProcess.stdin)
)
connection.listen()
await connection.sendRequest(new rpc.RequestType('initialize'), {
processId: -1,
// rootPath: '.',
rootUri: `file://${path.resolve('./tests/fixtures/', fixture)}`,
capabilities,
trace: 'off',
workspaceFolders: [],
initializationOptions: {
testMode: true,
},
})
await connection.sendNotification(new rpc.NotificationType('initialized'))
connection.onRequest(new rpc.RequestType('workspace/configuration'), (params) => {
return params.items.map((item) => {
if (docSettings.has(item.scopeUri)) {
return docSettings.get(item.scopeUri)[item.section] ?? {}
}
return settings[item.section] ?? {}
})
})
initPromise = new Promise((resolve) => {
connection.onRequest(new rpc.RequestType('client/registerCapability'), ({ registrations }) => {
if (registrations.findIndex((r) => r.method === 'textDocument/completion') > -1) {
resolve()
}
return null
})
})
let counter = 0
return {
connection,
sendRequest(type, params) {
return connection.sendRequest(new rpc.RequestType(type), params)
},
onNotification(type, callback) {
return connection.onNotification(new rpc.RequestType(type), callback)
},
async openDocument({ text, lang = 'html', dir = '', settings = {} }) {
let uri = `file://${path.resolve('./tests/fixtures', fixture, dir, `file-${counter++}`)}`
docSettings.set(uri, settings)
await connection.sendNotification(new rpc.NotificationType('textDocument/didOpen'), {
textDocument: {
uri,
languageId: lang,
version: 1,
text,
},
})
await initPromise
return {
uri,
async updateSettings(settings) {
docSettings.set(uri, settings)
await connection.sendNotification(
new rpc.NotificationType('workspace/didChangeConfiguration')
)
},
}
},
async updateSettings(newSettings) {
settings = newSettings
await connection.sendNotification(
new rpc.NotificationType('workspace/didChangeConfiguration')
)
},
async updateFile(file, text) {
let uri = `file://${path.resolve('./tests/fixtures', fixture, file)}`
await connection.sendNotification(new rpc.NotificationType('textDocument/didChange'), {
textDocument: { uri, version: counter++ },
contentChanges: [{ text }],
})
},
}
}
export function withFixture(fixture, callback) {
let c
beforeAll(async () => {
c = await init(fixture)
return () => c.connection.end()
})
callback({
get connection() {
return c.connection
},
get sendRequest() {
return c.sendRequest
},
get onNotification() {
return c.onNotification
},
get openDocument() {
return c.openDocument
},
get updateSettings() {
return c.updateSettings
},
get updateFile() {
return c.updateFile
},
})
}
// let counter = 0
// export async function openDocument(connection, fixture, languageId, text) {
// let uri = `file://${path.resolve('./tests/fixtures', fixture, `file-${counter++}`)}`
// await connection.sendNotification(new rpc.NotificationType('textDocument/didOpen'), {
// textDocument: {
// uri,
// languageId,
// version: 1,
// text,
// },
// })
// await initPromise
// return uri
// }
// export async function updateSettings(connection, newSettings) {
// settings = newSettings
// await connection.sendNotification(new rpc.NotificationType('workspace/didChangeConfiguration'))
// }

View File

@ -0,0 +1,84 @@
import { expect, test } from 'vitest'
import { withFixture } from '../common'
withFixture('dependencies', (c) => {
async function completion({
lang,
text,
position,
context = {
triggerKind: 1,
},
settings,
}) {
let textDocument = await c.openDocument({ text, lang, settings })
return c.sendRequest('textDocument/completion', {
textDocument,
position,
context,
})
}
test.concurrent('@config', async () => {
let result = await completion({
text: '@config "',
lang: 'css',
position: {
line: 0,
character: 9,
},
})
expect(result).toEqual({
isIncomplete: false,
items: [
{
label: 'sub-dir/',
kind: 19,
command: { command: 'editor.action.triggerSuggest', title: '' },
data: expect.anything(),
textEdit: {
newText: 'sub-dir/',
range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } },
},
},
{
label: 'tailwind.config.js',
kind: 17,
data: expect.anything(),
textEdit: {
newText: 'tailwind.config.js',
range: { start: { line: 0, character: 9 }, end: { line: 0, character: 9 } },
},
},
],
})
})
test.concurrent('@config directory', async () => {
let result = await completion({
text: '@config "./sub-dir/',
lang: 'css',
position: {
line: 0,
character: 19,
},
})
expect(result).toEqual({
isIncomplete: false,
items: [
{
label: 'colors.js',
kind: 17,
data: expect.anything(),
textEdit: {
newText: 'colors.js',
range: { start: { line: 0, character: 19 }, end: { line: 0, character: 19 } },
},
},
],
})
})
})

View File

@ -0,0 +1,121 @@
import { expect, test } from 'vitest'
import { withFixture } from '../common'
withFixture('basic', (c) => {
async function completion({
lang,
text,
position,
context = {
triggerKind: 1,
},
settings,
}) {
let textDocument = await c.openDocument({ text, lang, settings })
return c.sendRequest('textDocument/completion', {
textDocument,
position,
context,
})
}
async function expectCompletions({ lang, text, position, settings }) {
let result = await completion({ lang, text, position, settings })
let textEdit = expect.objectContaining({ range: { start: position, end: position } })
expect(result.items.length).toBe(11175)
expect(result.items.filter((item) => item.label.endsWith(':')).length).toBe(157)
expect(result).toEqual({
isIncomplete: false,
items: expect.arrayContaining([
expect.objectContaining({ label: 'hover:', textEdit }),
expect.objectContaining({ label: 'uppercase', textEdit }),
]),
})
}
test.concurrent('HTML', async () => {
await expectCompletions({ text: '<div class=""></div>', position: { line: 0, character: 12 } })
})
test.concurrent('JSX', async () => {
await expectCompletions({
lang: 'javascriptreact',
text: "<div className={''}></div>",
position: {
line: 0,
character: 17,
},
})
})
test.concurrent('JSX concatination', async () => {
await expectCompletions({
lang: 'javascriptreact',
text: "<div className={'' + ''}></div>",
position: {
line: 0,
character: 22,
},
})
})
test.concurrent('JSX outside strings', async () => {
let result = await completion({
lang: 'javascriptreact',
text: "<div className={'' + ''}></div>",
position: {
line: 0,
character: 18,
},
})
expect(result).toBe(null)
})
test.concurrent('classRegex simple', async () => {
await expectCompletions({
text: 'test ',
position: {
line: 0,
character: 5,
},
settings: { tailwindCSS: { experimental: { classRegex: ['test (\\S*)'] } } },
})
})
test.concurrent('classRegex nested', async () => {
await expectCompletions({
text: 'test ""',
position: {
line: 0,
character: 6,
},
settings: {
tailwindCSS: { experimental: { classRegex: [['test (\\S*)', '"([^"]*)"']] } },
},
})
})
test.concurrent('resolve', async () => {
let result = await completion({
text: '<div class="">',
position: {
line: 0,
character: 12,
},
})
let item = result.items.find((item) => item.label === 'uppercase')
let resolved = await c.sendRequest('completionItem/resolve', item)
expect(resolved).toEqual({
...item,
detail: 'text-transform: uppercase;',
documentation: {
kind: 'markdown',
value: '```css\n.uppercase {\n text-transform: uppercase;\n}\n```',
},
})
})
})

View File

@ -0,0 +1,5 @@
{
"code": ".test { @apply uppercase; color: red; @apply lowercase }",
"language": "css",
"expected": []
}

View File

@ -0,0 +1,5 @@
{
"code": ".test { @apply uppercase }\n.test { @apply lowercase }",
"language": "css",
"expected": []
}

View File

@ -0,0 +1,116 @@
{
"code": ".test { @apply uppercase lowercase }",
"language": "css",
"expected": [
{
"code": "cssConflict",
"className": {
"className": "uppercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 34 }
},
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": { "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 24 } }
},
"otherClassNames": [
{
"className": "lowercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 34 }
},
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": {
"start": { "line": 0, "character": 25 },
"end": { "line": 0, "character": 34 }
}
}
],
"range": { "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 24 } },
"severity": 2,
"message": "'uppercase' applies the same CSS properties as 'lowercase'.",
"relatedInformation": [
{
"message": "lowercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 25 },
"end": { "line": 0, "character": 34 }
}
}
}
]
},
{
"code": "cssConflict",
"className": {
"className": "lowercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 34 }
},
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 34 } }
},
"otherClassNames": [
{
"className": "uppercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 34 }
},
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": {
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 24 }
}
}
],
"range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 34 } },
"severity": 2,
"message": "'lowercase' applies the same CSS properties as 'uppercase'.",
"relatedInformation": [
{
"message": "uppercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 24 }
}
}
}
]
}
]
}

View File

@ -0,0 +1,5 @@
{
"code": "<div className={'lowercase' + 'uppercase'}>",
"language": "javascriptreact",
"expected": []
}

View File

@ -0,0 +1,112 @@
{
"code": "<div className={'lowercase uppercase' + 'uppercase'}>",
"language": "javascriptreact",
"expected": [
{
"code": "cssConflict",
"className": {
"className": "lowercase",
"classList": {
"classList": "lowercase uppercase",
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 36 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 26 } }
},
"otherClassNames": [
{
"className": "uppercase",
"classList": {
"classList": "lowercase uppercase",
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 36 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": {
"start": { "line": 0, "character": 27 },
"end": { "line": 0, "character": 36 }
}
}
],
"range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 26 } },
"severity": 2,
"message": "'lowercase' applies the same CSS properties as 'uppercase'.",
"relatedInformation": [
{
"message": "uppercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 27 },
"end": { "line": 0, "character": 36 }
}
}
}
]
},
{
"code": "cssConflict",
"className": {
"className": "uppercase",
"classList": {
"classList": "lowercase uppercase",
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 36 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": { "start": { "line": 0, "character": 27 }, "end": { "line": 0, "character": 36 } }
},
"otherClassNames": [
{
"className": "lowercase",
"classList": {
"classList": "lowercase uppercase",
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 36 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 26 }
}
}
],
"range": { "start": { "line": 0, "character": 27 }, "end": { "line": 0, "character": 36 } },
"severity": 2,
"message": "'uppercase' applies the same CSS properties as 'lowercase'.",
"relatedInformation": [
{
"message": "lowercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 26 }
}
}
}
]
}
]
}

View File

@ -0,0 +1,111 @@
{
"code": "<div class=\"uppercase lowercase\"></div>",
"expected": [
{
"code": "cssConflict",
"className": {
"className": "uppercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } }
},
"otherClassNames": [
{
"className": "lowercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": {
"start": { "line": 0, "character": 22 },
"end": { "line": 0, "character": 31 }
}
}
],
"range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } },
"severity": 2,
"message": "'uppercase' applies the same CSS properties as 'lowercase'.",
"relatedInformation": [
{
"message": "lowercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 22 },
"end": { "line": 0, "character": 31 }
}
}
}
]
},
{
"code": "cssConflict",
"className": {
"className": "lowercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
"range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } }
},
"otherClassNames": [
{
"className": "uppercase",
"classList": {
"classList": "uppercase lowercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 21 }
}
}
],
"range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } },
"severity": 2,
"message": "'lowercase' applies the same CSS properties as 'uppercase'.",
"relatedInformation": [
{
"message": "uppercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 21 }
}
}
}
]
}
]
}

View File

@ -0,0 +1,4 @@
{
"code": "<div class=\"uppercase sm:lowercase\"></div>",
"expected": []
}

View File

@ -0,0 +1,111 @@
{
"code": "<div class=\"sm:uppercase sm:lowercase\"></div>",
"expected": [
{
"code": "cssConflict",
"className": {
"className": "sm:uppercase",
"classList": {
"classList": "sm:uppercase sm:lowercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 37 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 12 }
},
"range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 24 } }
},
"otherClassNames": [
{
"className": "sm:lowercase",
"classList": {
"classList": "sm:uppercase sm:lowercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 37 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 13 },
"end": { "line": 0, "character": 25 }
},
"range": {
"start": { "line": 0, "character": 25 },
"end": { "line": 0, "character": 37 }
}
}
],
"range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 24 } },
"severity": 2,
"message": "'sm:uppercase' applies the same CSS properties as 'sm:lowercase'.",
"relatedInformation": [
{
"message": "sm:lowercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 25 },
"end": { "line": 0, "character": 37 }
}
}
}
]
},
{
"code": "cssConflict",
"className": {
"className": "sm:lowercase",
"classList": {
"classList": "sm:uppercase sm:lowercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 37 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 13 },
"end": { "line": 0, "character": 25 }
},
"range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 37 } }
},
"otherClassNames": [
{
"className": "sm:uppercase",
"classList": {
"classList": "sm:uppercase sm:lowercase",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 37 }
}
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 12 }
},
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 24 }
}
}
],
"range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 37 } },
"severity": 2,
"message": "'sm:lowercase' applies the same CSS properties as 'sm:uppercase'.",
"relatedInformation": [
{
"message": "sm:uppercase",
"location": {
"uri": "{{URI}}",
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 24 }
}
}
}
]
}
]
}

View File

@ -0,0 +1,37 @@
import { expect, test } from 'vitest'
import { withFixture } from '../common'
import * as fs from 'node:fs/promises'
withFixture('basic', (c) => {
function testFixture(fixture) {
test(fixture, async () => {
fixture = await fs.readFile(`tests/diagnostics/${fixture}.json`, 'utf8')
let { code, expected, language = 'html' } = JSON.parse(fixture)
let promise = new Promise((resolve) => {
c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => {
resolve(diagnostics)
})
})
let doc = await c.openDocument({ text: code, lang: language })
let diagnostics = await promise
expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri))
expect(diagnostics).toEqual(expected)
})
}
testFixture('css-conflict/simple')
testFixture('css-conflict/variants-negative')
testFixture('css-conflict/variants-positive')
testFixture('css-conflict/jsx-concat-negative')
testFixture('css-conflict/jsx-concat-positive')
testFixture('css-conflict/css')
testFixture('css-conflict/css-multi-rule')
testFixture('css-conflict/css-multi-prop')
testFixture('invalid-screen/simple')
testFixture('invalid-theme/simple')
})

View File

@ -0,0 +1,13 @@
{
"code": "@screen small",
"language": "css",
"expected": [
{
"code": "invalidScreen",
"range": { "start": { "line": 0, "character": 8 }, "end": { "line": 0, "character": 13 } },
"severity": 1,
"message": "The screen 'small' does not exist in your theme config. Did you mean 'sm'?",
"suggestions": ["sm"]
}
]
}

View File

@ -0,0 +1,13 @@
{
"code": ".test { color: theme(colors.red.901) }",
"language": "css",
"expected": [
{
"code": "invalidConfigPath",
"range": { "start": { "line": 0, "character": 21 }, "end": { "line": 0, "character": 35 } },
"severity": 1,
"message": "'colors.red.901' does not exist in your theme config. Did you mean 'colors.red.900'?",
"suggestions": ["colors.red.900"]
}
]
}

View File

@ -0,0 +1,42 @@
import { test, expect } from 'vitest'
import { withFixture } from '../common'
import * as path from 'path'
withFixture('basic', (c) => {
async function testDocumentLinks(name, { text, lang, expected }) {
test.concurrent(name, async () => {
let textDocument = await c.openDocument({ text, lang })
let res = await c.sendRequest('textDocument/documentLink', {
textDocument,
})
expect(res).toEqual(expected)
})
}
testDocumentLinks('file exists', {
text: '@config "tailwind.config.js";',
lang: 'css',
expected: [
{
target: `file://${path
.resolve('./tests/fixtures/basic/tailwind.config.js')
.replace(/@/g, '%40')}`,
range: { start: { line: 0, character: 8 }, end: { line: 0, character: 28 } },
},
],
})
testDocumentLinks('file does not exist', {
text: '@config "does-not-exist.js";',
lang: 'css',
expected: [
{
target: `file://${path
.resolve('./tests/fixtures/basic/does-not-exist.js')
.replace(/@/g, '%40')}`,
range: { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } },
},
],
})
})

View File

@ -0,0 +1,38 @@
import { test, expect } from 'vitest'
import { withFixture } from '../common'
withFixture('multi-config-content', (c) => {
test.concurrent('multi-config with content config - 1', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-foo">', dir: 'one' })
let res = await c.sendRequest('textDocument/hover', {
textDocument,
position: { line: 0, character: 13 },
})
expect(res).toEqual({
contents: {
language: 'css',
value:
'.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(255 0 0 / var(--tw-bg-opacity));\n}',
},
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } },
})
})
test.concurrent('multi-config with content config - 2', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-foo">', dir: 'two' })
let res = await c.sendRequest('textDocument/hover', {
textDocument,
position: { line: 0, character: 13 },
})
expect(res).toEqual({
contents: {
language: 'css',
value:
'.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(0 0 255 / var(--tw-bg-opacity));\n}',
},
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } },
})
})
})

View File

@ -0,0 +1,38 @@
import { test, expect } from 'vitest'
import { withFixture } from '../common'
withFixture('multi-config', (c) => {
test.concurrent('multi-config 1', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-foo">', dir: 'one' })
let res = await c.sendRequest('textDocument/hover', {
textDocument,
position: { line: 0, character: 13 },
})
expect(res).toEqual({
contents: {
language: 'css',
value:
'.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(255 0 0 / var(--tw-bg-opacity));\n}',
},
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } },
})
})
test.concurrent('multi-config 2', async () => {
let textDocument = await c.openDocument({ text: '<div class="bg-foo">', dir: 'two' })
let res = await c.sendRequest('textDocument/hover', {
textDocument,
position: { line: 0, character: 13 },
})
expect(res).toEqual({
contents: {
language: 'css',
value:
'.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(0 0 255 / var(--tw-bg-opacity));\n}',
},
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } },
})
})
})

View File

@ -0,0 +1 @@
module.exports = {}

View File

@ -0,0 +1,3 @@
module.exports = {
foo: 'red',
}

View File

@ -0,0 +1,9 @@
const colors = require('./sub-dir/colors')
module.exports = {
theme: {
extend: {
colors,
},
},
}

View File

@ -0,0 +1,10 @@
module.exports = {
content: ['./one/**/*'],
theme: {
extend: {
colors: {
foo: 'red',
},
},
},
}

View File

@ -0,0 +1,10 @@
module.exports = {
content: ['./two/**/*'],
theme: {
extend: {
colors: {
foo: 'blue',
},
},
},
}

View File

@ -0,0 +1,9 @@
module.exports = {
theme: {
extend: {
colors: {
foo: 'red',
},
},
},
}

View File

@ -0,0 +1,9 @@
module.exports = {
theme: {
extend: {
colors: {
foo: 'blue',
},
},
},
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
{
"dependencies": {
"tailwindcss": "1.9.6"
}
}

View File

@ -0,0 +1 @@
module.exports = {}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
{
"dependencies": {
"tailwindcss": "2.2.19"
}
}

View File

@ -0,0 +1,3 @@
module.exports = {
mode: 'jit',
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
{
"dependencies": {
"tailwindcss": "2.2.19"
}
}

View File

@ -0,0 +1 @@
module.exports = {}

View File

@ -0,0 +1,78 @@
import { test, expect } from 'vitest'
import { withFixture } from '../common'
withFixture('basic', (c) => {
async function testHover(name, { text, lang, position, expected, expectedRange, settings }) {
test.concurrent(name, async () => {
let textDocument = await c.openDocument({ text, lang, settings })
let res = await c.sendRequest('textDocument/hover', {
textDocument,
position,
})
expect(res).toEqual(
expected
? {
contents: {
language: 'css',
value: expected,
},
range: expectedRange,
}
: expected
)
})
}
testHover('disabled', {
text: '<div class="bg-red-500">',
settings: {
tailwindCSS: { hovers: false },
},
expected: null,
})
testHover('hover', {
text: '<div class="bg-red-500">',
position: { line: 0, character: 13 },
expected:
'.bg-red-500 {\n' +
' --tw-bg-opacity: 1;\n' +
' background-color: rgb(239 68 68 / var(--tw-bg-opacity));\n' +
'}',
expectedRange: {
start: { line: 0, character: 12 },
end: { line: 0, character: 22 },
},
})
testHover('arbitrary value', {
text: '<div class="p-[3px]">',
position: { line: 0, character: 13 },
expected: '.p-\\[3px\\] {\n' + ' padding: 3px;\n' + '}',
expectedRange: {
start: { line: 0, character: 12 },
end: { line: 0, character: 19 },
},
})
testHover('arbitrary value with theme function', {
text: '<div class="p-[theme(spacing.4)]">',
position: { line: 0, character: 13 },
expected: '.p-\\[theme\\(spacing\\.4\\)\\] {\n' + ' padding: 1rem/* 16px */;\n' + '}',
expectedRange: {
start: { line: 0, character: 12 },
end: { line: 0, character: 32 },
},
})
testHover('arbitrary property', {
text: '<div class="[text-wrap:balance]">',
position: { line: 0, character: 13 },
expected: '.\\[text-wrap\\:balance\\] {\n' + ' text-wrap: balance;\n' + '}',
expectedRange: {
start: { line: 0, character: 12 },
end: { line: 0, character: 31 },
},
})
})

View File

@ -0,0 +1,9 @@
const glob = require('fast-glob')
const path = require('path')
const childProcess = require('child_process')
const fixtures = glob.sync('tests/fixtures/*/package.json')
for (let fixture of fixtures) {
childProcess.execSync('npm install', { cwd: path.dirname(fixture) })
}