Skip to content

Instantly share code, notes, and snippets.

@enekesabel
Last active April 9, 2026 21:57
Show Gist options
  • Select an option

  • Save enekesabel/a23a31114fb5c9595952bf581276d807 to your computer and use it in GitHub Desktop.

Select an option

Save enekesabel/a23a31114fb5c9595952bf581276d807 to your computer and use it in GitHub Desktop.
Plain Playwright + a small toHaveState matcher for semantic state assertions
import { expect as baseExpect, type ExpectMatcherState } from '@playwright/test';
type Predicate<T> = (value: T) => boolean;
type ExpectedState = Record<string, unknown>;
type QueryEntry = readonly [key: string, expectedValue: unknown, query: () => Promise<unknown>];
export interface ToHaveStateOptions {
timeout?: number;
stableFor?: number;
}
export type AsyncQueryKeys<T> = T extends object ? {
[K in keyof T]: T[K] extends (...args: infer A) => Promise<infer R>
? A extends []
? R extends void
? never
: R extends (...args: unknown[]) => unknown
? never
: K
: never
: never;
}[keyof T] : never;
export type StateOf<T> = T extends object ? {
[K in AsyncQueryKeys<T>]?: T[K] extends () => Promise<infer R>
? R | Predicate<R>
: never;
} : never;
export interface ToHaveStateMatcher<T> {
toHaveState(
expected: StateOf<NonNullable<T>>,
options?: ToHaveStateOptions,
): Promise<void>;
not: ReturnType<typeof baseExpect<T>>['not'] & {
toHaveState(
expected: StateOf<NonNullable<T>>,
options?: ToHaveStateOptions,
): Promise<void>;
};
}
export type ToHaveStateMatchers = typeof toHaveStateMatchers;
declare const receiverBrand: unique symbol;
interface StateReceiver {
[receiverBrand]: unknown;
}
function isPredicate(value: unknown): value is Predicate<unknown> {
return typeof value === 'function';
}
function normalizeExpected(expected: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(expected)) {
normalized[key] = isPredicate(value) ? value.toString() : value;
}
return normalized;
}
function resolveQueries(received: StateReceiver, expected: ExpectedState): QueryEntry[] {
return Object.entries(expected).map(([key, expectedValue]) => {
const query = (received as unknown as Record<string, unknown>)[key];
if (typeof query !== 'function') {
throw new Error(`toHaveState: "${key}" is not a method on the received object`);
}
return [key, expectedValue, query as () => Promise<unknown>];
});
}
async function readActualState(received: StateReceiver, queries: readonly QueryEntry[]): Promise<ExpectedState> {
const actualState: ExpectedState = {};
for (const [key, expectedValue, query] of queries) {
const actualValue = await query.call(received);
actualState[key] = isPredicate(expectedValue)
? expectedValue(actualValue) ? expectedValue.toString() : actualValue
: actualValue;
}
return actualState;
}
function statesMatch(actual: ExpectedState, expected: ExpectedState): boolean {
try {
baseExpect(actual).toEqual(expected);
return true;
} catch {
return false;
}
}
function createMismatchMessage(actual: ExpectedState, expected: ExpectedState): string {
try {
baseExpect(actual).toEqual(expected);
return 'State matched';
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
function createStabilityMessage(
utils: ExpectMatcherState['utils'],
expand: boolean,
actual: ExpectedState,
expected: ExpectedState,
stableFor: number,
timeout: number,
): string {
const matcherHint = utils.matcherHint('toHaveState', 'received', 'expected');
const diff = (utils as ExpectMatcherState['utils'] & {
printDiffOrStringify?: (
expected: unknown,
received: unknown,
expectedLabel: string,
receivedLabel: string,
expand: boolean,
) => string,
}).printDiffOrStringify?.(
expected,
actual,
'Expected state',
'Last matched state',
expand,
);
return [
matcherHint,
'',
`State matched, but did not remain stable for ${stableFor}ms within ${timeout}ms.`,
...(diff ? ['', diff] : [
'',
'Expected state:',
` ${utils.printExpected(expected)}`,
'',
'Last matched state:',
` ${utils.printReceived(actual)}`,
]),
].join('\n');
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export const toHaveStateMatchers = {
async toHaveState(
this: ExpectMatcherState,
received: StateReceiver,
expected: ExpectedState,
options?: ToHaveStateOptions,
) {
const normalizedExpected = normalizeExpected(expected ?? {});
const timeout = options?.timeout ?? (typeof this.timeout === 'number' ? this.timeout : 5_000);
const stableFor = options?.stableFor ?? 0;
if (Object.keys(normalizedExpected).length === 0) {
return {
pass: true,
message: () => '',
};
}
try {
const queries = resolveQueries(received, expected);
const deadline = Date.now() + timeout;
let stableSince: number | null = null;
let lastActualState: ExpectedState = {};
while (Date.now() <= deadline) {
lastActualState = await readActualState(received, queries);
if (!statesMatch(lastActualState, normalizedExpected)) {
stableSince = null;
await sleep(25);
continue;
}
if (stableFor <= 0) {
return {
pass: true,
message: () => '',
};
}
const now = Date.now();
if (stableSince === null) {
stableSince = now;
}
if (now - stableSince >= stableFor) {
return {
pass: true,
message: () => '',
};
}
await sleep(25);
}
return {
pass: false,
message: () => stableSince !== null
? createStabilityMessage(
this.utils,
(this as ExpectMatcherState & { expand?: boolean }).expand !== false,
lastActualState,
normalizedExpected,
stableFor,
timeout,
)
: createMismatchMessage(lastActualState, normalizedExpected),
};
} catch (error) {
return {
pass: false,
message: () => error instanceof Error ? error.message : String(error),
};
}
},
};
// Usage:
// https://playwright.dev/docs/test-assertions#add-custom-matchers-using-expectextend
//
// import { expect as baseExpect } from '@playwright/test';
// import { toHaveStateMatchers } from './toHaveState';
//
// const expect = baseExpect.extend(toHaveStateMatchers);
import { expect as baseExpect, type Locator, type Page, test } from '@playwright/test';
import { toHaveStateMatchers } from './toHaveState';
// https://playwright.dev/docs/test-assertions#add-custom-matchers-using-expectextend
const expect = baseExpect.extend(toHaveStateMatchers);
class TodoItem {
private checkbox: Locator;
private label: Locator;
constructor(readonly root: Locator) {
this.checkbox = root.getByRole('checkbox');
this.label = root.locator('label');
}
async toggle() {
await this.checkbox.click();
}
async text() {
return this.label.innerText();
}
async isChecked() {
return this.checkbox.isChecked();
}
}
class TodoPage {
private input: Locator;
private itemsLocator: Locator;
private itemsLeft: Locator;
constructor(readonly page: Page) {
this.input = page.locator('.new-todo');
this.itemsLocator = page.locator('.todo-list').getByRole('listitem');
this.itemsLeft = page.locator('.todo-count');
}
async open() {
await this.page.goto('https://demo.playwright.dev/todomvc/#/');
}
item(index: number) {
return new TodoItem(this.itemsLocator.nth(index));
}
async itemCount() {
return this.itemsLocator.count();
}
async itemsLeftCount() {
const text = await this.itemsLeft.innerText();
return Number.parseInt(text, 10);
}
async addTodo(text: string) {
await this.input.fill(text);
await this.input.press('Enter');
}
}
test('plain Playwright objects plus one matcher read like intent', async ({ page }) => {
const todoPage = new TodoPage(page);
await todoPage.open();
await todoPage.addTodo('Buy milk');
await todoPage.addTodo('Ship docs');
await expect(todoPage).toHaveState(
{
itemCount: 2,
itemsLeftCount: 2,
},
{ timeout: 5_000, stableFor: 50 },
);
const first = todoPage.item(0);
const second = todoPage.item(1);
await expect(first).toHaveState({ text: 'Buy milk', isChecked: false });
await expect(second).toHaveState({ text: 'Ship docs' });
await first.toggle();
await expect(first).toHaveState({ text: 'Buy milk', isChecked: true }, { stableFor: 50 });
await expect(todoPage).toHaveState({ itemsLeftCount: 1 }, { stableFor: 50 });
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment