Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ jobs:
with:
name: dist
path: dist
test-types:
runs-on: ubuntu-latest
needs: [build]
steps:
- name: Check out code
uses: actions/checkout@v4

- name: Restore node modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('yarn.lock') }}

- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist

- name: Type-check published types
run: yarn test:types
test:
runs-on: ubuntu-latest
needs: [yarn-install]
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"test": "ng test",
"test:jest": "ng run spectator:test-jest",
"test:vitest": "ng run spectator:test-vitest",
"test:types": "tsc -p type-tests/jasmine && tsc -p type-tests/jest && tsc -p type-tests/vitest",
"test:ci": "cross-env NODE_ENV=build yarn test && yarn test:jest --silent && yarn test:vitest",
"lint": "ng lint",
"format": "prettier --write \"{projects,src}/**/*.ts\"",
Expand Down
64 changes: 34 additions & 30 deletions projects/spectator/jest/src/lib/matchers-types.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,63 @@
declare namespace jest {
interface Matchers<R> {
toExist(): R;
declare global {
namespace jest {
interface Matchers<R> {
toExist(): R;

toHaveLength(expected: number): R;
toHaveLength(expected: number): R;

toHaveId(id: string | number): R;
toHaveId(id: string | number): R;

toHaveClass(className: string | string[], options?: { strict: boolean }): R;
toHaveClass(className: string | string[], options?: { strict: boolean }): R;

toHaveAttribute(attr: string | object, val?: string): R;
toHaveAttribute(attr: string | object, val?: string): R;

toHaveProperty(prop: string | object, val?: string | boolean): R;
toHaveProperty(prop: string | object, val?: string | boolean): R;

toContainProperty(prop: string | object, val?: string): R;
toContainProperty(prop: string | object, val?: string): R;

toHaveText(text: string | string[] | ((text: string) => boolean), exact?: boolean): R;
toHaveText(text: string | string[] | ((text: string) => boolean), exact?: boolean): R;

toContainText(text: string | string[] | ((text: string) => boolean), exact?: boolean): R;
toContainText(text: string | string[] | ((text: string) => boolean), exact?: boolean): R;

toHaveExactText(text: string | string[] | ((text: string) => boolean), options?: { trim: boolean }): R;
toHaveExactText(text: string | string[] | ((text: string) => boolean), options?: { trim: boolean }): R;

toHaveExactTrimmedText(text: string | string[] | ((text: string) => boolean)): R;
toHaveExactTrimmedText(text: string | string[] | ((text: string) => boolean)): R;

toHaveValue(value: string | string[]): R;
toHaveValue(value: string | string[]): R;

toContainValue(value: string | string[]): R;
toContainValue(value: string | string[]): R;

toHaveStyle(style: { [styleKey: string]: any }): R;
toHaveStyle(style: { [styleKey: string]: any }): R;

toHaveData({ data, val }: { data: string; val: string }): R;
toHaveData({ data, val }: { data: string; val: string }): R;

toBeChecked(): R;
toBeChecked(): R;

toBeIndeterminate(): R;
toBeIndeterminate(): R;

toBeDisabled(): R;
toBeDisabled(): R;

toBeEmpty(): R;
toBeEmpty(): R;

toBePartial(partial: object): R;
toBePartial(partial: object): R;

toBeHidden(): R;
toBeHidden(): R;

toBeSelected(): R;
toBeSelected(): R;

toBeVisible(): R;
toBeVisible(): R;

toBeFocused(): R;
toBeFocused(): R;

toBeMatchedBy(selector: string | Element): R;
toBeMatchedBy(selector: string | Element): R;

toHaveDescendant(selector: string | Element): R;
toHaveDescendant(selector: string | Element): R;

toHaveDescendantWithText({ selector, text }: { selector: string; text: string }): R;
toHaveDescendantWithText({ selector, text }: { selector: string; text: string }): R;

toHaveSelectedOptions(expected: string | string[] | HTMLOptionElement | HTMLOptionElement[]): R;
toHaveSelectedOptions(expected: string | string[] | HTMLOptionElement | HTMLOptionElement[]): R;
}
}
}

export {};
4 changes: 2 additions & 2 deletions projects/spectator/jest/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// <reference types="jest" />
/// <reference path="./lib/matchers-types.ts" preserve="true" />
import './lib/matchers-types';

export * from './lib/dom-selectors';
export * from './lib/mock';
export * from './lib/spectator';
Expand Down
64 changes: 34 additions & 30 deletions projects/spectator/src/lib/matchers-types.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,63 @@
declare namespace jasmine {
interface Matchers<T> {
toExist(): boolean;
declare global {
namespace jasmine {
interface Matchers<T> {
toExist(): boolean;

toHaveLength(expected: number): boolean;
toHaveLength(expected: number): boolean;

toHaveId(id: string | number): boolean;
toHaveId(id: string | number): boolean;

toHaveClass(className: string | string[], options?: { strict: boolean }): boolean;
toHaveClass(className: string | string[], options?: { strict: boolean }): boolean;

toHaveAttribute(attr: string | object, val?: string): boolean;
toHaveAttribute(attr: string | object, val?: string): boolean;

toHaveProperty(prop: string | object, val?: string | boolean): boolean;
toHaveProperty(prop: string | object, val?: string | boolean): boolean;

toContainProperty(prop: string | object, val?: string): boolean;
toContainProperty(prop: string | object, val?: string): boolean;

toHaveText(text: string | string[] | ((text: string) => boolean), exact?: boolean): boolean;
toHaveText(text: string | string[] | ((text: string) => boolean), exact?: boolean): boolean;

toContainText(text: string | string[] | ((text: string) => boolean), exact?: boolean): boolean;
toContainText(text: string | string[] | ((text: string) => boolean), exact?: boolean): boolean;

toHaveExactText(text: string | string[] | ((text: string) => boolean), options?: { trim: boolean }): boolean;
toHaveExactText(text: string | string[] | ((text: string) => boolean), options?: { trim: boolean }): boolean;

toHaveExactTrimmedText(text: string | string[] | ((text: string) => boolean)): boolean;
toHaveExactTrimmedText(text: string | string[] | ((text: string) => boolean)): boolean;

toHaveValue(value: string | string[]): boolean;
toHaveValue(value: string | string[]): boolean;

toContainValue(value: string | string[]): boolean;
toContainValue(value: string | string[]): boolean;

toHaveStyle(style: { [styleKey: string]: any }): boolean;
toHaveStyle(style: { [styleKey: string]: any }): boolean;

toHaveData({ data, val }: { data: string; val: string }): boolean;
toHaveData({ data, val }: { data: string; val: string }): boolean;

toBeChecked(): boolean;
toBeChecked(): boolean;

toBeIndeterminate(): boolean;
toBeIndeterminate(): boolean;

toBeDisabled(): boolean;
toBeDisabled(): boolean;

toBeEmpty(): boolean;
toBeEmpty(): boolean;

toBePartial(partial: object): boolean;
toBePartial(partial: object): boolean;

toBeHidden(): boolean;
toBeHidden(): boolean;

toBeSelected(): boolean;
toBeSelected(): boolean;

toBeVisible(): boolean;
toBeVisible(): boolean;

toBeFocused(): boolean;
toBeFocused(): boolean;

toBeMatchedBy(selector: string | Element): boolean;
toBeMatchedBy(selector: string | Element): boolean;

toHaveDescendant(selector: string | Element): boolean;
toHaveDescendant(selector: string | Element): boolean;

toHaveDescendantWithText({ selector, text }: { selector: string; text: string }): boolean;
toHaveDescendantWithText({ selector, text }: { selector: string; text: string }): boolean;

toHaveSelectedOptions(expected: string | string[] | HTMLOptionElement | HTMLOptionElement[]): boolean;
toHaveSelectedOptions(expected: string | string[] | HTMLOptionElement | HTMLOptionElement[]): boolean;
}
}
}

export {};
3 changes: 2 additions & 1 deletion projects/spectator/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/// <reference path="./lib/matchers-types.ts" preserve="true" />
import './lib/matchers-types';

export { Spectator } from './lib/spectator/spectator';
export { SpectatorOptions } from './lib/spectator/options';
export { createComponentFactory, SpectatorFactory, SpectatorOverrides } from './lib/spectator/create-factory';
Expand Down
4 changes: 2 additions & 2 deletions projects/spectator/vitest/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// <reference types="vitest" />
/// <reference path="./lib/matchers-types.ts" preserve="true" />
import './lib/matchers-types';

export * from './lib/dom-selectors';
export * from './lib/mock';
export * from './lib/spectator';
Expand Down
5 changes: 5 additions & 0 deletions type-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Type tests

These projects type-check consumer-style code against the built `dist/spectator` type bundles to ensure the custom DOM matcher declarations are shipped for every entry point (see issue #8).

Run `yarn build` first, then `yarn test:types`.
52 changes: 52 additions & 0 deletions type-tests/jasmine/consumer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createComponentFactory, Spectator } from '@openng/spectator';

declare const element: HTMLElement;
declare const option: HTMLOptionElement;

describe('custom DOM matchers are wired into expect()', () => {
it('type-checks every custom matcher', () => {
expect(element).toExist();
expect(element).toHaveLength(3);
expect(element).toHaveId('root');
expect(element).toHaveClass('active');
expect(element).toHaveClass(['active', 'selected'], { strict: false });
expect(element).toHaveAttribute('role', 'button');
expect(element).toHaveAttribute({ role: 'button' });
expect(element).toHaveProperty('title', 'hello');
expect(element).toHaveProperty('hidden', true);
expect(element).toContainProperty('title', 'hell');
expect(element).toHaveText('hello');
expect(element).toHaveText(['hello', 'world'], true);
expect(element).toHaveText((text) => text.startsWith('hell'));
expect(element).toContainText('ell');
expect(element).toHaveExactText('hello', { trim: true });
expect(element).toHaveExactTrimmedText('hello');
expect(element).toHaveValue('42');
expect(element).toContainValue(['4', '2']);
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveData({ data: 'role', val: 'admin' });
expect(element).toBeChecked();
expect(element).toBeIndeterminate();
expect(element).toBeDisabled();
expect(element).toBeEmpty();
expect(element).toBePartial({ title: 'hello' });
expect(element).toBeHidden();
expect(element).toBeSelected();
expect(element).toBeVisible();
expect(element).toBeFocused();
expect(element).toBeMatchedBy('.active');
expect(element).toHaveDescendant('button');
expect(element).toHaveDescendantWithText({ selector: 'button', text: 'Save' });
expect(element).toHaveSelectedOptions('1');
expect(element).toHaveSelectedOptions([option]);

// Negative control: proves the assertion type is not `any`, i.e. the
// matcher checks above are meaningful.
// @ts-expect-error unknown matcher must not type-check
expect(element).toHaveTextBogus('hello');
});
});

// Reference the entry point exports so the import is used and the bundle resolves.
export type Check = Spectator<unknown>;
export const factory = createComponentFactory;
16 changes: 16 additions & 0 deletions type-tests/jasmine/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"skipLibCheck": true,
"types": ["jasmine"],
"paths": {
"@openng/spectator": ["../../dist/spectator/types/openng-spectator.d.ts"]
}
},
"files": ["consumer.ts"]
}
52 changes: 52 additions & 0 deletions type-tests/jest/consumer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createComponentFactory, Spectator } from '@openng/spectator/jest';

declare const element: HTMLElement;
declare const option: HTMLOptionElement;

describe('custom DOM matchers are wired into expect()', () => {
it('type-checks every custom matcher', () => {
expect(element).toExist();
expect(element).toHaveLength(3);
expect(element).toHaveId('root');
expect(element).toHaveClass('active');
expect(element).toHaveClass(['active', 'selected'], { strict: false });
expect(element).toHaveAttribute('role', 'button');
expect(element).toHaveAttribute({ role: 'button' });
expect(element).toHaveProperty('title', 'hello');
expect(element).toHaveProperty('hidden', true);
expect(element).toContainProperty('title', 'hell');
expect(element).toHaveText('hello');
expect(element).toHaveText(['hello', 'world'], true);
expect(element).toHaveText((text) => text.startsWith('hell'));
expect(element).toContainText('ell');
expect(element).toHaveExactText('hello', { trim: true });
expect(element).toHaveExactTrimmedText('hello');
expect(element).toHaveValue('42');
expect(element).toContainValue(['4', '2']);
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveData({ data: 'role', val: 'admin' });
expect(element).toBeChecked();
expect(element).toBeIndeterminate();
expect(element).toBeDisabled();
expect(element).toBeEmpty();
expect(element).toBePartial({ title: 'hello' });
expect(element).toBeHidden();
expect(element).toBeSelected();
expect(element).toBeVisible();
expect(element).toBeFocused();
expect(element).toBeMatchedBy('.active');
expect(element).toHaveDescendant('button');
expect(element).toHaveDescendantWithText({ selector: 'button', text: 'Save' });
expect(element).toHaveSelectedOptions('1');
expect(element).toHaveSelectedOptions([option]);

// Negative control: proves the assertion type is not `any`, i.e. the
// matcher checks above are meaningful.
// @ts-expect-error unknown matcher must not type-check
expect(element).toHaveTextBogus('hello');
});
});

// Reference the entry point exports so the import is used and the bundle resolves.
export type Check = Spectator<unknown>;
export const factory = createComponentFactory;
17 changes: 17 additions & 0 deletions type-tests/jest/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"skipLibCheck": true,
"types": ["jest"],
"paths": {
"@openng/spectator": ["../../dist/spectator/types/openng-spectator.d.ts"],
"@openng/spectator/jest": ["../../dist/spectator/types/openng-spectator-jest.d.ts"]
}
},
"files": ["consumer.ts"]
}
Loading
Loading