@lewiswright/vitest-plugin-vis
Version:
Vitest visual testing plugin
200 lines (182 loc) • 6.59 kB
text/typescript
import { join, relative, resolve } from 'pathe'
import { pick } from 'type-plus'
import type { VisOptions } from '../config/types.ts'
import { BASELINE_DIR, DIFF_DIR, RESULT_DIR } from '../shared/constants.ts'
import { getProjectName } from './commands/browser_command_context.ts'
import { file } from './file.ts'
import { getSnapshotSubpath, resolveSnapshotRootDir } from './snapshot_path.ts'
import { ctx } from './vis_context.ctx.ts'
import type { PartialBrowserCommandContext, VisProjectState, VisState } from './vis_context.types.ts'
export function createVisContext() {
let visOptionsRecord: Record<string, VisOptions<any>> = {}
let state: VisState = {}
const context = {
setOptions<M extends 'pixel' | 'ssim'>(projectName: string | undefined, options: VisOptions<M> = {} as any) {
visOptionsRecord[projectName ?? '__default'] = options
},
__test__getOptions(projectName: string) {
return visOptionsRecord[projectName]
},
__test__reset() {
visOptionsRecord = {}
state = {}
},
__test__getState(context: PartialBrowserCommandContext) {
return state[getProjectId(context)]!
},
/**
* Setup suite is called on each test file's beforeAll hook.
* Test files include vitest test files and storybook story files.
* It needs to make sure there is no race condition between the test files.
*/
async setupSuite(browserContext: PartialBrowserCommandContext) {
const projectId = getProjectId(browserContext)
const visOptions = getVisOptions(visOptionsRecord, browserContext)
if (!state[projectId]) {
state[projectId] = setupState(browserContext, visOptions)
}
const projectState = await state[projectId]
const { suiteId, suite } = createSuite(projectState, browserContext.testPath, visOptions)
projectState.suites[suiteId] = suite
await Promise.allSettled([ctx.rimraf(suite.diffDir), ctx.rimraf(suite.resultDir)])
return pick(projectState, 'subjectDataTestId')
},
async getSnapshotInfo(
browserContext: PartialBrowserCommandContext,
name: string,
isAutoSnapshot: boolean,
options?: { snapshotFileId?: string | undefined },
) {
const suiteInfo = await context.getSuiteInfo(browserContext, name)
const snapshotFilename = context.getSnapshotFilename(
browserContext,
suiteInfo,
options?.snapshotFileId,
isAutoSnapshot,
)
const { baselineDir, resultDir, diffDir, task } = suiteInfo
task.count = task.count + 1
const baselinePath = join(baselineDir, snapshotFilename)
const resultPath = join(resultDir, snapshotFilename)
const diffPath = join(diffDir, snapshotFilename)
return {
...pick(
getVisOptions(visOptionsRecord, browserContext),
'comparisonMethod',
'diffOptions',
'failureThreshold',
'failureThresholdType',
'timeout',
'animations',
),
baselinePath,
resultPath,
diffPath,
}
},
async getTaskCount(browserContext: PartialBrowserCommandContext, taskId: string) {
return (await context.getSuiteInfo(browserContext, taskId)).task.count
},
async hasImageSnapshot(
browserContext: PartialBrowserCommandContext,
taskId: string,
snapshotFileId: string | undefined,
isAutoSnapshot: boolean,
) {
const info = await context.getSuiteInfo(browserContext, taskId)
return file.existFile(
resolve(
info.projectRoot,
info.baselineDir,
context.getSnapshotFilename(browserContext, info, snapshotFileId, isAutoSnapshot),
),
)
},
getSnapshotFilename(
browserContext: PartialBrowserCommandContext,
info: { taskId: string; task: { count: number } },
snapshotFileId: string | undefined,
isAutoSnapshot: boolean,
) {
if (snapshotFileId) return `${snapshotFileId}.png`
const customizeSnapshotId =
getVisOptions(visOptionsRecord, browserContext).customizeSnapshotId ?? (({ id, index }) => `${id}-${index}`)
return `${customizeSnapshotId({
id: info.taskId,
index: info.task.count,
isAutoSnapshot,
})}.png`
},
async getSuiteInfo(browserContext: PartialBrowserCommandContext, taskId: string) {
const projectId = getProjectId(browserContext)
const projectState = await state[projectId]!
const visOptions = getVisOptions(visOptionsRecord, browserContext)
const suiteId = getSuiteId(projectState, browserContext.testPath, visOptions)
const suite = projectState.suites[suiteId]!
const task = (suite.tasks[taskId] = suite.tasks[taskId] ?? { count: 1 })
return {
projectRoot: projectState.projectRoot,
suiteId,
taskId,
baselineDir: suite.baselineDir,
resultDir: suite.resultDir,
diffDir: suite.diffDir,
task,
}
},
}
return context
}
async function setupState(
browserContext: PartialBrowserCommandContext,
visOptions: Pick<VisOptions, 'snapshotRootDir' | 'platform' | 'subjectDataTestId'>,
) {
const snapshotRootDir = resolveSnapshotRootDir(browserContext, visOptions)
const projectRoot = getProjectRoot(browserContext)
const state = {
projectRoot,
testTimeout: browserContext.project.config.testTimeout,
hookTimeout: browserContext.project.config.hookTimeout,
snapshotRootDir,
snapshotBaselineDir: join(snapshotRootDir, BASELINE_DIR),
snapshotResultDir: join(snapshotRootDir, RESULT_DIR),
snapshotDiffDir: join(snapshotRootDir, DIFF_DIR),
snapshotRootPath: join(projectRoot, snapshotRootDir),
subjectDataTestId: visOptions.subjectDataTestId,
suites: {},
}
await Promise.allSettled([ctx.rimraf(join(state.snapshotDiffDir)), ctx.rimraf(join(state.snapshotResultDir))])
return state
}
export function createSuite(
state: VisProjectState,
testPath: string,
options: Pick<VisOptions, 'customizeSnapshotSubpath'>,
) {
const suiteId = getSuiteId(state, testPath, options)
return {
suiteId,
suite: {
baselineDir: join(state.snapshotBaselineDir, suiteId),
resultDir: join(state.snapshotResultDir, suiteId),
diffDir: join(state.snapshotDiffDir, suiteId),
tasks: {},
},
}
}
export function getSuiteId(
state: VisProjectState,
testPath: string,
options: Pick<VisOptions, 'customizeSnapshotSubpath'>,
) {
return getSnapshotSubpath(relative(state.projectRoot, testPath), options)
}
function getVisOptions(visOptionsRecord: Record<string, VisOptions<any>>, context: PartialBrowserCommandContext) {
return visOptionsRecord[getProjectName(context) ?? '__default'] ?? {}
}
function getProjectRoot(context: PartialBrowserCommandContext) {
return context.project.config.root
}
function getProjectId(context: PartialBrowserCommandContext) {
return `${context.project.config.root}/${context.project.config.name}`
}