From 4ff72084067f821aa4b69f7478f701263807e8e4 Mon Sep 17 00:00:00 2001 From: dominicbachmann Date: Thu, 9 Jul 2026 22:50:15 +0200 Subject: [PATCH 1/2] fix: ship custom matcher type declarations in bundled d.ts The custom DOM matcher declarations (toHaveText, toHaveAttribute, toExist, ...) were only pulled in via triple-slash path references in the public_api files. ng-packagr's declaration bundling follows the module graph from the entry file, so the ambient matchers-types files were dropped from the published types and consumers got TS2339 on every custom matcher. Convert the matchers-types files into proper modules (declare global namespace augmentation for jasmine/jest and a declare module augmentation for vitest) and import them from each public_api so the flattened .d.ts bundles carry them. Closes #8 --- .../spectator/jest/src/lib/matchers-types.ts | 64 ++++++++++--------- projects/spectator/jest/src/public_api.ts | 4 +- projects/spectator/src/lib/matchers-types.ts | 64 ++++++++++--------- projects/spectator/src/public_api.ts | 3 +- projects/spectator/vitest/src/public_api.ts | 4 +- 5 files changed, 74 insertions(+), 65 deletions(-) diff --git a/projects/spectator/jest/src/lib/matchers-types.ts b/projects/spectator/jest/src/lib/matchers-types.ts index 7afa788f..737217df 100644 --- a/projects/spectator/jest/src/lib/matchers-types.ts +++ b/projects/spectator/jest/src/lib/matchers-types.ts @@ -1,59 +1,63 @@ -declare namespace jest { - interface Matchers { - toExist(): R; +declare global { + namespace jest { + interface Matchers { + 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 {}; diff --git a/projects/spectator/jest/src/public_api.ts b/projects/spectator/jest/src/public_api.ts index 8e87ef56..e4bef8ff 100644 --- a/projects/spectator/jest/src/public_api.ts +++ b/projects/spectator/jest/src/public_api.ts @@ -1,5 +1,5 @@ -/// -/// +import './lib/matchers-types'; + export * from './lib/dom-selectors'; export * from './lib/mock'; export * from './lib/spectator'; diff --git a/projects/spectator/src/lib/matchers-types.ts b/projects/spectator/src/lib/matchers-types.ts index 2cb0436a..76944094 100644 --- a/projects/spectator/src/lib/matchers-types.ts +++ b/projects/spectator/src/lib/matchers-types.ts @@ -1,59 +1,63 @@ -declare namespace jasmine { - interface Matchers { - toExist(): boolean; +declare global { + namespace jasmine { + interface Matchers { + 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 {}; diff --git a/projects/spectator/src/public_api.ts b/projects/spectator/src/public_api.ts index c23f9a47..f953d375 100644 --- a/projects/spectator/src/public_api.ts +++ b/projects/spectator/src/public_api.ts @@ -1,4 +1,5 @@ -/// +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'; diff --git a/projects/spectator/vitest/src/public_api.ts b/projects/spectator/vitest/src/public_api.ts index c3f511a4..e4bef8ff 100644 --- a/projects/spectator/vitest/src/public_api.ts +++ b/projects/spectator/vitest/src/public_api.ts @@ -1,5 +1,5 @@ -/// -/// +import './lib/matchers-types'; + export * from './lib/dom-selectors'; export * from './lib/mock'; export * from './lib/spectator'; From 3a80ceda6d763e2a4425a347c6cf335670680f51 Mon Sep 17 00:00:00 2001 From: dominicbachmann Date: Thu, 9 Jul 2026 23:19:41 +0200 Subject: [PATCH 2/2] test: type-check custom matcher declarations in the built package Add consumer-style type tests under type-tests/ that compile against the flattened d.ts bundles in dist/spectator with tsc --noEmit. Each runner (jasmine, jest, vitest) exercises every custom DOM matcher on expect(), plus a @ts-expect-error negative control proving the assertions are meaningful. This guards against the regression from #8, where the matcher augmentations were silently dropped from the published types. Wired up as yarn test:types and run in the CI build job after the build. --- .github/workflows/ci.yml | 21 +++++++++++++ package.json | 1 + type-tests/README.md | 5 +++ type-tests/jasmine/consumer.ts | 52 +++++++++++++++++++++++++++++++ type-tests/jasmine/tsconfig.json | 16 ++++++++++ type-tests/jest/consumer.ts | 52 +++++++++++++++++++++++++++++++ type-tests/jest/tsconfig.json | 17 ++++++++++ type-tests/vitest/consumer.ts | 53 ++++++++++++++++++++++++++++++++ type-tests/vitest/tsconfig.json | 17 ++++++++++ 9 files changed, 234 insertions(+) create mode 100644 type-tests/README.md create mode 100644 type-tests/jasmine/consumer.ts create mode 100644 type-tests/jasmine/tsconfig.json create mode 100644 type-tests/jest/consumer.ts create mode 100644 type-tests/jest/tsconfig.json create mode 100644 type-tests/vitest/consumer.ts create mode 100644 type-tests/vitest/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 385304c1..aab83421 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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] diff --git a/package.json b/package.json index 5e78b4e8..d1203c8d 100644 --- a/package.json +++ b/package.json @@ -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\"", diff --git a/type-tests/README.md b/type-tests/README.md new file mode 100644 index 00000000..a7c96147 --- /dev/null +++ b/type-tests/README.md @@ -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`. diff --git a/type-tests/jasmine/consumer.ts b/type-tests/jasmine/consumer.ts new file mode 100644 index 00000000..405d3ff9 --- /dev/null +++ b/type-tests/jasmine/consumer.ts @@ -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; +export const factory = createComponentFactory; diff --git a/type-tests/jasmine/tsconfig.json b/type-tests/jasmine/tsconfig.json new file mode 100644 index 00000000..2941d61b --- /dev/null +++ b/type-tests/jasmine/tsconfig.json @@ -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"] +} diff --git a/type-tests/jest/consumer.ts b/type-tests/jest/consumer.ts new file mode 100644 index 00000000..a513ddb3 --- /dev/null +++ b/type-tests/jest/consumer.ts @@ -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; +export const factory = createComponentFactory; diff --git a/type-tests/jest/tsconfig.json b/type-tests/jest/tsconfig.json new file mode 100644 index 00000000..8952b161 --- /dev/null +++ b/type-tests/jest/tsconfig.json @@ -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"] +} diff --git a/type-tests/vitest/consumer.ts b/type-tests/vitest/consumer.ts new file mode 100644 index 00000000..01b7173c --- /dev/null +++ b/type-tests/vitest/consumer.ts @@ -0,0 +1,53 @@ +import { createComponentFactory, Spectator } from '@openng/spectator/vitest'; +import { describe, expect, it } from 'vitest'; + +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; +export const factory = createComponentFactory; diff --git a/type-tests/vitest/tsconfig.json b/type-tests/vitest/tsconfig.json new file mode 100644 index 00000000..907c1eb5 --- /dev/null +++ b/type-tests/vitest/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "skipLibCheck": true, + "types": [], + "paths": { + "@openng/spectator": ["../../dist/spectator/types/openng-spectator.d.ts"], + "@openng/spectator/vitest": ["../../dist/spectator/types/openng-spectator-vitest.d.ts"] + } + }, + "files": ["consumer.ts"] +}