mirror of
https://github.com/tutao/tutanota.git
synced 2025-10-19 16:03:43 +00:00

- Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
343 lines
9.3 KiB
TypeScript
343 lines
9.3 KiB
TypeScript
import { ansiSequences, fancy } from "./fancy.js"
|
|
import { Assertion } from "./Assertion.js"
|
|
import { RunResult, TestResult } from "./TestResult.js"
|
|
|
|
class OTest {
|
|
private static readonly DEFAULT_TIMEOUT_MS = 200
|
|
private taskTree: Spec = { name: "O", specs: [], tests: [], before: [], after: [], beforeEach: [], afterEach: [] }
|
|
private currentSpec = this.taskTree
|
|
private currentTest: TestResult | null = null
|
|
|
|
/**
|
|
* Define a group of tests.
|
|
* Spec may contain:
|
|
* * tests
|
|
* * before/beforeEach/after/afterEach clauses
|
|
* * other specs
|
|
*
|
|
* Example:
|
|
* ```ts
|
|
* o.spec("testableFunction", () => {
|
|
* o.test("it works", () => {
|
|
* o.check(testableFunction(1)).equals(2)
|
|
* })
|
|
* })
|
|
* ```
|
|
*/
|
|
spec(name: string, definition: () => void) {
|
|
const previousCurrentSpec = this.currentSpec
|
|
const newSpec = (this.currentSpec = {
|
|
name,
|
|
tests: [],
|
|
specs: [],
|
|
before: [],
|
|
after: [],
|
|
beforeEach: [],
|
|
afterEach: [],
|
|
})
|
|
Object.defineProperty(definition, "name", { value: name, writable: false })
|
|
const definitionResult = definition()
|
|
if (typeof definitionResult !== "undefined") {
|
|
throw new Error(`Invalid spec definition for "${previousCurrentSpec.name} > ${name}"! Is it async by any chance?`)
|
|
}
|
|
this.currentSpec = previousCurrentSpec
|
|
this.currentSpec.specs.push(newSpec)
|
|
}
|
|
|
|
/**
|
|
* Define a test.
|
|
* Tests may be async in which case they should either await or return a promise.
|
|
* Timeouts apply and can be changed with {@link timeout}.
|
|
*/
|
|
test(name: string, definition: (() => Promise<void>) | (() => void)) {
|
|
Object.defineProperty(definition, "name", { value: name, writable: false })
|
|
this.currentSpec.tests.push({ name, task: definition })
|
|
}
|
|
|
|
/**
|
|
* Start an assertion.
|
|
*/
|
|
check<T>(value: T): Assertion<T> {
|
|
if (this.currentTest == null) {
|
|
throw new Error("Assertion outside of running test!")
|
|
}
|
|
return new Assertion(value, this.currentTest)
|
|
}
|
|
|
|
/**
|
|
* Define a task to be executed before any test in the spec (once per spec).
|
|
*/
|
|
before(task: () => Promise<void> | void) {
|
|
this.currentSpec.before.push(task)
|
|
}
|
|
|
|
/**
|
|
* Define a task to be executed after all test in the spec (once per spec).
|
|
*/
|
|
after(task: () => Promise<void> | void) {
|
|
this.currentSpec.after.push(task)
|
|
}
|
|
|
|
/**
|
|
* Define a task to be executed before each test in the spec (once per test).
|
|
* Also applies to tests in nested specs.
|
|
*/
|
|
beforeEach(task: () => Promise<void> | void) {
|
|
this.currentSpec.beforeEach.push(task)
|
|
}
|
|
|
|
/**
|
|
* Define a task to be executed after each test in the spec (once per test).
|
|
* Also applies to tests in nested specs.
|
|
*/
|
|
afterEach(task: () => Promise<void> | void) {
|
|
this.currentSpec.afterEach.push(task)
|
|
}
|
|
|
|
/**
|
|
* Set a timeout (in ms) for the currently running test.
|
|
*/
|
|
timeout(ms: number) {
|
|
if (this.currentTest === null) {
|
|
throw new Error("timeout() call outside of test")
|
|
} else if (this.currentTest.timeout != null) {
|
|
throw new Error(`timeout is already set! ${this.currentTest}`)
|
|
} else {
|
|
this.currentTest.timeout = ms
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the tests that were previously defined.
|
|
* @param {string} filter: only run tests that match the filter string in either spec name or a test name.
|
|
*/
|
|
async run({ filter }: { filter?: string } = {}): Promise<RunResult> {
|
|
const runResult: RunResult = { passedTests: [], failingTests: [], skippedTests: [] }
|
|
|
|
function processSpecResult(spec: SpecResult, path: SpecResult[]) {
|
|
const pathNames = path.map((s) => s.name).concat(spec.name)
|
|
for (const test of spec.testResults) {
|
|
if (test.errors.length) {
|
|
runResult.failingTests.push({ path: pathNames, result: test })
|
|
} else if (test.skipped) {
|
|
runResult.skippedTests.push({ path: pathNames, result: test })
|
|
} else {
|
|
runResult.passedTests.push({ path: pathNames, result: test })
|
|
}
|
|
}
|
|
|
|
for (const subspec of spec.specResults) {
|
|
processSpecResult(subspec, [...path, spec])
|
|
}
|
|
}
|
|
|
|
const topSpecResult = await this.runSpec(this.currentSpec, [], filter ?? "")
|
|
processSpecResult(topSpecResult, [])
|
|
return runResult
|
|
}
|
|
|
|
private async runSpec(spec: Spec, path: Spec[], filter: string): Promise<SpecResult> {
|
|
const newPath = [...path, spec]
|
|
const newPathSerialized = newPath.map((s) => s.name).join(" > ")
|
|
|
|
let printSpecOnce = () => {
|
|
printSpecOnce = () => {}
|
|
console.log(fancy("SPEC", ansiSequences.greenBg), newPathSerialized)
|
|
}
|
|
|
|
for (const before of spec.before) {
|
|
try {
|
|
await before()
|
|
} catch (e) {
|
|
console.error("Spec before() failed!", newPathSerialized, e)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
const specMatches = filter === "" || spec.name.includes(filter)
|
|
|
|
const result = {
|
|
name: spec.name,
|
|
specResults: await promiseMap(spec.specs, (nestedSpec) => this.runSpec(nestedSpec, newPath, specMatches ? "" : filter)),
|
|
testResults: await promiseMap(spec.tests, async (test) => {
|
|
if (specMatches || test.name.includes(filter)) {
|
|
printSpecOnce()
|
|
printSpecOnce = () => {}
|
|
const allBeforeEach = [...path.flatMap((s) => s.beforeEach), ...spec.beforeEach]
|
|
for (const beforeEach of allBeforeEach) {
|
|
await beforeEach()
|
|
}
|
|
console.log(" ", fancy("TEST", ansiSequences.greenBg), test.name)
|
|
|
|
const testResult = await this.runTest(test)
|
|
|
|
const allAfterEach = [...path.flatMap((s) => s.afterEach), ...spec.afterEach]
|
|
for (const afterEach of allAfterEach) {
|
|
await afterEach()
|
|
}
|
|
|
|
return testResult
|
|
} else {
|
|
return { name: test.name, errors: [], timeout: null, skipped: true }
|
|
}
|
|
}),
|
|
}
|
|
|
|
for (const after of spec.after) {
|
|
try {
|
|
await after()
|
|
} catch (e) {
|
|
console.error("Spec after() failed!", newPathSerialized, e)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Output the result of the test run.
|
|
* @param result
|
|
*/
|
|
printReport(result: RunResult) {
|
|
console.log(
|
|
`
|
|
|
|
${fancy("TEST FINISHED", ansiSequences.bold)}
|
|
|
|
${result.filter ? `filter: "${result.filter}"` : ""}
|
|
|
|
${fancy("passing", ansiSequences.greenBg)}: ${result.passedTests.length} ${fancy("failing", ansiSequences.redBg)}: ${result.failingTests.length} ${fancy(
|
|
"skipped",
|
|
ansiSequences.yellowBg,
|
|
)}: ${result.skippedTests.length}`,
|
|
"\n",
|
|
)
|
|
|
|
for (const test of result.failingTests) {
|
|
console.error(fancy("FAIL", ansiSequences.redBg), test.path.join(" > "), "|", test.result.name)
|
|
|
|
for (const error of test.result.errors) {
|
|
if (error.userMessage) {
|
|
console.error(fancy(error.userMessage, ansiSequences.redFg))
|
|
}
|
|
console.error(error.error)
|
|
if (error.diff) {
|
|
console.log(error.diff)
|
|
}
|
|
console.log()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A utility to exit the process with the appropriate exit code.
|
|
* only runs in node, no-op otherwise.
|
|
*/
|
|
terminateProcess(result: RunResult) {
|
|
if (typeof process !== "undefined") {
|
|
process.exit(result.failingTests.length ? 1 : 0)
|
|
}
|
|
}
|
|
|
|
private async runTest(test: Test): Promise<TestResult> {
|
|
const currentTestResult: TestResult = (this.currentTest = {
|
|
name: test.name,
|
|
errors: [],
|
|
timeout: null,
|
|
skipped: false,
|
|
})
|
|
|
|
let testResolved = false
|
|
|
|
async function startTimeoutTask() {
|
|
await new Promise((resolve) => {
|
|
if (currentTestResult.timeout == null) throw new Error("timeout not set while running timeout task!")
|
|
setTimeout(resolve, currentTestResult.timeout)
|
|
})
|
|
if (!testResolved) {
|
|
throw new Error("timed out!")
|
|
}
|
|
}
|
|
|
|
async function runTask() {
|
|
try {
|
|
const p = test.task()
|
|
currentTestResult.timeout = currentTestResult.timeout ?? OTest.DEFAULT_TIMEOUT_MS
|
|
await p
|
|
} finally {
|
|
testResolved = true
|
|
}
|
|
}
|
|
|
|
try {
|
|
// run task and timeout in parallel, if timeout counter comes first, we are timeout out
|
|
// the test task should set the timeout immediately or we will not pick it up.
|
|
await Promise.race([runTask(), startTimeoutTask()])
|
|
} catch (e) {
|
|
currentTestResult.errors.push({ error: wrapError(e), userMessage: null })
|
|
} finally {
|
|
this.currentTest = null
|
|
}
|
|
return currentTestResult
|
|
}
|
|
}
|
|
|
|
function wrapError(e: unknown) {
|
|
return e instanceof Error ? e : new Error(String(e))
|
|
}
|
|
|
|
async function promiseMap<T, R>(array: ReadonlyArray<T>, mapper: (value: T) => Promise<R>): Promise<Array<R>> {
|
|
const result = []
|
|
for (const el of array) {
|
|
result.push(await mapper(el))
|
|
}
|
|
return result
|
|
}
|
|
|
|
interface SpecResult {
|
|
name: string
|
|
testResults: TestResult[]
|
|
specResults: SpecResult[]
|
|
}
|
|
|
|
interface Test {
|
|
name: string
|
|
|
|
task(): Promise<void> | void
|
|
}
|
|
|
|
interface Spec {
|
|
name: string
|
|
tests: Test[]
|
|
specs: Spec[]
|
|
before: (() => void)[]
|
|
after: (() => void)[]
|
|
beforeEach: (() => void)[]
|
|
afterEach: (() => void)[]
|
|
}
|
|
|
|
// this weird API is inherited from ospec, we would like to eventually deprecate it with proper functions like o.test and o.check but this API is TBD
|
|
export type CallableOTest = OTest & {
|
|
/** An alias for {@link OTest.test}. Discouraged. */
|
|
(name: string, definition: () => Promise<void> | void): void
|
|
|
|
/** An alias for {@link OTest.check}. Discouraged. */ <T>(actual: T): Assertion<T>
|
|
}
|
|
|
|
const otest = new OTest()
|
|
|
|
function o<T>(item: T | string, definition?: () => Promise<void> | void) {
|
|
// we need to do these tricks otherwise "this" reference will be lost
|
|
const oo = o as unknown as OTest
|
|
if (typeof definition === "undefined") {
|
|
return oo.check(item)
|
|
} else {
|
|
oo.test(item as string, definition)
|
|
}
|
|
}
|
|
|
|
Object.assign(o, otest)
|
|
Object.setPrototypeOf(o, Object.getPrototypeOf(otest))
|
|
|
|
export default o as CallableOTest
|