Repository navigation
Expand file tree
/
Copy pathproject_tests.lua
More file actions
216 lines (202 loc) · 6.62 KB
/
Copy pathproject_tests.lua
File metadata and controls
216 lines (202 loc) · 6.62 KB
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
-- Which kinds of tests the project in the current folder has (Vitest, Jest,
-- Playwright) and where its Playwright folders are, for the status line and the
-- test list (,mt). Read in the background from git's list of files, once per
-- folder, so it finds them in monorepos and in subfolders like tests/e2e
local M = {}
---@class ProjectTests
---@field vitest boolean
---@field jest boolean
---@field playwright string[] folders with a Playwright config
---@type table<string, ProjectTests|false> false while it's being read
local found = {}
---@type table<string, fun(tests: ProjectTests)[]>
local waiting = {}
-- bumped when a config file is saved, so a read that started before is ignored
local generation = 0
local configs = { 'vitest.config.*', 'vitest.workspace.*', 'jest.config.*', 'playwright.config.*' }
---@param path string
---@return table?
local function read_json(path)
local fd = io.open(path)
if not fd then
return
end
local ok, data = pcall(vim.json.decode, fd:read('*a'))
fd:close()
return ok and type(data) == 'table' and data or nil
end
---@param dir string
---@param files string[] relative to dir
---@return ProjectTests
local function classify(dir, files)
local tests = { vitest = false, jest = false, playwright = {} }
-- packages that depend on Playwright: one without a config of its own runs with
-- Playwright's defaults, from its own folder
local depends, configs_found = {}, {}
for _, rel in ipairs(files) do
local name = vim.fs.basename(rel)
local folder = vim.fs.normalize(vim.fs.joinpath(dir, vim.fs.dirname(rel)))
if name == 'package.json' then
local pkg = read_json(vim.fs.joinpath(dir, rel)) or {}
local deps = {}
for _, field in ipairs({ 'dependencies', 'devDependencies' }) do
if type(pkg[field]) == 'table' then
deps = vim.tbl_extend('force', deps, pkg[field])
end
end
tests.vitest = tests.vitest or deps.vitest ~= nil
tests.jest = tests.jest or deps.jest ~= nil or pkg.jest ~= nil
if deps['@playwright/test'] then
depends[#depends + 1] = folder
end
elseif name:match('^vitest%.') then
tests.vitest = true
elseif name:match('^jest%.') then
tests.jest = true
elseif name:match('^playwright%.') then
tests.playwright[#tests.playwright + 1] = folder
configs_found[#configs_found + 1] = vim.fs.joinpath(folder, name)
end
end
-- unless a config runs its tests: one inside it, or one above whose testDir
-- reaches it (without a testDir, a config runs everything below it)
local test_files = require('config.test_files')
for _, folder in ipairs(depends) do
local covered = vim.iter(configs_found):any(function(config)
local at = vim.fs.dirname(config)
if at == folder or vim.startswith(at, folder .. '/') then
return true
elseif not vim.startswith(folder, at .. '/') then
return false
end
local dirs = test_files.playwright_config(config).dirs
return #dirs == 0
or vim.iter(dirs):any(function(d)
return d == folder or vim.startswith(d, folder .. '/') or vim.startswith(folder, d .. '/')
end)
end)
if not covered then
tests.playwright[#tests.playwright + 1] = folder
end
end
table.sort(tests.playwright)
return tests
end
---@param name string
---@return boolean
local function is_config(name)
return name:match('^vitest%.config%.')
or name:match('^vitest%.workspace%.')
or name:match('^jest%.config%.')
or name:match('^playwright%.config%.')
end
---Outside a git repository, only the folder itself is looked at
---@param dir string
---@return string[]
local function own_files(dir)
local files = {}
for name, type in vim.fs.dir(dir) do
if type == 'file' and (name == 'package.json' or is_config(name)) then
files[#files + 1] = name
end
end
return files
end
---@param dir string
---@param done fun(tests: ProjectTests)
local function read(dir, done)
local cmd = { 'git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--' }
for _, glob in ipairs(vim.list_extend({ 'package.json' }, configs)) do
cmd[#cmd + 1] = ':(glob)**/' .. glob
end
cmd[#cmd + 1] = ':(exclude,glob)**/node_modules/**'
local ok = pcall(vim.system, cmd, { cwd = dir, text = true }, function(result)
vim.schedule(function()
local files = result.code == 0 and vim.split(result.stdout, '\0', { plain = true, trimempty = true })
or own_files(dir)
done(classify(dir, files))
end)
end)
if not ok then
done(classify(dir, own_files(dir)))
end
end
---The tests of the project in `dir`, or nil while they're still being looked for
---@param dir? string defaults to the current folder
---@return ProjectTests?
function M.get(dir)
dir = dir or vim.fn.getcwd()
if found[dir] == nil then
found[dir] = false
local started = generation
-- not straight away: the status line asks while it's being drawn
vim.schedule(function()
read(dir, function(tests)
if started ~= generation then
return
end
found[dir] = tests
for _, fn in ipairs(waiting[dir] or {}) do
fn(tests)
end
waiting[dir] = nil
vim.cmd.redrawstatus()
end)
end)
end
return found[dir] or nil
end
---Calls `fn` with the project's tests, as soon as they're known
---@param dir string
---@param fn fun(tests: ProjectTests)
function M.when_ready(dir, fn)
local tests = M.get(dir)
if tests then
fn(tests)
else
waiting[dir] = waiting[dir] or {}
table.insert(waiting[dir], fn)
end
end
---"Vitest / Jest", "Vitest" or "Jest": the test list's name for that area
---@param tests ProjectTests
---@return string?
function M.unit_name(tests)
local names = {}
if tests.vitest then
names[#names + 1] = 'Vitest'
end
if tests.jest then
names[#names + 1] = 'Jest'
end
return #names > 0 and table.concat(names, ' / ') or nil
end
---For the status line: " Jest · Playwright", or '' when the project has no tests
---@return string
function M.label()
local tests = M.get()
if not tests then
return ''
end
local names = {}
if tests.vitest then
names[#names + 1] = 'Vitest'
end
if tests.jest then
names[#names + 1] = 'Jest'
end
if #tests.playwright > 0 then
names[#names + 1] = 'Playwright'
end
return #names > 0 and (' ' .. table.concat(names, ' · ')) or ''
end
vim.api.nvim_create_autocmd('BufWritePost', {
group = vim.api.nvim_create_augroup('user.project_tests', { clear = true }),
pattern = vim.list_extend({ 'package.json' }, configs),
callback = function()
found = {}
generation = generation + 1
vim.cmd.redrawstatus()
end,
})
return M