Files
Immich/web/test/common/expectations.ts
T
Jason Rasmussen 8e77a8c6f8 refactor: web tests (#32983)
* refactor: web tests

* fix: e2e ratelimits

* fix: prefer data-testid

* chore: pr feedback
2026-11-05 20:36:37 +03:00

139 lines
4.8 KiB
TypeScript

import type { Dispatcher } from '#test/common/dispatcher.ts';
type Entry<T> = { value: T; seen: boolean; expected: boolean };
type Expectation<T> = { description: string; matches: (value: T) => boolean; met: boolean };
export const matchesCall = (value: unknown, expected: unknown, equals: (value: unknown, expected: unknown) => void) => {
if (expected instanceof RegExp) {
return typeof value === 'string' && expected.test(value);
}
try {
equals(value, expected);
return true;
} catch {
return false;
}
};
export class Spy<TCall> {
private readonly entries: Entry<TCall>[] = [];
private readonly expectations: Expectation<TCall>[] = [];
constructor(readonly name: string) {}
get calls(): ReadonlyArray<TCall> {
return this.entries.map((entry) => entry.value);
}
record(value: TCall, { expected = false } = {}) {
const pending = this.expectations.find((expectation) => !expectation.met && expectation.matches(value));
if (pending) {
pending.met = true;
}
this.entries.push({ value, seen: pending !== undefined, expected });
}
expect(description: string, matches: (value: TCall) => boolean) {
const found = this.entries.filter((entry) => matches(entry.value));
for (const entry of found) {
entry.seen = true;
}
if (found.length === 0) {
this.expectations.push({ description, matches, met: false });
}
}
unexpected() {
return this.entries.filter((entry) => entry.expected && !entry.seen).map((entry) => entry.value);
}
missing() {
return this.expectations.filter((expectation) => !expectation.met).map(({ description }) => description);
}
}
const maxLines = 3;
const settleTimeout = 2000;
const settleInterval = 50;
type Logger = { error: Spy<string> };
const fixes = {
'Handler errors': ['the shared handler in test/common/handlers.ts threw; fix it there'],
'Unhandled requests': [
'add a handler in test/common/handlers.ts, or one for this test only',
' api.on({ getAlbumInfo: () => ({ json: factory.album() }) });',
" api.on({ getAlbumInfo: () => ({ status: 500, json: { message: 'Server error', statusCode: 500 } }) });",
],
'Network requests': [
'assert the mutation the test expects',
" expect(api.updateAlbumInfo).toHaveBeenCalledWith({ params: { id: album.id }, body: { description: 'New' } });",
],
'Browser errors': [
'assert the console.error or page error the test expects',
' expect(logger.error).toHaveBeenCalledWith(/Server error/);',
],
'Missing events': ['the test expected an event that never happened; check the action that should trigger it'],
} as const;
type ProblemKind = keyof typeof fixes;
type Problem = { kind: ProblemKind; line: string };
const missingEvents = (api: Dispatcher, logger: Logger) => [...api.missingExpectations(), ...logger.error.missing()];
export const settleExpectations = async (api: Dispatcher, logger: Logger) => {
const deadline = Date.now() + settleTimeout;
while (missingEvents(api, logger).length > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, settleInterval));
}
};
export const assertMockClean = (api: Dispatcher, logger: Logger) => {
const problems: Problem[] = [
...api.failures.map((line) => ({ kind: 'Handler errors' as const, line })),
...api.unhandled.map((line) => ({ kind: 'Unhandled requests' as const, line })),
...api.unexpectedCalls().map((line) => ({ kind: 'Network requests' as const, line })),
...logger.error.unexpected().map((line) => ({ kind: 'Browser errors' as const, line })),
...missingEvents(api, logger).map((line) => ({ kind: 'Missing events' as const, line })),
];
if (problems.length > 0) {
throw new Error(`Not every expected event was handled.\n\n${formatProblems(problems).join('\n\n')}`);
}
};
const formatProblems = (problems: Problem[]) => {
const kinds = new Map<ProblemKind, string[]>();
for (const { kind, line } of problems) {
kinds.set(kind, [...new Set([...(kinds.get(kind) ?? []), line])]);
}
return [...kinds].map(([kind, lines]) => formatSection(kind, lines).join('\n'));
};
const formatSection = (kind: ProblemKind, lines: string[]) => {
const [summary, ...code] = fixes[kind];
const heading = `── ${kind} (${lines.length}) `;
return [
heading.padEnd(66, '─'),
...indent(
lines.flatMap((line, index) => formatOffender(line, index)),
2,
),
'',
` Example fix: ${summary}`,
...indent(code, 2),
];
};
const formatOffender = (line: string, index: number) => {
const [first, ...rest] = line.split('\n', maxLines);
return [
`${index + 1}. ${first}`,
...indent(
rest.map((frame) => frame.trim()),
5,
),
];
};
const indent = (lines: readonly string[], width: number) => lines.map((line) => `${' '.repeat(width)}${line}`);