Files

512 lines
13 KiB
TypeScript

import type { Arrayable } from '@vitest/utils'
import type { RunnerTestFile as File, RunnerTask as Task, RunnerTaskResultPack as TaskResultPack, RunnerTestCase as Test, TestArtifact } from 'vitest'
import type { CollectFilteredTests, CollectorInfo, Filter, FilteredTests, SearchMatcher } from '~/composables/explorer/types'
import { toArray } from '@vitest/utils/helpers'
import { client, findById } from '~/composables/client'
import { testRunState } from '~/composables/client/state'
import { expandNodesOnEndRun } from '~/composables/explorer/expand'
import { runFilter, testMatcher } from '~/composables/explorer/filter'
import { explorerTree } from '~/composables/explorer/index'
import {
initialized,
openedTreeItems,
treeFilter,
uiEntries,
uiFiles,
} from '~/composables/explorer/state'
import {
createOrUpdateFileNode,
createOrUpdateNodeTask,
createOrUpdateSuiteTask,
isRunningTestNode,
isSlowTestTask,
} from '~/composables/explorer/utils'
import { isSuite } from '~/utils/task'
import { hasFailedSnapshot } from '../../../../vitest/src/utils/tasks'
export { hasFailedSnapshot }
export function runLoadFiles(
remoteFiles: File[],
collect: boolean,
search: SearchMatcher,
filter: Filter,
) {
remoteFiles.map(f => [`${f.filepath}:${f.projectName || ''}`, f] as const)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, f]) => createOrUpdateFileNode(f, collect))
uiFiles.value = [...explorerTree.root.tasks]
runFilter(search, {
failed: filter.failed,
success: filter.success,
skipped: filter.skipped,
slow: filter.slow,
onlyTests: filter.onlyTests,
})
}
export function preparePendingTasks(packs: TaskResultPack[]) {
queueMicrotask(() => {
const pending = explorerTree.pendingTasks
const idMap = client.state.idMap
for (const pack of packs) {
const result = pack[1]
if (result) {
const task = idMap.get(pack[0])
if (task) {
let file = pending.get(task.file.id)
if (!file) {
file = new Set()
pending.set(task.file.id, file)
}
file.add(task.id)
}
}
}
})
}
export function recordTestArtifact(
id: string,
artifact: TestArtifact,
) {
const pending = explorerTree.pendingTasks
const idMap = client.state.idMap
const test = idMap.get(id)
if (test?.type === 'test') {
let file = pending.get(test.file.id)
if (!file) {
file = new Set()
pending.set(test.file.id, file)
}
file.add(test.id)
if (artifact.type === 'internal:annotation') {
test.annotations.push(artifact.annotation)
}
else {
test.artifacts.push(artifact)
}
}
}
export function runCollect(
start: boolean,
end: boolean,
summary: CollectorInfo,
search: SearchMatcher,
filter: Filter,
executionTime: number,
) {
if (start) {
resetCollectorInfo(summary)
}
const collect = !start
queueMicrotask(() => {
if (end) {
traverseFiles(collect)
}
else {
traverseReceivedFiles(collect)
}
})
queueMicrotask(() => {
collectData(summary, executionTime)
})
queueMicrotask(() => {
if (end) {
summary.failedSnapshot = uiFiles.value && hasFailedSnapshot(
uiFiles.value.map(f => findById(f.id)!),
)
summary.failedSnapshotEnabled = true
}
})
queueMicrotask(() => {
doRunFilter(search, filter, end)
})
}
function* collectRunningTodoTests() {
yield* uiEntries.value.filter(isRunningTestNode)
}
function updateRunningTodoTests() {
const idMap = client.state.idMap
let task: Task | undefined
for (const test of collectRunningTodoTests()) {
// lookup the parent
task = idMap.get(test.parentId)
if (task && isSuite(task) && task.mode === 'todo') {
task = idMap.get(test.id)
if (task) {
task.mode = 'todo'
}
}
}
}
function traverseFiles(collect: boolean) {
// add missing files: now we have only files with running tests on the initial ws open event
const files = client.state.getFiles()
const currentFiles = explorerTree.nodes
const missingFiles = files.filter(f => !currentFiles.has(f.id))
for (let i = 0; i < missingFiles.length; i++) {
createOrUpdateFileNode(missingFiles[i], collect)
createOrUpdateEntry(missingFiles[i].tasks)
}
// update pending tasks
const rootTasks = explorerTree.root.tasks
// collect remote children
for (let i = 0; i < rootTasks.length; i++) {
const fileNode = rootTasks[i]
const file = findById(fileNode.id)
if (!file) {
continue
}
createOrUpdateFileNode(file, collect)
const tasks = file.tasks
if (!tasks?.length) {
continue
}
createOrUpdateEntry(file.tasks)
}
}
function traverseReceivedFiles(collect: boolean) {
const updatedFiles = new Map(explorerTree.pendingTasks.entries())
explorerTree.pendingTasks.clear()
// add missing files: now we have only files with running tests on the initial ws open event
const currentFiles = explorerTree.nodes
const missingFiles = Array
.from(updatedFiles.keys())
.filter(id => !currentFiles.has(id))
.map(id => findById(id))
.filter(Boolean) as File[]
let newFile: File
for (let i = 0; i < missingFiles.length; i++) {
newFile = missingFiles[i]
createOrUpdateFileNode(newFile, false)
createOrUpdateEntry(newFile.tasks)
// remove the file from the updated files
updatedFiles.delete(newFile.id)
}
// collect remote children
const idMap = client.state.idMap
const rootTasks = explorerTree.root.tasks
for (let i = 0; i < rootTasks.length; i++) {
const fileNode = rootTasks[i]
const file = findById(fileNode.id)
if (!file) {
continue
}
const entries = updatedFiles.get(file.id)
if (!entries) {
continue
}
createOrUpdateFileNode(file, collect)
createOrUpdateEntry(Array.from(entries, id => idMap.get(id)).filter(Boolean) as Task[])
}
}
function doRunFilter(
search: SearchMatcher,
filter: Filter,
end = false,
) {
const expandAll = treeFilter.value.expandAll
const resetExpandAll = expandAll !== true
const ids = new Set(openedTreeItems.value)
const applyExpandNodes = (ids.size > 0 && expandAll === false) || resetExpandAll
// refresh explorer
queueMicrotask(() => {
refreshExplorer(search, filter, end)
})
// initialize the explorer
if (!initialized.value) {
queueMicrotask(() => {
if (uiEntries.value.length || end) {
initialized.value = true
}
})
}
if (applyExpandNodes) {
// expand all nodes
queueMicrotask(() => {
expandNodesOnEndRun(ids, end)
if (resetExpandAll) {
treeFilter.value.expandAll = false
}
})
// refresh explorer
queueMicrotask(() => {
refreshExplorer(search, filter, end)
})
}
}
function refreshExplorer(search: SearchMatcher, filter: Filter, end: boolean) {
runFilter(search, filter)
// update only at the end
if (end) {
updateRunningTodoTests()
testRunState.value = 'idle'
}
}
function createOrUpdateEntry(tasks: Task[]) {
let task: Task
for (let i = 0; i < tasks.length; i++) {
task = tasks[i]
if (isSuite(task)) {
createOrUpdateSuiteTask(task.id, true)
}
else {
createOrUpdateNodeTask(task.id)
}
}
}
function resetCollectorInfo(summary: CollectorInfo) {
summary.files = 0
summary.time = ''
summary.filesFailed = 0
summary.filesSuccess = 0
summary.filesIgnore = 0
summary.filesRunning = 0
summary.filesSkipped = 0
summary.filesTodo = 0
summary.testsFailed = 0
summary.testsSuccess = 0
summary.testsIgnore = 0
summary.testsSkipped = 0
summary.testsTodo = 0
summary.testsExpectedFail = 0
summary.testsSlow = 0
summary.totalTests = 0
summary.failedSnapshotEnabled = false
}
function collectData(
summary: CollectorInfo,
time: number,
) {
const idMap = client.state.idMap
const filesMap = new Map(explorerTree.root.tasks.filter(f => idMap.has(f.id)).map(f => [f.id, f]))
const useFiles = Array.from(filesMap.values(), file => [file.id, findById(file.id)] as const)
const data = {
files: filesMap.size,
time: time > 1000 ? `${(time / 1000).toFixed(2)}s` : `${Math.round(time)}ms`,
filesFailed: 0,
filesSuccess: 0,
filesIgnore: 0,
filesRunning: 0,
filesSkipped: 0,
filesTodo: 0,
filesSnapshotFailed: 0,
testsFailed: 0,
testsSuccess: 0,
testsIgnore: 0,
testsSkipped: 0,
testsTodo: 0,
testsExpectedFail: 0,
testsSlow: 0,
totalTests: 0,
failedSnapshot: false,
failedSnapshotEnabled: false,
} satisfies CollectorInfo
for (const [_, f] of useFiles) {
if (!f) {
continue
}
if (f.result?.state === 'fail') {
data.filesFailed++
}
else if (f.result?.state === 'pass') {
data.filesSuccess++
}
else if (f.mode === 'skip') {
data.filesIgnore++
data.filesSkipped++
}
else if (f.mode === 'todo') {
data.filesIgnore++
data.filesTodo++
}
else {
data.filesRunning++
}
const {
failed,
success,
skipped,
total,
ignored,
todo,
expectedFail,
slow,
} = collectTests(f)
data.totalTests += total
data.testsFailed += failed
data.testsSuccess += success
data.testsSkipped += skipped
data.testsTodo += todo
data.testsExpectedFail += expectedFail
data.testsSlow += slow
data.testsIgnore += ignored
}
summary.files = data.files
summary.time = data.time
summary.filesFailed = data.filesFailed
summary.filesSuccess = data.filesSuccess
summary.filesIgnore = data.filesIgnore
summary.filesRunning = data.filesRunning
summary.filesSkipped = data.filesSkipped
summary.filesTodo = data.filesTodo
summary.testsFailed = data.testsFailed
summary.testsSuccess = data.testsSuccess
summary.testsTodo = data.testsTodo
summary.testsExpectedFail = data.testsExpectedFail
summary.testsSlow = data.testsSlow
summary.testsIgnore = data.testsIgnore
summary.testsSkipped = data.testsSkipped
summary.totalTests = data.totalTests
}
function collectTests(file: File, search: SearchMatcher = () => true, filter?: Filter) {
const data = {
failed: 0,
success: 0,
skipped: 0,
running: 0,
total: 0,
ignored: 0,
todo: 0,
expectedFail: 0,
slow: 0,
} satisfies CollectFilteredTests
for (const t of testsCollector(file)) {
if (!filter || testMatcher(t, search, filter)) {
data.total++
if (isSlowTestTask(t)) {
data.slow++
}
if (t.result?.state === 'fail') {
data.failed++
}
else if (t.result?.state === 'pass') {
// Check if this is an expected failure
if (t.fails) {
data.expectedFail++
}
else {
data.success++
}
}
else if (t.mode === 'skip') {
data.ignored++
data.skipped++
}
else if (t.mode === 'todo') {
data.ignored++
data.todo++
}
}
}
data.running = data.total - data.failed - data.success - data.ignored - data.expectedFail
return data
}
export function collectTestsTotalData(
filtered: boolean,
onlyTests: boolean,
tests: File[],
filesSummary: FilteredTests,
search: SearchMatcher,
filter: Filter,
) {
if (onlyTests) {
// todo: apply similar logic when filtered
return tests
.map(file => collectTests(file, search, filter))
.reduce((acc, {
failed,
success,
ignored,
running,
}) => {
acc.failed += failed
acc.success += success
acc.skipped += ignored
acc.running += running
return acc
}, { failed: 0, success: 0, skipped: 0, running: 0 })
}
else if (filtered) {
const data = {
failed: 0,
success: 0,
skipped: 0,
running: 0,
} satisfies FilteredTests
// will match when the filter entry is active or filter is inactive (skipped excluded)
// for example, we should update all when the filter is empty
// but shouldn't update failed if we're filtering by success
const empty = !filter.success && !filter.failed
const applyFailed = filter.failed || empty
const applySuccess = filter.success || empty
for (const f of tests) {
if (f.result?.state === 'fail') {
data.failed += applyFailed ? 1 : 0
}
else if (f.result?.state === 'pass') {
data.success += applySuccess ? 1 : 0
}
else if (f.mode === 'skip' || f.mode === 'todo') {
// just ignore
}
else {
data.running++
}
}
return data
}
return filesSummary
}
function* testsCollector(suite: Arrayable<Task>): Generator<Test> {
const arraySuites = toArray(suite)
let s: Task
for (let i = 0; i < arraySuites.length; i++) {
s = arraySuites[i]
if (s.type === 'test') {
yield s
}
else {
yield* testsCollector(s.tasks)
}
}
}