Add deterministic-tests (#190)
Reviewed-on: https://home.schmelczer.dev/git/git/andras/vault-link/pulls/190 Co-authored-by: Andras Schmelczer <andras@schmelczer.dev> Co-committed-by: Andras Schmelczer <andras@schmelczer.dev>
This commit is contained in:
parent
4482e0155f
commit
0e3132f96c
5 changed files with 7722 additions and 1 deletions
5
frontend/deterministic-tests/src/utils/assert.ts
Normal file
5
frontend/deterministic-tests/src/utils/assert.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function assert(value: boolean, message: string): asserts value {
|
||||
if (!value) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
150
frontend/deterministic-tests/src/utils/assertable-state.ts
Normal file
150
frontend/deterministic-tests/src/utils/assertable-state.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import type { ClientState } from "../test-definition";
|
||||
|
||||
export class AssertableState {
|
||||
public readonly files: Map<string, string>;
|
||||
public readonly clientFiles: Map<string, string>[];
|
||||
|
||||
public constructor(state: ClientState) {
|
||||
this.files = state.files;
|
||||
this.clientFiles = state.clientFiles;
|
||||
}
|
||||
|
||||
public assertFileCount(expected: number): this {
|
||||
if (this.files.size !== expected) {
|
||||
const keys = Array.from(this.files.keys()).join(", ");
|
||||
throw new Error(
|
||||
`Expected ${expected} file(s), got ${this.files.size}: [${keys}]`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertFileExists(path: string): this {
|
||||
if (!this.files.has(path)) {
|
||||
const keys = Array.from(this.files.keys()).join(", ");
|
||||
throw new Error(`Expected "${path}" to exist. Files: [${keys}]`);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertFileNotExists(path: string): this {
|
||||
if (this.files.has(path)) {
|
||||
const keys = Array.from(this.files.keys()).join(", ");
|
||||
throw new Error(
|
||||
`Expected "${path}" not to exist. Files: [${keys}]`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertContent(path: string, expected: string): this {
|
||||
this.assertFileExists(path);
|
||||
const actual = this.files.get(path) ?? "";
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`Expected "${path}" to have content "${expected}", got: "${actual}"`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertContains(path: string, ...substrings: string[]): this {
|
||||
this.assertFileExists(path);
|
||||
const content = this.files.get(path) ?? "";
|
||||
const missing = substrings.filter((s) => !content.includes(s));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Expected "${path}" to contain ${missing.map((s) => `"${s}"`).join(", ")}. Content: "${content}"`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertContainsAny(path: string, ...substrings: string[]): this {
|
||||
this.assertFileExists(path);
|
||||
const content = this.files.get(path) ?? "";
|
||||
const found = substrings.some((s) => content.includes(s));
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`Expected "${path}" to contain at least one of ${substrings.map((s) => `"${s}"`).join(", ")}. Content: "${content}"`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertAnyFileContains(...substrings: string[]): this {
|
||||
const allContent = Array.from(this.files.values()).join("\n");
|
||||
const missing = substrings.filter((s) => !allContent.includes(s));
|
||||
if (missing.length > 0) {
|
||||
const dump = Array.from(this.files.entries())
|
||||
.map(([k, v]) => ` ${k}: "${v}"`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`Expected some file to contain ${missing.map((s) => `"${s}"`).join(", ")}.\nFiles:\n${dump}`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertNoFileContains(...substrings: string[]): this {
|
||||
const offenders: { path: string; substring: string }[] = [];
|
||||
for (const [path, content] of this.files) {
|
||||
for (const s of substrings) {
|
||||
if (content.includes(s)) {
|
||||
offenders.push({ path, substring: s });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (offenders.length > 0) {
|
||||
const dump = Array.from(this.files.entries())
|
||||
.map(([k, v]) => ` ${k}: "${v}"`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`Expected no file to contain ${substrings.map((s) => `"${s}"`).join(", ")}, but found ${offenders.map((o) => `"${o.substring}" in "${o.path}"`).join(", ")}.\nFiles:\n${dump}`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertSubstringCount(
|
||||
path: string,
|
||||
substring: string,
|
||||
expected: number
|
||||
): this {
|
||||
this.assertFileExists(path);
|
||||
const content = this.files.get(path) ?? "";
|
||||
const actual = content.split(substring).length - 1;
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`Expected "${substring}" to appear ${expected} time(s) in "${path}", found ${actual}. Content: "${content}"`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertContentInAtMostOneFile(substring: string): this {
|
||||
const matches = Array.from(this.files.entries()).filter(([, content]) =>
|
||||
content.includes(substring)
|
||||
);
|
||||
if (matches.length > 1) {
|
||||
const dump = Array.from(this.files.entries())
|
||||
.map(([k, v]) => ` ${k}: "${v}"`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
`Expected "${substring}" in at most 1 file, found in ${matches.length}: [${matches.map(([p]) => p).join(", ")}].\nFiles:\n${dump}`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public ifFileExists(path: string, fn: (state: this) => void): this {
|
||||
if (this.files.has(path)) {
|
||||
fn(this);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public getContent(path: string): string {
|
||||
return this.files.get(path) ?? "";
|
||||
}
|
||||
}
|
||||
29
frontend/deterministic-tests/src/utils/find-free-port.ts
Normal file
29
frontend/deterministic-tests/src/utils/find-free-port.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as net from "node:net";
|
||||
|
||||
interface PortReservation {
|
||||
port: number;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a free port and keep it reserved until the caller explicitly releases it.
|
||||
*/
|
||||
export async function findFreePort(): Promise<PortReservation> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
if (addr === null || typeof addr === "string") {
|
||||
server.close();
|
||||
reject(new Error("Failed to get port from server"));
|
||||
return;
|
||||
}
|
||||
const { port } = addr;
|
||||
resolve({
|
||||
port,
|
||||
release: () => server.close()
|
||||
});
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
3
frontend/deterministic-tests/src/utils/sleep.ts
Normal file
3
frontend/deterministic-tests/src/utils/sleep.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
15
frontend/deterministic-tests/src/utils/with-timeout.ts
Normal file
15
frontend/deterministic-tests/src/utils/with-timeout.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(message));
|
||||
}, timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue