import type { SpecDirectory, OptionKey } from "../spec-directory"
import { Compiler } from "../compiler"
import {
failures,
TestResult,
getExpectedFiles,
overwriteResults,
SassResult,
} from "./util"
import { compareResults } from "./compare"
import { getExpectedResult } from "./expected"
/**
* A wrapper around a SpecDirectory that represents a sass-spec test case.
*
* Contains methods for running the test and updating the underlying directory
* based on the results.
*/
export default class TestCase {
readonly dir: SpecDirectory
readonly impl: string
private compiler: Compiler
private todoMode?: string
private _actual?: SassResult
private _result?: TestResult
// Private constructor that instantiates properties.
// The only way to create a test case is through the async factory below
private constructor(
dir: SpecDirectory,
impl: string,
compiler: Compiler,
todoMode?: string
) {
this.dir = dir
this.impl = impl
this.compiler = compiler
this.todoMode = todoMode
}
/**
* Run the spec at the given directory and return a TestCase object representing it
*/
static async create(
dir: SpecDirectory,
impl: string,
compiler: Compiler,
todoMode?: string
): Promise<TestCase> {
const testCase = new TestCase(dir, impl, compiler, todoMode)
try {
testCase._result = await testCase.run()
} catch (error) {
testCase._result = { type: "error", error }
}
return testCase
}
/** Return the name of the input file of this test directory. */
private inputFile(): string {
if (this.dir.hasFile("input.sass") && this.dir.hasFile("input.scss")) {
throw new Error(`Multiple input files found in ${this.dir.relPath()}`)
}
return this.dir.hasFile("input.sass") ? "input.sass" : "input.scss"
}
/** Get the contents of the input file for this test directory. */
async input(): Promise<string> {
return await this.dir.readFile(this.inputFile())
}
// Run the compiler and calculate the actual result
private async calcActualResult(): Promise<SassResult> {
const precision = (await this.dir.options()).precision()
const cmdArgs = []
// Pass in the indented option to the command
if (precision) {
cmdArgs.push(`--precision`)
cmdArgs.push(`${precision}`)
}
cmdArgs.push(this.inputFile())
const { stdout, stderr, status } = await this.compiler.compile(
this.dir.path,
cmdArgs
)
if (status === 0) {
return { isSuccess: true, output: stdout, warning: stderr }
} else {
return { isSuccess: false, error: stderr }
}
}
// Do the test run, storing the actual output if there is one, and return the test result
private async run(): Promise<TestResult> {
const options = await this.dir.options()
const mode = options.getMode(this.impl)
const warningTodo = options.isWarningTodo(this.impl)
if (mode === "ignore") {
return { type: "skip" }
}
if (mode === "todo" && !this.todoMode) {
return { type: "todo" }
}
const [expected, actual] = await Promise.all([
getExpectedResult(this.dir, this.impl),
this.calcActualResult(),
])
this._actual = actual
const testResult = compareResults(expected, actual, {
// Compare the full error only for dart-sass
trimErrors: this.impl !== "dart-sass",
// Skip warning checks :warning_todo is enabled and we're not running todos
skipWarning: warningTodo && !this.todoMode,
})
// If we're probing todo
if (this.todoMode === "probe") {
if (mode === "todo") {
if (testResult.type === "pass") {
return failures.UnnecessaryTodo()
} else {
return { type: "todo" }
}
}
if (warningTodo) {
if (testResult.type === "pass") {
return failures.UnnecessaryTodo()
} else {
return { type: "pass" }
}
}
}
return testResult
}
actual(): SassResult {
if (!this._actual) {
throw new Error(`Test case ${this.dir.relPath()} has not yet run.`)
}
return this._actual
}
result(): TestResult {
if (!this._result) {
throw new Error(`Test case ${this.dir.relPath()} has not yet run.`)
}
return this._result
}
// Mutations
/** Add the given option for the given impl */
async addOptionForImpl(option: OptionKey): Promise<void> {
const options = await this.dir.directOptions()
const updatedOptions = options.addImpl(this.impl, option)
await this.dir.writeFile("options.yml", updatedOptions.toYaml())
}
/**
* Overwrite the base results with the actual results
*/
async overwrite(): Promise<void> {
// overwrite the contents of the base files
await overwriteResults(this.dir, this.actual())
// delete any override files for this impl
await Promise.all(
getExpectedFiles(this.impl).map((filename) =>
this.dir.removeFile(filename)
)
)
this._result = { type: "pass" }
}
/**
* Migrate a copy of the expected results to pass on impl
*/
async migrateImpl(): Promise<void> {
const actual = this.actual()
await overwriteResults(this.dir, this.actual(), this.impl)
// If a nonempty base warning exists, but the actual result yields no warning,
// create a warning file
if (
this.dir.hasFile("warning") &&
this.dir.readFile("warning") &&
actual.isSuccess &&
!actual.warning
) {
await this.dir.writeFile(`warning-${this.impl}`, "")
}
this._result = { type: "pass" }
}
/** Mark this test (or its warning) as TODO */
async markTodo(): Promise<void> {
if (this.result().failureType === "warning_difference") {
await this.addOptionForImpl(":warning_todo")
this._result = { type: "pass" }
} else {
await this.addOptionForImpl(":todo")
this._result = { type: "todo" }
}
}
/** Mark this test as ignored for the current implementation */
async markIgnore(): Promise<void> {
await this.addOptionForImpl(":ignore_for")
this._result = { type: "skip" }
}
}