From bd6c8d704ce522ca039a87e49098654487a68054 Mon Sep 17 00:00:00 2001 From: Eric Simonton Date: Wed, 10 Jun 2026 06:45:36 -0400 Subject: [PATCH 01/14] `ng generate library ng-vitest` --- angular.json | 37 +++++++ projects/ng-vitest/README.md | 64 +++++++++++ projects/ng-vitest/eslint.config.js | 37 +++++++ projects/ng-vitest/ng-package.json | 7 ++ projects/ng-vitest/package.json | 12 +++ projects/ng-vitest/src/lib/ng-vitest.spec.ts | 22 ++++ projects/ng-vitest/src/lib/ng-vitest.ts | 9 ++ projects/ng-vitest/src/public-api.ts | 5 + projects/ng-vitest/tsconfig.lib.json | 13 +++ projects/ng-vitest/tsconfig.lib.prod.json | 11 ++ projects/ng-vitest/tsconfig.spec.json | 10 ++ tsconfig.json | 105 ++++++++++++++----- 12 files changed, 307 insertions(+), 25 deletions(-) create mode 100644 projects/ng-vitest/README.md create mode 100644 projects/ng-vitest/eslint.config.js create mode 100644 projects/ng-vitest/ng-package.json create mode 100644 projects/ng-vitest/package.json create mode 100644 projects/ng-vitest/src/lib/ng-vitest.spec.ts create mode 100644 projects/ng-vitest/src/lib/ng-vitest.ts create mode 100644 projects/ng-vitest/src/public-api.ts create mode 100644 projects/ng-vitest/tsconfig.lib.json create mode 100644 projects/ng-vitest/tsconfig.lib.prod.json create mode 100644 projects/ng-vitest/tsconfig.spec.json diff --git a/angular.json b/angular.json index 26ae2172..02f347c5 100644 --- a/angular.json +++ b/angular.json @@ -551,6 +551,43 @@ } } } + }, + "ng-vitest": { + "projectType": "library", + "root": "projects/ng-vitest", + "sourceRoot": "projects/ng-vitest/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "configurations": { + "production": { + "tsConfig": "projects/ng-vitest/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/ng-vitest/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular/build:karma", + "options": { + "tsConfig": "projects/ng-vitest/tsconfig.spec.json", + "polyfills": ["zone.js", "zone.js/testing"] + } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "projects/ng-vitest/**/*.ts", + "projects/ng-vitest/**/*.html" + ], + "eslintConfig": "projects/ng-vitest/eslint.config.js" + } + } + } } } } diff --git a/projects/ng-vitest/README.md b/projects/ng-vitest/README.md new file mode 100644 index 00000000..fcbd42d1 --- /dev/null +++ b/projects/ng-vitest/README.md @@ -0,0 +1,64 @@ +# NgVitest + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.0. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the library, run: + +```bash +ng build ng-vitest +``` + +This command will compile your project, and the build artifacts will be placed in the `dist/` directory. + +### Publishing the Library + +Once the project is built, you can publish your library by following these steps: + +1. Navigate to the `dist` directory: + + ```bash + cd dist/ng-vitest + ``` + +2. Run the `npm publish` command to publish your library to the npm registry: + ```bash + npm publish + ``` + +## Running unit tests + +To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/projects/ng-vitest/eslint.config.js b/projects/ng-vitest/eslint.config.js new file mode 100644 index 00000000..ee50a210 --- /dev/null +++ b/projects/ng-vitest/eslint.config.js @@ -0,0 +1,37 @@ +// @ts-check +const { defineConfig } = require('eslint/config'); +const rootConfig = require('../../eslint.config.js'); + +module.exports = defineConfig([ + ...rootConfig, + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'lib', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'lib', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + rules: {}, + }, +]); diff --git a/projects/ng-vitest/ng-package.json b/projects/ng-vitest/ng-package.json new file mode 100644 index 00000000..a7789448 --- /dev/null +++ b/projects/ng-vitest/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/ng-vitest", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ng-vitest/package.json b/projects/ng-vitest/package.json new file mode 100644 index 00000000..4c4e088a --- /dev/null +++ b/projects/ng-vitest/package.json @@ -0,0 +1,12 @@ +{ + "name": "ng-vitest", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^21.2.0", + "@angular/core": "^21.2.0" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "sideEffects": false +} diff --git a/projects/ng-vitest/src/lib/ng-vitest.spec.ts b/projects/ng-vitest/src/lib/ng-vitest.spec.ts new file mode 100644 index 00000000..c0e571a8 --- /dev/null +++ b/projects/ng-vitest/src/lib/ng-vitest.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NgVitest } from './ng-vitest'; + +describe('NgVitest', () => { + let component: NgVitest; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgVitest], + }).compileComponents(); + + fixture = TestBed.createComponent(NgVitest); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/ng-vitest/src/lib/ng-vitest.ts b/projects/ng-vitest/src/lib/ng-vitest.ts new file mode 100644 index 00000000..c30fb614 --- /dev/null +++ b/projects/ng-vitest/src/lib/ng-vitest.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'lib-ng-vitest', + imports: [], + template: `

ng-vitest works!

`, + styles: ``, +}) +export class NgVitest {} diff --git a/projects/ng-vitest/src/public-api.ts b/projects/ng-vitest/src/public-api.ts new file mode 100644 index 00000000..2e47114e --- /dev/null +++ b/projects/ng-vitest/src/public-api.ts @@ -0,0 +1,5 @@ +/* + * Public API Surface of ng-vitest + */ + +export * from './lib/ng-vitest'; diff --git a/projects/ng-vitest/tsconfig.lib.json b/projects/ng-vitest/tsconfig.lib.json new file mode 100644 index 00000000..e28f812e --- /dev/null +++ b/projects/ng-vitest/tsconfig.lib.json @@ -0,0 +1,13 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"] +} diff --git a/projects/ng-vitest/tsconfig.lib.prod.json b/projects/ng-vitest/tsconfig.lib.prod.json new file mode 100644 index 00000000..9215caac --- /dev/null +++ b/projects/ng-vitest/tsconfig.lib.prod.json @@ -0,0 +1,11 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/projects/ng-vitest/tsconfig.spec.json b/projects/ng-vitest/tsconfig.spec.json new file mode 100644 index 00000000..84c7c852 --- /dev/null +++ b/projects/ng-vitest/tsconfig.spec.json @@ -0,0 +1,10 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": ["jasmine"] + }, + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index bfef5721..0cd86f50 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,8 @@ "target": "ES2022", "module": "preserve", "paths": { - "@s-libs/*": ["./dist/*"] + "@s-libs/*": ["./dist/*"], + "ng-vitest": ["./dist/ng-vitest"] }, // @@ -36,29 +37,83 @@ }, "files": [], "references": [ - { "path": "./projects/app-state/tsconfig.lib.json" }, - { "path": "./projects/app-state/tsconfig.spec.json" }, - { "path": "./projects/integration/tsconfig.app.json" }, - { "path": "./projects/integration/tsconfig.spec.json" }, - { "path": "./projects/js-core/tsconfig.lib.json" }, - { "path": "./projects/js-core/tsconfig.spec.json" }, - { "path": "./projects/micro-dash/tsconfig.lib.json" }, - { "path": "./projects/micro-dash/tsconfig.spec.json" }, - { "path": "./projects/micro-dash-sizes/tsconfig.app.json" }, - { "path": "./projects/micro-dash-sizes/tsconfig.spec.json" }, - { "path": "./projects/ng-app-state/tsconfig.lib.json" }, - { "path": "./projects/ng-app-state/tsconfig.spec.json" }, - { "path": "./projects/ng-core/tsconfig.lib.json" }, - { "path": "./projects/ng-core/tsconfig.spec.json" }, - { "path": "./projects/ng-dev/tsconfig.lib.json" }, - { "path": "./projects/ng-dev/tsconfig.spec.json" }, - { "path": "./projects/ng-jasmine/tsconfig.lib.json" }, - { "path": "./projects/ng-jasmine/tsconfig.spec.json" }, - { "path": "./projects/ng-mat-core/tsconfig.lib.json" }, - { "path": "./projects/ng-mat-core/tsconfig.spec.json" }, - { "path": "./projects/rxjs-core/tsconfig.lib.json" }, - { "path": "./projects/rxjs-core/tsconfig.spec.json" }, - { "path": "./projects/signal-store/tsconfig.lib.json" }, - { "path": "./projects/signal-store/tsconfig.spec.json" } + { + "path": "./projects/app-state/tsconfig.lib.json" + }, + { + "path": "./projects/app-state/tsconfig.spec.json" + }, + { + "path": "./projects/integration/tsconfig.app.json" + }, + { + "path": "./projects/integration/tsconfig.spec.json" + }, + { + "path": "./projects/js-core/tsconfig.lib.json" + }, + { + "path": "./projects/js-core/tsconfig.spec.json" + }, + { + "path": "./projects/micro-dash/tsconfig.lib.json" + }, + { + "path": "./projects/micro-dash/tsconfig.spec.json" + }, + { + "path": "./projects/micro-dash-sizes/tsconfig.app.json" + }, + { + "path": "./projects/micro-dash-sizes/tsconfig.spec.json" + }, + { + "path": "./projects/ng-app-state/tsconfig.lib.json" + }, + { + "path": "./projects/ng-app-state/tsconfig.spec.json" + }, + { + "path": "./projects/ng-core/tsconfig.lib.json" + }, + { + "path": "./projects/ng-core/tsconfig.spec.json" + }, + { + "path": "./projects/ng-dev/tsconfig.lib.json" + }, + { + "path": "./projects/ng-dev/tsconfig.spec.json" + }, + { + "path": "./projects/ng-jasmine/tsconfig.lib.json" + }, + { + "path": "./projects/ng-jasmine/tsconfig.spec.json" + }, + { + "path": "./projects/ng-mat-core/tsconfig.lib.json" + }, + { + "path": "./projects/ng-mat-core/tsconfig.spec.json" + }, + { + "path": "./projects/rxjs-core/tsconfig.lib.json" + }, + { + "path": "./projects/rxjs-core/tsconfig.spec.json" + }, + { + "path": "./projects/signal-store/tsconfig.lib.json" + }, + { + "path": "./projects/signal-store/tsconfig.spec.json" + }, + { + "path": "./projects/ng-vitest/tsconfig.lib.json" + }, + { + "path": "./projects/ng-vitest/tsconfig.spec.json" + } ] } From 94c873597a966c4ebc5c74361e8f9fbb6be46bd8 Mon Sep 17 00:00:00 2001 From: Eric Simonton Date: Wed, 10 Jun 2026 06:57:39 -0400 Subject: [PATCH 02/14] copy / guess at some config --- angular.json | 6 +- package-lock.json | 903 +++++++++++++++++++++++++- package.json | 4 +- projects/ng-vitest/eslint.config.js | 4 +- projects/ng-vitest/package.json | 21 +- projects/ng-vitest/tsconfig.lib.json | 5 +- projects/ng-vitest/tsconfig.spec.json | 2 +- 7 files changed, 922 insertions(+), 23 deletions(-) diff --git a/angular.json b/angular.json index 02f347c5..bc8db89d 100644 --- a/angular.json +++ b/angular.json @@ -556,7 +556,7 @@ "projectType": "library", "root": "projects/ng-vitest", "sourceRoot": "projects/ng-vitest/src", - "prefix": "lib", + "prefix": "s", "architect": { "build": { "builder": "@angular/build:ng-packagr", @@ -571,10 +571,10 @@ "defaultConfiguration": "production" }, "test": { - "builder": "@angular/build:karma", + "builder": "@angular/build:unit-test", "options": { "tsConfig": "projects/ng-vitest/tsconfig.spec.json", - "polyfills": ["zone.js", "zone.js/testing"] + "browsers": "chromium" } }, "lint": { diff --git a/package-lock.json b/package-lock.json index 52580dcd..62e124e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "s-libs", - "version": "20.2.0", + "version": "21.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "s-libs", - "version": "20.2.0", + "version": "21.0.0", "dependencies": { "@angular/cdk": "^21.0.0", "@angular/common": "^21.0.0", @@ -33,6 +33,7 @@ "expect-type": "^1.1.0", "glob": "^13.0.0", "jasmine-core": "~6.0.0", + "jsdom": "^27.1.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", @@ -47,9 +48,17 @@ "tsx": "^4.20.3", "typedoc": "^0.28.5", "typescript": "~5.9.2", - "typescript-eslint": "8.46.4" + "typescript-eslint": "8.46.4", + "vitest": "^4.0.8" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@algolia/abtesting": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", @@ -911,6 +920,61 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1214,6 +1278,146 @@ "node": ">=0.1.90" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -1903,6 +2107,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@gar/promise-retry": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", @@ -4484,11 +4706,11 @@ "license": "MIT" }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "license": "MIT", - "peer": true + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" }, "node_modules/@szmarczak/http-timer": { "version": "1.1.2", @@ -4577,6 +4799,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -4587,6 +4820,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5364,6 +5604,156 @@ "vite": "^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -5730,6 +6120,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5805,6 +6205,16 @@ "node": ">=14.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -6297,6 +6707,16 @@ "node": ">=0.10.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7264,6 +7684,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", @@ -7277,6 +7711,32 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", @@ -7297,6 +7757,30 @@ "node": ">=0.10.0" } }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/date-format": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", @@ -7372,6 +7856,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", @@ -8085,6 +8576,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -8527,9 +9025,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9942,6 +10440,19 @@ "node": "20 || >=22" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -10510,6 +11021,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -10828,6 +11346,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -12290,6 +12848,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -13928,6 +14493,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -14439,6 +15018,13 @@ "node": ">=4" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -16030,6 +16616,19 @@ "license": "BlueOak-1.0.0", "optional": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scss-comment-parser": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/scss-comment-parser/-/scss-comment-parser-0.8.4.tgz", @@ -16257,6 +16856,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -16741,6 +17347,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standard-version": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/standard-version/-/standard-version-9.5.0.tgz", @@ -16991,6 +17604,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", @@ -17164,6 +17784,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -17401,6 +18028,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -17418,6 +18062,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -17532,6 +18206,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -19460,6 +20160,106 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/void-elements": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", @@ -19470,6 +20270,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -19492,6 +20305,40 @@ "license": "MIT", "optional": true }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -19508,6 +20355,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -19708,6 +20572,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index b5b05f3f..0afceee9 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "expect-type": "^1.1.0", "glob": "^13.0.0", "jasmine-core": "~6.0.0", + "jsdom": "^27.1.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", @@ -69,7 +70,8 @@ "tsx": "^4.20.3", "typedoc": "^0.28.5", "typescript": "~5.9.2", - "typescript-eslint": "8.46.4" + "typescript-eslint": "8.46.4", + "vitest": "^4.0.8" }, "standard-version": { "bumpFiles": [ diff --git a/projects/ng-vitest/eslint.config.js b/projects/ng-vitest/eslint.config.js index ee50a210..834cbb8e 100644 --- a/projects/ng-vitest/eslint.config.js +++ b/projects/ng-vitest/eslint.config.js @@ -16,7 +16,7 @@ module.exports = defineConfig([ 'error', { type: 'attribute', - prefix: 'lib', + prefix: ['app', 'sl'], style: 'camelCase', }, ], @@ -24,7 +24,7 @@ module.exports = defineConfig([ 'error', { type: 'element', - prefix: 'lib', + prefix: ['app', 'sl'], style: 'kebab-case', }, ], diff --git a/projects/ng-vitest/package.json b/projects/ng-vitest/package.json index 4c4e088a..316c0491 100644 --- a/projects/ng-vitest/package.json +++ b/projects/ng-vitest/package.json @@ -1,9 +1,22 @@ { - "name": "ng-vitest", - "version": "0.0.1", + "name": "@s-libs/ng-vitest", + "version": "21.0.0", + "author": "Simonton Software", + "license": "MIT", + "homepage": "https://github.com/simontonsoftware/s-libs/tree/master/projects/ng-vitest", + "repository": { + "type": "git", + "url": "https://github.com/simontonsoftware/s-libs.git", + "directory": "projects/ng-vitest" + }, "peerDependencies": { - "@angular/common": "^21.2.0", - "@angular/core": "^21.2.0" + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", + "@s-libs/js-core": "^21.0.0", + "@s-libs/micro-dash": "^21.0.0", + "@s-libs/ng-core": "^21.0.0", + "@s-libs/ng-dev": "^21.0.0", + "vitest": "^4.0.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/projects/ng-vitest/tsconfig.lib.json b/projects/ng-vitest/tsconfig.lib.json index e28f812e..dddad6ba 100644 --- a/projects/ng-vitest/tsconfig.lib.json +++ b/projects/ng-vitest/tsconfig.lib.json @@ -6,7 +6,10 @@ "outDir": "../../out-tsc/lib", "declaration": true, "declarationMap": true, - "types": [] + "types": [ + // Added after scaffolding: this lib relies on vitest in prod code + "vitest/globals" + ] }, "include": ["src/**/*.ts"], "exclude": ["**/*.spec.ts"] diff --git a/projects/ng-vitest/tsconfig.spec.json b/projects/ng-vitest/tsconfig.spec.json index 84c7c852..48fcc2fd 100644 --- a/projects/ng-vitest/tsconfig.spec.json +++ b/projects/ng-vitest/tsconfig.spec.json @@ -4,7 +4,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/spec", - "types": ["jasmine"] + "types": ["vitest/globals"] }, "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] } From 9ee17fbd60bf800eee810136f59326266ffe03df Mon Sep 17 00:00:00 2001 From: Eric Simonton Date: Wed, 10 Jun 2026 07:01:40 -0400 Subject: [PATCH 03/14] copy in files from a to-replace --- .../angular-context/angular-context.spec.ts | 560 ++++++++++++++++++ .../lib/angular-context/angular-context.ts | 260 ++++++++ .../fake-timer-harness-environment.spec.ts | 40 ++ .../fake-timer-harness-environment.ts | 43 ++ .../src/lib/angular-context/index.ts | 1 + .../component-context.spec.ts | 530 +++++++++++++++++ .../component-context/component-context.ts | 275 +++++++++ .../component-harness-superclass.spec.ts | 102 ++++ .../component-harness-superclass.ts | 38 ++ projects/ng-vitest/src/lib/expectations.ts | 13 + projects/ng-vitest/src/lib/interfaces.ts | 5 + .../lib/mocks/async-method-controller.spec.ts | 358 +++++++++++ .../src/lib/mocks/async-method-controller.ts | 203 +++++++ .../expect-single-call-and-reset.spec.ts | 25 + .../lib/mocks/expect-single-call-and-reset.ts | 22 + .../ng-vitest/src/lib/mocks/test-call.spec.ts | 169 ++++++ projects/ng-vitest/src/lib/mocks/test-call.ts | 38 ++ projects/ng-vitest/src/lib/ng-vitest.spec.ts | 22 - projects/ng-vitest/src/lib/ng-vitest.ts | 9 - .../src/lib/service-harness-superclass.ts | 10 + .../src/lib/static-test/static-test.spec.ts | 22 + .../src/lib/static-test/static-test.ts | 18 + .../lib/test-request/expect-request.spec.ts | 178 ++++++ .../src/lib/test-request/expect-request.ts | 109 ++++ .../lib/test-request/sl-test-request.spec.ts | 160 +++++ .../src/lib/test-request/sl-test-request.ts | 69 +++ projects/ng-vitest/src/lib/zone-polyfills.ts | 2 + 27 files changed, 3250 insertions(+), 31 deletions(-) create mode 100644 projects/ng-vitest/src/lib/angular-context/angular-context.spec.ts create mode 100644 projects/ng-vitest/src/lib/angular-context/angular-context.ts create mode 100644 projects/ng-vitest/src/lib/angular-context/fake-timer-harness-environment.spec.ts create mode 100644 projects/ng-vitest/src/lib/angular-context/fake-timer-harness-environment.ts create mode 100644 projects/ng-vitest/src/lib/angular-context/index.ts create mode 100644 projects/ng-vitest/src/lib/component-context/component-context.spec.ts create mode 100644 projects/ng-vitest/src/lib/component-context/component-context.ts create mode 100644 projects/ng-vitest/src/lib/component-harness/component-harness-superclass.spec.ts create mode 100644 projects/ng-vitest/src/lib/component-harness/component-harness-superclass.ts create mode 100644 projects/ng-vitest/src/lib/expectations.ts create mode 100644 projects/ng-vitest/src/lib/interfaces.ts create mode 100644 projects/ng-vitest/src/lib/mocks/async-method-controller.spec.ts create mode 100644 projects/ng-vitest/src/lib/mocks/async-method-controller.ts create mode 100644 projects/ng-vitest/src/lib/mocks/expect-single-call-and-reset.spec.ts create mode 100644 projects/ng-vitest/src/lib/mocks/expect-single-call-and-reset.ts create mode 100644 projects/ng-vitest/src/lib/mocks/test-call.spec.ts create mode 100644 projects/ng-vitest/src/lib/mocks/test-call.ts delete mode 100644 projects/ng-vitest/src/lib/ng-vitest.spec.ts delete mode 100644 projects/ng-vitest/src/lib/ng-vitest.ts create mode 100644 projects/ng-vitest/src/lib/service-harness-superclass.ts create mode 100644 projects/ng-vitest/src/lib/static-test/static-test.spec.ts create mode 100644 projects/ng-vitest/src/lib/static-test/static-test.ts create mode 100644 projects/ng-vitest/src/lib/test-request/expect-request.spec.ts create mode 100644 projects/ng-vitest/src/lib/test-request/expect-request.ts create mode 100644 projects/ng-vitest/src/lib/test-request/sl-test-request.spec.ts create mode 100644 projects/ng-vitest/src/lib/test-request/sl-test-request.ts create mode 100644 projects/ng-vitest/src/lib/zone-polyfills.ts diff --git a/projects/ng-vitest/src/lib/angular-context/angular-context.spec.ts b/projects/ng-vitest/src/lib/angular-context/angular-context.spec.ts new file mode 100644 index 00000000..9c94ea1c --- /dev/null +++ b/projects/ng-vitest/src/lib/angular-context/angular-context.spec.ts @@ -0,0 +1,560 @@ +import { OverlayContainer } from '@angular/cdk/overlay'; +import { HttpClient, provideHttpClient } from '@angular/common/http'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { + APP_ID, + ApplicationRef, + Component, + DoCheck, + effect, + EnvironmentProviders, + ErrorHandler, + inject, + Injectable, + InjectionToken, + Injector, + provideAppInitializer, + provideZonelessChangeDetection, + signal, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MATERIAL_ANIMATIONS } from '@angular/material/core'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarHarness } from '@angular/material/snack-bar/testing'; +import { Deferred } from '@s-libs/js-core'; +import { MockErrorHandler } from '@s-libs/ng-dev'; +import { noop, Observable } from 'rxjs'; +import { ComponentContext } from '../component-context/component-context'; +import { AngularContext } from './angular-context'; +import { FakeTimerHarnessEnvironment } from './fake-timer-harness-environment'; + +describe('AngularContext', () => { + class SnackBarContext extends AngularContext { + constructor() { + super({ imports: [MatSnackBarModule] }); + } + + protected override cleanUp(): void { + this.inject(OverlayContainer).ngOnDestroy(); + // flush(); + super.cleanUp(); + } + } + + describe('.getCurrent()', () => { + it('returns the currently running angular context', async () => { + expect(AngularContext.getCurrent()).toBeUndefined(); + + const ctx = new AngularContext(); + await ctx.run(() => { + expect(AngularContext.getCurrent()).toBe(ctx); + }); + + expect(AngularContext.getCurrent()).toBeUndefined(); + }); + }); + + describe('.startTime', () => { + it('controls the time at which the test starts', async () => { + const ctx = new AngularContext(); + ctx.startTime = new Date('2012-07-14T21:42:17.523Z'); + await ctx.run(() => { + expect(new Date()).toEqual(new Date('2012-07-14T21:42:17.523Z')); + }); + }); + + it('defaults to the current time', async () => { + const ctx = new AngularContext(); + const now = Date.now(); + await ctx.run(() => { + expect(Date.now()).toBeCloseTo(now, -2); + }); + }); + }); + + describe('constructor', () => { + it('accepts module metadata to be bootstrapped', async () => { + const value = Symbol(''); + const token = new InjectionToken('tok'); + const ctx = new AngularContext({ + providers: [{ provide: token, useValue: value }], + }); + await ctx.run(() => { + expect(ctx.inject(token)).toBe(value); + }); + }); + + // it('sets up http client testing', () => { + // const ctx = new AngularContext({ providers: [provideHttpClient()] }); + // ctx.run(() => { + // ctx.inject(HttpClient).get('some URL').subscribe(); + // expectRequest('GET', 'some URL'); + // }); + // }); + + // // this is more sensitive than the test above, since `provideHttpClientTesting()` has to end up _after_ `provideHttpClient()` to work properly + // it('sets up testing for `provideHttpClient()`', () => { + // const ctx = new AngularContext({ providers: [provideHttpClient()] }); + // ctx.run(() => { + // ctx.inject(HttpClient).get('some URL').subscribe(); + // expectRequest('GET', 'some URL'); + // }); + // }); + + it('sets up MockErrorHandler', async () => { + const ctx = new AngularContext(); + await ctx.run(() => { + expect(ctx.inject(ErrorHandler)).toEqual(expect.any(MockErrorHandler)); + }); + }); + + it('allows the user to override MockErrorHandler', async () => { + const errorHandler = { handleError: noop }; + const ctx = new AngularContext({ + providers: [{ provide: ErrorHandler, useValue: errorHandler }], + }); + await ctx.run(() => { + expect(ctx.inject(ErrorHandler)).toBe(errorHandler); + }); + }); + + it('disables animations', async () => { + const ctx = new AngularContext(); + await ctx.run(() => { + expect(ctx.inject(MATERIAL_ANIMATIONS)).toEqual({ + animationsDisabled: true, + }); + }); + }); + + it('allows the user to override MATERIAL_ANIMATIONS', async () => { + const ctx = new AngularContext({ + providers: [{ provide: MATERIAL_ANIMATIONS, useValue: {} }], + }); + await ctx.run(() => { + expect(ctx.inject(MATERIAL_ANIMATIONS)).toEqual({}); + }); + }); + + it('gives a nice error message if trying to use 2 at the same time', async () => { + await new AngularContext().run(async () => { + expect(() => { + // eslint-disable-next-line no-new -- nothing more is needed for this test + new AngularContext(); + }).toThrow( + 'There is already another AngularContext in use (or it was not cleaned up)', + ); + }); + }); + }); + + describe('.run()', () => { + it('can handle async tests that call tick', async () => { + let completed = false; + const ctx = new AngularContext(); + await ctx.run(async () => { + setTimeout(() => { + completed = true; + }, 500); + await ctx.tick(500); + }); + expect(completed).toBe(true); + }); + + it('can catch "inject() must be called from an injection context" errors (pre-release design flaw)', async () => { + // In 16.0.0-next.0 I added the ability to use `inject()` inside test code. That's cool, but it also could mask a production bug! + await new AngularContext().run(() => { + expect(() => { + inject(APP_ID); + }).toThrow(); + }); + }); + + it('does not swallow errors (production bug)', async () => { + await expect( + new AngularContext().run(() => { + throw new Error(); + }), + ).rejects.toThrow(); + }); + + describe('next test run', () => { + async function runInitTest(): Promise { + class BadInitContext extends AngularContext { + protected override async init(): Promise { + await super.init(); + throw new Error('mess up init'); + } + } + const ctx = new BadInitContext(); + await expect(ctx.run(noop)).rejects.toThrow('mess up init'); + } + + async function runCleanupTest(): Promise { + class NonCleanup extends AngularContext { + protected override cleanUp(): void { + throw new Error('mess up cleanup'); + } + } + const ctx = new NonCleanup(); + await expect(ctx.run(noop)).rejects.toThrow('mess up cleanup'); + } + + it('is OK when throwing an error during init', runInitTest); + it('is OK when throwing an error during init', runInitTest); + + it('is OK when throwing an error during cleanup', runCleanupTest); + it('is OK when throwing an error during cleanup', runCleanupTest); + }); + }); + + describe('.isRunning()', () => { + it('works', async () => { + const ctx = new AngularContext(); + expect(ctx.isRunning()).toBe(false); + await ctx.run(() => { + expect(ctx.isRunning()).toBe(true); + }); + expect(ctx.isRunning()).toBe(false); + }); + }); + + describe('.inject()', () => { + it('fetches from the root injector', async () => { + const ctx = new AngularContext(); + await ctx.run(() => { + expect(ctx.inject(Injector)).toBe(TestBed.inject(Injector)); + }); + }); + }); + + describe('.hasHarness()', () => { + it('returns whether a match for the harness exists', async () => { + const ctx = new SnackBarContext(); + await ctx.run(async () => { + expect(await ctx.hasHarness(MatSnackBarHarness)).toBe(false); + + ctx.inject(MatSnackBar).open('hi'); + expect(await ctx.hasHarness(MatSnackBarHarness)).toBe(true); + }); + }); + }); + + describe('.getHarness()', () => { + it('returns a harness', async () => { + const ctx = new SnackBarContext(); + await ctx.run(async () => { + ctx.inject(MatSnackBar).open('hi'); + const bar = await ctx.getHarness(MatSnackBarHarness); + expect(await bar.getMessage()).toBe('hi'); + }); + }); + }); + + describe('.getHarnessOrNull()', () => { + it('returns a harness or null', async () => { + const ctx = new SnackBarContext(); + await ctx.run(async () => { + expect(await ctx.getHarnessOrNull(MatSnackBarHarness)).toBe(null); + + ctx.inject(MatSnackBar).open('hi'); + expect(await ctx.getHarnessOrNull(MatSnackBarHarness)).not.toBe(null); + }); + }); + }); + + describe('.getAllHarnesses()', () => { + it('gets an array of harnesses', async () => { + const ctx = new SnackBarContext(); + await ctx.run(async () => { + let bars = await ctx.getAllHarnesses(MatSnackBarHarness); + expect(bars.length).toBe(0); + ctx.inject(MatSnackBar).open('hi'); + bars = await ctx.getAllHarnesses(MatSnackBarHarness); + expect(bars.length).toBe(1); + expect(await bars[0].getMessage()).toBe('hi'); + }); + }); + }); + + describe('.tick()', () => { + it('defaults to not advance time', async () => { + const ctx = new AngularContext(); + const start = ctx.startTime.getTime(); + await ctx.run(async () => { + await ctx.tick(); + expect(Date.now()).toBe(start); + }); + }); + + it('defaults to advancing in milliseconds', async () => { + const ctx = new AngularContext(); + const start = ctx.startTime.getTime(); + await ctx.run(async () => { + await ctx.tick(10); + expect(Date.now()).toBe(start + 10); + }); + }); + + it('allows specifying the units to advance', async () => { + const ctx = new AngularContext(); + const start = ctx.startTime.getTime(); + await ctx.run(async () => { + await ctx.tick(10, 'sec'); + expect(Date.now()).toBe(start + 10000); + }); + }); + + it('runs change detection even if no tasks are queued', async () => { + let ranChangeDetection = false; + + @Component({ template: '' }) + class LocalComponent implements DoCheck { + ngDoCheck(): void { + ranChangeDetection = true; + } + } + TestBed.overrideComponent(LocalComponent, {}); + + const ctx = new AngularContext(); + await ctx.run(async () => { + const fixture = TestBed.createComponent(LocalComponent); + ctx.inject(ApplicationRef).attachView(fixture.componentRef.hostView); + + expect(ranChangeDetection).toBe(false); + await ctx.tick(); + expect(ranChangeDetection).toBe(true); + }); + }); + + it('flushes micro tasks before running change detection', async () => { + let ranChangeDetection = false; + let flushedMicroTasksBeforeChangeDetection = false; + + @Component({ template: '' }) + class LocalComponent implements DoCheck { + ngDoCheck(): void { + ranChangeDetection = true; + } + } + TestBed.overrideComponent(LocalComponent, {}); + + const ctx = new AngularContext(); + await ctx.run(async () => { + const fixture = TestBed.createComponent(LocalComponent); + ctx.inject(ApplicationRef).attachView(fixture.componentRef.hostView); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve().then(() => { + flushedMicroTasksBeforeChangeDetection = !ranChangeDetection; + }); + await ctx.tick(); + expect(flushedMicroTasksBeforeChangeDetection).toBe(true); + }); + }); + + it('settles microtasks queued from effects (prod bug)', async () => { + const ctx = new AngularContext(); + await ctx.run(async () => { + const source = signal(false); + let result = false; + TestBed.runInInjectionContext(() => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/strict-void-return + effect(async () => { + const val = source(); + await Promise.resolve(); // must be native await, not .then(), to trigger one of the bugs + result = val; + }); + }); + setTimeout(() => { + source.set(true); + }); + + await ctx.tick(); + + expect(result).toBe(true); + }); + }); + + it('advances `performance.now()`', async () => { + const ctx = new AngularContext(); + await ctx.run(async () => { + const start = performance.now(); + await ctx.tick(10); + expect(performance.now()).toBe(start + 10); + }); + }); + + it('gives a nice error message when you try to use it outside `run()`', async () => { + const ctx = new AngularContext(); + await expect(ctx.tick()).rejects.toThrow( + ".tick() only works inside the .run() callback (because it needs Vitest's fake timers)", + ); + await ctx.run(noop); + }); + + describe('change detection after a timeout', () => { + async function runTest( + providers: EnvironmentProviders[], + expectChangeDetection: boolean, + ): Promise { + let ranTimeout = false; + let ranChangeDetectionAfterTimeout = false; + + @Component({ template: '' }) + class LocalComponent implements DoCheck { + ngDoCheck(): void { + ranChangeDetectionAfterTimeout = ranTimeout; + } + } + TestBed.overrideComponent(LocalComponent, {}); + + const ctx = new AngularContext({ providers }); + await ctx.run(async () => { + const fixture = TestBed.createComponent(LocalComponent); + ctx.inject(ApplicationRef).attachView(fixture.componentRef.hostView); + + setTimeout(() => { + ranTimeout = true; + }); + await ctx.tick(); + expect(ranChangeDetectionAfterTimeout).toBe(expectChangeDetection); + }); + } + + // it('runs with zone', async () => { + // await runTest([provideZoneChangeDetection()], true); + // }); + + it('does not run without zone', async () => { + await runTest([provideZonelessChangeDetection()], false); + }); + }); + }); + + describe('.init()', async () => { + it('waits for async app init', async () => { + const deferred = new Deferred(); + let testRan = false; + + const ctx = new AngularContext({ + providers: [provideAppInitializer(async () => deferred.promise)], + }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + ctx.run(() => { + testRan = true; + }); + + await vi.advanceTimersByTimeAsync(0); + expect(testRan).toBe(false); + + deferred.resolve(); + await vi.advanceTimersByTimeAsync(0); + expect(testRan).toBe(true); + }); + }); + + describe('.verifyPostTestConditions()', () => { + it('errs if there are unexpected http requests', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + await expect( + ctx.run(() => { + ctx.inject(HttpClient).get('an unexpected URL').subscribe(); + }), + ).rejects.toThrow( + 'Expected no open requests, found 1: GET an unexpected URL', + ); + }); + + it('errs if there are unexpected errors', async () => { + @Component({ + template: '', + }) + class ThrowingComponent { + throwError(): never { + throw new Error(); + } + } + + const ctx = new ComponentContext(ThrowingComponent); + await expect( + ctx.run(async () => { + let intercepted = false; + window.addEventListener( + 'error', + () => { + // without this no-op listener, vitest reports an unhandled error and fails the test run ¯\_(ツ)_/¯ + intercepted = true; + }, + { once: true }, + ); + + const loader = FakeTimerHarnessEnvironment.documentRootLoader(ctx); + const button = await loader.locatorFor('button')(); + await button.click(); + + // sanity check that the handler ran (and therefore was removed because of the { once: true }) + expect(intercepted).toBe(true); + }), + ).rejects.toThrow(); + }); + }); + + // describe('.cleanUp()', () => { + // it('discards periodic tasks', async () => { + // const ctx = new AngularContext(); + // await ctx.run(() => { + // setInterval(noop, 10); + // }); + // }); + // + // it('flushes pending timeouts', async () => { + // const ctx = new AngularContext(); + // await expect(async () => { + // await ctx.run(() => { + // setTimeout(noop, 1); + // }); + // }) + // // No error: "1 timer(s) still in the queue." + // .resolves.not.toThrow(); + // }); + // }); +}); + +describe('AngularContext class-level doc example', () => { + // This is the class we will test. + @Injectable({ providedIn: 'root' }) + class MemoriesService { + #httpClient = inject(HttpClient); + + getLastYearToday(): Observable { + const datetime = new Date(); + datetime.setFullYear(datetime.getFullYear() - 1); + const date = datetime.toISOString().split('T')[0]; + return this.#httpClient.get(`http://example.com/post-from/${date}`); + } + } + + describe('MemoriesService', () => { + // Tests should have exactly 1 variable outside an "it": `ctx`. + let ctx: AngularContext; + beforeEach(() => { + ctx = new AngularContext({ providers: [provideHttpClient()] }); + }); + + it('requests a post from 1 year ago', async () => { + // Before calling `run`, set up any context variables this test needs. + ctx.startTime = new Date('2004-02-16T10:15:00.000Z'); + + // Pass the test itself as a callback to `run()`. + await ctx.run(() => { + const httpBackend = ctx.inject(HttpTestingController); + const myService = ctx.inject(MemoriesService); + + myService.getLastYearToday().subscribe(); + + httpBackend.expectOne('http://example.com/post-from/2003-02-16'); + }); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/angular-context/angular-context.ts b/projects/ng-vitest/src/lib/angular-context/angular-context.ts new file mode 100644 index 00000000..3218ce8d --- /dev/null +++ b/projects/ng-vitest/src/lib/angular-context/angular-context.ts @@ -0,0 +1,260 @@ +import { ComponentHarness, HarnessQuery } from '@angular/cdk/testing'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { + AbstractType, + ApplicationInitStatus, + ApplicationRef, + InjectionToken, + Type, +} from '@angular/core'; +import { TestBed, TestModuleMetadata } from '@angular/core/testing'; +import { MATERIAL_ANIMATIONS } from '@angular/material/core'; +import { assert, convertTime } from '@s-libs/js-core'; +import { forOwn, isUndefined } from '@s-libs/micro-dash'; +import { MockErrorHandler } from '@s-libs/ng-dev'; +import { vi } from 'vitest'; +import { FakeTimerHarnessEnvironment } from './fake-timer-harness-environment'; + +// overrides later it the list will take precedence +export function extendMetadata( + ...allMetadata: TestModuleMetadata[] +): TestModuleMetadata { + const result: any = {}; + for (const metadata of allMetadata) { + forOwn(metadata, (val, key) => { + const existing = result[key]; + if (isUndefined(existing)) { + result[key] = val; + } else { + result[key] = [result[key], val]; + } + }); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return result; +} + +/** + * Provides the foundation for an opinionated testing pattern. + * - All tests are run with `vi.useFakeTimers`. This gives you full control over the timing of everything by default. + * - Variables that are initialized for each test exist in a context that is thrown away, so they cannot leak between tests. + * - Clearly separates initialization code from the test itself. + * - Gives control over the simulated date and time with a single line of code. + * - Automatically includes {@link https://angular.dev/api/common/http/testing/provideHttpClientTesting | provideHttpClientTesting()} to stub network requests without additional setup. + * - Always verifies that no unexpected http requests were made. + * - Always verifies that no unmatched errors were thrown (using {@link MockErrorHandler}). + * - Disables Material animations so that you don't need to wait for them in your tests. + * - Automatically discards periodic tasks and flushes pending timers at the end of each test to avoid the error "X timer(s) still in the queue". + * + * This example tests a simple service that uses `HttpClient` and is tested by using `AngularContext` directly. More often, `AngularContext` will be used as a super class. See {@link ComponentContext} for more common use cases. + * + * ```ts + * // This is the class we will test. + * @Injectable({ providedIn: 'root' }) + * class MemoriesService { + * #httpClient = inject(HttpClient); + * + * getLastYearToday(): Observable { + * const datetime = new Date(); + * datetime.setFullYear(datetime.getFullYear() - 1); + * const date = datetime.toISOString().split('T')[0]; + * return this.#httpClient.get(`http://example.com/post-from/${date}`); + * } + * } + * + * describe('MemoriesService', () => { + * // Tests should have exactly 1 variable outside an "it": `ctx`. + * let ctx: AngularContext; + * beforeEach(() => { + * ctx = new AngularContext({ providers: [provideHttpClient()] }); + * }); + * + * it('requests a post from 1 year ago', () => { + * // Before calling `run`, set up any context variables this test needs. + * ctx.startTime = new Date('2004-02-16T10:15:00.000Z'); + * + * // Pass the test itself as a callback to `run()`. + * ctx.run(() => { + * const httpBackend = ctx.inject(HttpTestingController); + * const myService = ctx.inject(MemoriesService); + * + * myService.getLastYearToday().subscribe(); + * + * httpBackend.expectOne('http://example.com/post-from/2003-02-16'); + * }); + * }); + * }); + * ``` + */ +export class AngularContext { + static #current?: AngularContext; + + /** + * Set this before calling `run()` to mock the time at which the test starts. + */ + startTime = new Date(); + + #isRunning = false; + #loader = FakeTimerHarnessEnvironment.documentRootLoader(this); + + /** + * @param moduleMetadata passed along to {@linkcode https://angular.dev/api/core/testing/TestBedStatic#configureTestingModule | TestBed.configureTestingModule()}. Automatically includes {@link provideHttpClientTesting}, {@link MockErrorHandler}, and {@link MATERIAL_ANIMATIONS} with `animationsDisabled: true`. + */ + constructor(moduleMetadata: TestModuleMetadata = {}) { + assert( + !AngularContext.#current, + 'There is already another AngularContext in use (or it was not cleaned up)', + ); + AngularContext.#current = this; + TestBed.configureTestingModule( + extendMetadata( + { + providers: [ + MockErrorHandler.overrideProvider(), + { + provide: MATERIAL_ANIMATIONS, + useValue: { animationsDisabled: true }, + }, + ], + }, + moduleMetadata, + { providers: [provideHttpClientTesting()] }, + ), + ); + } + + /** + * Returns the current `AngularContext` that is in use, or `undefined` if there is not one. A context is defined to be "in use" from the time it is constructed until after its `run()` method completes. + */ + static getCurrent(): AngularContext | undefined { + return AngularContext.#current; + } + + /** + * Runs `test` in a `fakeAsync` zone. It can use async/await, but be sure anything you `await` is already due to execute (e.g. if a timeout is due in 3 seconds, call `.tick(3000)` before `await`ing its result). + * + * Also runs the following in this order, all within the same zone: + * + * 1. `this.init()` + * 2. `test()` + * 3. `this.verifyPostTestConditions()` + * 4. `this.cleanUp()` + */ + async run(test: () => Promise | void): Promise { + this.#isRunning = true; + vi.useFakeTimers(); + vi.setSystemTime(this.startTime); + try { + await this.init(); + await test(); + this.verifyPostTestConditions(); + } finally { + try { + this.cleanUp(); + } finally { + vi.useRealTimers(); + AngularContext.#current = undefined; + this.#isRunning = false; + } + } + } + + /** + * Returns whether this context is currently executing the {@linkcode #run} callback. + */ + isRunning(): boolean { + return this.#isRunning; + } + + /** + * Gets a service or other injectable from the root injector. + * + * This implementation is a simple pass-through to {@linkcode https://angular.dev/api/core/testing/TestBedStatic#inject | TestBed.inject()}, but subclasses may provide their own implementation. It is recommended to use this in your tests instead of using `TestBed` directly. + */ + inject(token: AbstractType | InjectionToken | Type): T { + return TestBed.inject(token); + } + + /** + * Returns whether any components match the given `query`. + */ + async hasHarness( + query: HarnessQuery, + ): Promise { + return this.#loader.hasHarness(query); + } + + /** + * Gets a component harness, wrapped for use in a fakeAsync test. Throws an error if no matching component is found. + */ + async getHarness( + query: HarnessQuery, + ): Promise { + return this.#loader.getHarness(query); + } + + /** + * Gets a component harness, wrapped for use in a fakeAsync test. Returns `null` if no matching component is found. + */ + async getHarnessOrNull( + query: HarnessQuery, + ): Promise { + return this.#loader.getHarnessOrNull(query); + } + + /** + * Gets all component harnesses that match the query, wrapped for use in a fakeAsync test. + */ + async getAllHarnesses( + query: HarnessQuery, + ): Promise { + return this.#loader.getAllHarnesses(query); + } + + /** + * Advance time and trigger change detection. It is common to call this with no arguments to trigger change detection without advancing time. + * + * @param unit The unit of time `amount` represents. Accepts anything described in `@s-libs/s-core`'s [TimeUnit]{@linkcode https://simontonsoftware.github.io/s-js-utils/typedoc/enums/timeunit.html} enum. + */ + async tick(amount = 0, unit = 'ms'): Promise { + if (!this.#isRunning) { + throw new Error( + `.tick() only works inside the .run() callback (because it needs Vitest's fake timers)`, + ); + } + + await vi.advanceTimersByTimeAsync(convertTime(amount, unit, 'ms')); + this.runChangeDetection(); + await vi.advanceTimersByTimeAsync(0); + } + + // /** + // * This is a hook for subclasses to override. It is called during `run()`, before the `test()` callback. This implementation does nothing, but if you override this it is still recommended to call `super.init()` in case this implementation does something in the future. + // */ + protected async init(): Promise { + await this.inject(ApplicationInitStatus).donePromise; + } + + protected runChangeDetection(): void { + this.inject(ApplicationRef).tick(); + } + + /** + * Runs post-test verifications. This base implementation runs {@linkcode https://angular.dev/api/common/http/testing/HttpTestingController#verify | HttpTestingController.verify} and {@linkcode MockErrorHandler.verify}. Unlike {@linkcode #cleanUp}, it is OK for this method to throw an error to indicate a violation. + */ + protected verifyPostTestConditions(): void { + this.inject(HttpTestingController).verify(); + this.inject(MockErrorHandler).verify(); + } + + // /** + // * Performs any cleanup needed at the end of each test. This base implementation calls {@linkcode https://angular.dev/api/core/testing/discardPeriodicTasks | discardPeriodicTasks} and {@linkcode https://angular.dev/api/core/testing/flush | flush} to avoid an error from the `fakeAsync` zone. + // */ + protected cleanUp(): void { + // discardPeriodicTasks(); + // flush(); + } +} diff --git a/projects/ng-vitest/src/lib/angular-context/fake-timer-harness-environment.spec.ts b/projects/ng-vitest/src/lib/angular-context/fake-timer-harness-environment.spec.ts new file mode 100644 index 00000000..5cd06cea --- /dev/null +++ b/projects/ng-vitest/src/lib/angular-context/fake-timer-harness-environment.spec.ts @@ -0,0 +1,40 @@ +import { Component } from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatSnackBarHarness } from '@angular/material/snack-bar/testing'; +import { ComponentContext } from '../component-context/component-context'; + +describe('FakeTimerHarnessEnvironment', () => { + @Component({ + imports: [MatButton], + template: ` + + `, + }) + class ClickableButtonComponent { + clicked = false; + } + + it('runs asynchronous events that are due automatically', async () => { + const ctx = new ComponentContext(ClickableButtonComponent); + await ctx.run(async () => { + const button = await ctx.getHarness(MatButtonHarness); + await button.click(); + expect(await button.getText()).toBe('true'); + }); + }); + + it('does not flush timeouts that are not yet due', async () => { + const ctx = new ComponentContext(ClickableButtonComponent, { + imports: [MatSnackBarModule], + }); + await ctx.run(async () => { + ctx + .inject(MatSnackBar) + // When using the built-in TestBedHarnessEnvironment, fetching the harness would flush the duration, and it would disappear before being selected + .open('Hello, snackbar!', 'OK', { duration: 5000 }); + expect(await ctx.getHarness(MatSnackBarHarness)).toBeDefined(); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/angular-context/fake-timer-harness-environment.ts b/projects/ng-vitest/src/lib/angular-context/fake-timer-harness-environment.ts new file mode 100644 index 00000000..e978ed93 --- /dev/null +++ b/projects/ng-vitest/src/lib/angular-context/fake-timer-harness-environment.ts @@ -0,0 +1,43 @@ +import { HarnessEnvironment } from '@angular/cdk/testing'; +import { UnitTestElement } from '@angular/cdk/testing/testbed'; +import { bindKey } from '@s-libs/micro-dash'; +import { vi } from 'vitest'; +import { AngularContext } from './angular-context'; + +export class FakeTimerHarnessEnvironment extends HarnessEnvironment { + protected constructor( + rawRootElement: Element, + private ctx: AngularContext, + ) { + super(rawRootElement); + } + + static documentRootLoader(ctx: AngularContext): FakeTimerHarnessEnvironment { + return new FakeTimerHarnessEnvironment(document.body, ctx); + } + + async waitForTasksOutsideAngular(): Promise { + console.log('outsidealizing'); + await vi.runAllTimersAsync(); + } + + async forceStabilize(): Promise { + await this.ctx.tick(); + } + + protected createEnvironment(element: Element): HarnessEnvironment { + return new FakeTimerHarnessEnvironment(element, this.ctx); + } + + protected createTestElement(element: Element): UnitTestElement { + return new UnitTestElement(element, bindKey(this, 'forceStabilize')); + } + + protected async getAllRawElements(selector: string): Promise { + return Array.from(this.rawRootElement.querySelectorAll(selector)); + } + + protected getDocumentRoot(): Element { + return document.body; + } +} diff --git a/projects/ng-vitest/src/lib/angular-context/index.ts b/projects/ng-vitest/src/lib/angular-context/index.ts new file mode 100644 index 00000000..75124298 --- /dev/null +++ b/projects/ng-vitest/src/lib/angular-context/index.ts @@ -0,0 +1 @@ +export { AngularContext } from './angular-context'; diff --git a/projects/ng-vitest/src/lib/component-context/component-context.spec.ts b/projects/ng-vitest/src/lib/component-context/component-context.spec.ts new file mode 100644 index 00000000..1e890eee --- /dev/null +++ b/projects/ng-vitest/src/lib/component-context/component-context.spec.ts @@ -0,0 +1,530 @@ +import { ComponentHarness } from '@angular/cdk/testing'; +import { + ApplicationConfig, + Component, + Directive, + effect, + InjectionToken, + input, + Input, + model, + OnChanges, + provideAppInitializer, + provideZonelessChangeDetection, + signal, + SimpleChanges, +} from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + provideRouter, + RouterLink, + RouterOutlet, + Routes, +} from '@angular/router'; +import { Deferred } from '@s-libs/js-core'; +import { noop } from '@s-libs/micro-dash'; +import { expectTypeOf } from 'expect-type'; +import { staticTest } from '../static-test/static-test'; +import { ComponentContext } from './component-context'; + +describe('ComponentContext', () => { + @Component({ template: 'Hello, {{name()}}!' }) + class TestComponent { + readonly name = model('Default'); + } + + @Component({ template: '' }) + class ChangeDetectingComponent implements OnChanges { + readonly myInput = input(); + readonly ngOnChangesSpy = vi.fn(); + + ngOnChanges(changes: SimpleChanges): void { + this.ngOnChangesSpy(changes); + } + } + + describe('.fixture', () => { + it('is provided', async () => { + const ctx = new ComponentContext(TestComponent); + await ctx.run(() => { + expect(ctx.fixture).toBeInstanceOf(ComponentFixture); + }); + }); + }); + + describe('constructor', () => { + it('accepts module metadata to be bootstrapped', async () => { + const value = Symbol(''); + const token = new InjectionToken('tok'); + const ctx = new ComponentContext(TestComponent, { + providers: [{ provide: token, useValue: value }], + }); + await ctx.run(() => { + expect(ctx.inject(token)).toBe(value); + }); + }); + + it('supports standalone components', async () => { + @Component({ standalone: true, template: 'hi' }) + class StandaloneComponent {} + + const ctx = new ComponentContext(StandaloneComponent); + await ctx.run(() => { + expect(ctx.fixture.nativeElement.textContent).toBe('hi'); + }); + }); + + it('supports non-standalone components', async () => { + // eslint-disable-next-line @angular-eslint/prefer-standalone + @Component({ standalone: false, template: 'hi' }) + class ModulizedComponent {} + + const ctx = new ComponentContext(ModulizedComponent); + await ctx.run(() => { + expect(ctx.fixture.nativeElement.textContent).toBe('hi'); + }); + }); + + it('errors with a nice message when given a non-component', async () => { + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + class NotAComponent {} + + expect(() => { + // eslint-disable-next-line no-new -- nothing more is needed for this test + new ComponentContext(NotAComponent); + }).toThrow('That does not appear to be a component'); + }); + }); + + describe('.assignInputs()', () => { + // it('updates the inputs with zone', async () => { + // const ctx = new ComponentContext(TestComponent, { + // providers: [provideZoneChangeDetection()], + // }); + // await ctx.run(async () => { + // await ctx.assignInputs({ name: 'New Guy' }); + // expect(ctx.fixture.nativeElement.textContent).toContain('New Guy'); + // }); + // }); + + it('updates the inputs without zone', async () => { + const ctx = new ComponentContext(TestComponent, { + providers: [provideZonelessChangeDetection()], + }); + await ctx.run(async () => { + await ctx.assignInputs({ name: 'New Guy' }); + expect(ctx.fixture.nativeElement.textContent).toContain('New Guy'); + }); + }); + + it('triggers ngOnChanges() with the proper changes argument', async () => { + const ctx = new ComponentContext(ChangeDetectingComponent); + await ctx.run(async () => { + const spy = ctx.getComponentInstance().ngOnChangesSpy; + spy.mockClear(); + await ctx.assignInputs({ myInput: 'new value' }); + expect(spy).toHaveBeenCalledTimes(1); + const changes: SimpleChanges = vi.mocked(spy).mock.lastCall![0]; + expect(changes['myInput'].currentValue).toBe('new value'); + }); + }); + + it('errors with a nice message when given a non-input', async () => { + @Component({ template: '' }) + class NonInputComponent { + // eslint-disable-next-line @angular-eslint/no-input-rename + readonly letsTryToTrickIt = input('', { alias: 'nonInput' }); + nonInput?: string; + } + + const ctx = new ComponentContext(NonInputComponent); + await expect(ctx.assignInputs({ nonInput: 'value' })).rejects.toThrow( + 'Cannot bind to "nonInput"; it is not an input', + ); + await ctx.run(noop); + }); + + it('supports signal and non-signal inputs', async () => { + @Component({ template: '{{optional()}} {{required()}} {{legacy}}' }) + class SignalComponent { + // eslint-disable-next-line @angular-eslint/prefer-signals -- this is the point of the test + @Input() legacy!: string; + readonly optional = input(); + readonly required = input.required(); + } + const ctx = new ComponentContext(SignalComponent); + await ctx.assignInputs({ + optional: 'optional', + required: 'required', + legacy: 'legacy', + }); + await ctx.run(async () => { + expect(ctx.fixture.nativeElement.textContent).toBe( + 'optional required legacy', + ); + }); + }); + + it('can handle renamed inputs', async () => { + @Component({ template: '{{ propName() }}' }) + class RenamedInputComponent { + // eslint-disable-next-line @angular-eslint/no-input-rename + readonly propName = input('', { alias: 'templateName' }); + } + + const ctx = new ComponentContext(RenamedInputComponent); + await ctx.assignInputs({ propName: 'custom value' }); + await ctx.run(() => { + expect(ctx.fixture.nativeElement.textContent).toContain('custom value'); + }); + }); + + it('can handle inputs that are setters', async () => { + @Component({ template: '' }) + class SetterInputComponent { + receivedValue?: string; + + // eslint-disable-next-line @angular-eslint/prefer-signals -- this test is about supporting a legacy behavior + @Input() + set setterInput(value: string) { + this.receivedValue = value; + } + } + + const ctx = new ComponentContext(SetterInputComponent); + await ctx.assignInputs({ setterInput: 'sent value' }); + await ctx.run(() => { + expect(ctx.getComponentInstance().receivedValue).toBe('sent value'); + }); + }); + + it("can handle components that don't have inputs", async () => { + @Component({ template: '' }) + class NoInputComponent {} + + await expect( + new ComponentContext(NoInputComponent).run(noop), + ).resolves.not.toThrow(); + }); + + // https://github.com/simontonsoftware/s-libs/issues/40 + it('can handle inputs defined by a superclass (production bug)', async () => { + @Directive() + class SuperclassComponent { + readonly superclassInput = input(''); + } + + @Component({ template: '' }) + class SubclassComponent extends SuperclassComponent { + readonly subclassInput = input(''); + } + + const ctx = new ComponentContext(SubclassComponent); + await ctx.assignInputs({ superclassInput: 'an actual value' }); + await ctx.run(() => { + expect(ctx.getComponentInstance().superclassInput()).toBe( + 'an actual value', + ); + }); + }); + + it('allows using default values for inputs', async () => { + @Component({ template: '' }) + class UnboundInputComponent { + readonly doNotBind = input('default value'); + } + const ctx = new ComponentContext(UnboundInputComponent, {}); + await ctx.run(() => { + expect(ctx.getComponentInstance().doNotBind()).toBe('default value'); + }); + }); + }); + + describe('.assignWrapperStyles()', () => { + it('can be used before .run()', async () => { + const ctx = new ComponentContext(TestComponent); + await ctx.assignWrapperStyles({ border: '1px solid black' }); + await ctx.run(() => { + const wrapper = ctx.fixture.debugElement.query( + By.css('.s-libs-dynamic-wrapper'), + ); + expect(wrapper.styles).toEqual( + expect.objectContaining({ border: '1px solid black' }), + ); + }); + }); + + it('changes (only) the passed-in styles', async () => { + const ctx = new ComponentContext(TestComponent); + await ctx.assignWrapperStyles({ border: '1px solid black' }); + await ctx.run(async () => { + await ctx.assignWrapperStyles({ 'background-color': 'blue' }); + const wrapper = ctx.fixture.debugElement.query( + By.css('.s-libs-dynamic-wrapper'), + ); + expect(wrapper.styles).toEqual( + expect.objectContaining({ + border: '1px solid black', + 'background-color': 'blue', + }), + ); + }); + }); + }); + + describe('.getComponentInstance()', () => { + it('returns the instantiated component', async () => { + const ctx = new ComponentContext(TestComponent); + await ctx.assignInputs({ name: 'instantiated name' }); + await ctx.run(() => { + expect(ctx.getComponentInstance().name()).toBe('instantiated name'); + }); + }); + }); + + describe('.init()', async () => { + it('creates a component of the type specified in the constructor', async () => { + const ctx = new ComponentContext(TestComponent); + await ctx.run(() => { + expect(ctx.getComponentInstance()).toBeInstanceOf(TestComponent); + }); + }); + + it('triggers ngOnChanges if there are inputs', async () => { + const ctx = new ComponentContext(ChangeDetectingComponent); + await ctx.assignInputs({ myInput: 'blah' }); + await ctx.run(() => { + const spy = ctx.getComponentInstance().ngOnChangesSpy; + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + it('does not triggers ngOnChanges if there are no inputs', async () => { + const ctx = new ComponentContext(ChangeDetectingComponent); + await ctx.run(() => { + const spy = ctx.getComponentInstance().ngOnChangesSpy; + expect(spy).toHaveBeenCalledTimes(0); + }); + }); + + it('waits for app init (via superclass)', async () => { + const deferred = new Deferred(); + let componentCreated = false; + + @Component({ template: '' }) + class InitializingComponent { + constructor() { + componentCreated = true; + } + } + const ctx = new ComponentContext(InitializingComponent, { + providers: [provideAppInitializer(async () => deferred.promise)], + }); + const testPromise = ctx.run(noop); + + await vi.advanceTimersByTimeAsync(0); + expect(componentCreated).toBe(false); + deferred.resolve(); + await testPromise; + expect(componentCreated).toBe(true); + }); + + it('does not trigger "ApplicationRef.tick is called recursively" (prod bug)', async () => { + const trigger = signal(false); + + @Component({ template: '' }) + class LocalComponent { + constructor() { + effect(() => { + trigger(); + }); + } + } + + const ctx = new ComponentContext(LocalComponent); + await expect( + ctx.run(() => { + trigger.set(true); + }), + ).resolves.not.toThrow(); + }); + + it("uses the component's selector if it is a tag name", async () => { + @Component({ selector: 'sl-tag-name', template: '' }) + class TagNameComponent {} + + const ctx = new ComponentContext(TagNameComponent); + await ctx.run(() => { + const debugEl = ctx.fixture.debugElement.query( + By.directive(TagNameComponent), + ); + expect(debugEl.nativeElement.tagName).toBe('SL-TAG-NAME'); + }); + }); + + it("can handle components that don't have a selector", async () => { + @Component({ template: 'the template' }) + class NoSelectorComponent {} + + const ctx = new ComponentContext(NoSelectorComponent); + await ctx.run(() => { + expect(ctx.fixture.nativeElement.textContent).toContain('the template'); + }); + }); + }); + + describe('.runChangeDetection()', () => { + // it('gets change detection working inside the fixture with zone', async () => { + // const ctx = new ComponentContext(TestComponent, { + // providers: [provideZoneChangeDetection()], + // }); + // await ctx.run(async () => { + // ctx.getComponentInstance().name.set('Changed Guy'); + // expect(ctx.fixture.nativeElement.textContent).not.toContain( + // 'Changed Guy', + // ); + // await ctx.tick(); + // expect(ctx.fixture.nativeElement.textContent).toContain('Changed Guy'); + // }); + // }); + + it('gets change detection working inside the fixture without zone', async () => { + const ctx = new ComponentContext(TestComponent, { + providers: [provideZonelessChangeDetection()], + }); + await ctx.run(async () => { + ctx.getComponentInstance().name.set('Changed Guy'); + expect(ctx.fixture.nativeElement.textContent).not.toContain( + 'Changed Guy', + ); + await ctx.tick(); + expect(ctx.fixture.nativeElement.textContent).toContain('Changed Guy'); + }); + }); + }); + + describe('.cleanUp()', () => { + it('destroys the fixture', async () => { + const ctx = new ComponentContext(TestComponent); + await ctx.run(noop); + // This was the test in Angular 13, and still fails if the fixture is not destroyed in 14 + // ctx.getComponentInstance().name = 'Changed Guy'; + // ctx.fixture.detectChanges(); + // expect(ctx.fixture.nativeElement.textContent).not.toContain( + // 'Changed Guy', + // ); + expect(() => { + ctx.getComponentInstance(); + }).toThrow(); + }); + }); + + it('has fancy typing', () => { + staticTest(async () => { + const ctx = new ComponentContext(TestComponent); + expectTypeOf(ctx.fixture).toEqualTypeOf>(); + await ctx.assignInputs({}); + await ctx.assignInputs({ name: 'blah' }); + // @ts-expect-error -- name must be a string + await ctx.assignInputs({ name: 2 }); + expectTypeOf(ctx.getComponentInstance()).toEqualTypeOf(); + }); + }); +}); + +describe('ComponentContext class-level doc examples', () => { + describe('simple example', () => { + @Component({ template: 'Hello, {{name()}}!' }) + class GreeterComponent { + readonly name = input.required(); + } + + it('greets you by name', async () => { + const ctx = new ComponentContext(GreeterComponent); + await ctx.assignInputs({ name: 'World' }); + await ctx.run(() => { + expect(ctx.fixture.nativeElement.textContent).toBe('Hello, World!'); + }); + }); + }); + + describe('full example with routing', () => { + ///////////////// + // app-context.ts + + // To re-use your context setup, make a subclass of ComponentContext to import into any spec + class AppContext extends ComponentContext { + constructor() { + // Import `appConfig` from `app.config.ts` + super(AppComponent, appConfig); + } + } + + //////////////////////// + // app.component.spec.ts + + describe('AppComponent', () => { + let ctx: AppContext; + beforeEach(() => { + ctx = new AppContext(); + }); + + it('can navigate to the first page', async () => { + await ctx.run(async () => { + const app = await ctx.getHarness(AppComponentHarness); + await app.navigateToFirstPage(); + expect(ctx.fixture.nativeElement.textContent).toContain( + 'First works!', + ); + }); + }); + }); + + /////////////////////////// + // app.component.harness.ts + + // A simple component harness to demonstrate its integration with component contexts + class AppComponentHarness extends ComponentHarness { + static hostSelector = 'app-root'; + + #getFirstPageLink = this.locatorFor('a'); + + async navigateToFirstPage(): Promise { + const link = await this.#getFirstPageLink(); + await link.click(); + } + } + + ///////////////////// + // first.component.ts + + // A minimal component for demonstration purposes + @Component({ template: '

First works!

' }) + class FirstComponent {} + + /////////////////// + // app.component.ts + + // A minimal app component with routing for demonstration purposes + @Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLink], + template: ` + First Page + + `, + }) + class AppComponent {} + + //////////////////////// + // app.routes.ts + + const routes: Routes = [{ path: 'first-page', component: FirstComponent }]; + + //////////////// + // app.config.ts + + const appConfig: ApplicationConfig = { providers: [provideRouter(routes)] }; + }); +}); diff --git a/projects/ng-vitest/src/lib/component-context/component-context.ts b/projects/ng-vitest/src/lib/component-context/component-context.ts new file mode 100644 index 00000000..70b8a17e --- /dev/null +++ b/projects/ng-vitest/src/lib/component-context/component-context.ts @@ -0,0 +1,275 @@ +import { NgComponentOutlet, NgStyle } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + input, + inputBinding, + reflectComponentType, + Signal, + Type, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + TestModuleMetadata, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { assert, mapToObject } from '@s-libs/js-core'; +import { forOwn } from '@s-libs/micro-dash'; +import { RootStore } from '@s-libs/signal-store'; +import { + AngularContext, + extendMetadata, +} from '../angular-context/angular-context'; + +type Inputs = { + [K in keyof T]?: T[K] extends Signal ? U : T[K]; +}; + +@Component({ + imports: [NgComponentOutlet, NgStyle], + template: ` +
+ +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WrapperComponent { + readonly componentType = input.required>(); + readonly inputs = input.required>(); + readonly styles = input.required>(); +} + +/** + * Provides the foundation for an opinionated pattern for component tests. + * + * - Includes all features from {@link AngularContext} + * - Automatically creates your component at the beginning of `run()`. + * - Sets up Angular to call `ngOnChanges()` like it would in production. This is not the case if you use the standard `TestBed.createComponent()` directly. + * - Wraps your component in a parent that you can easily style however you like. + * - Lets you use {@link https://material.angular.dev/cdk/testing/overview | component harnesses} in the `fakeAsync` zone, which is normally a challenge. + * - Causes async {@link https://angular.dev/api/core/APP_INITIALIZER | APP_INITIALIZER}s to complete before instantiating the component. Two caveats: + * - this requires all work in your initializers to complete with a call to `tick()` + * - this requires delaying app initialization until inside the `fakeAsync` zone, i.e. with the callback to {@link #run}. If you have async initializers, you must be careful not to do things that finalize the app setup before then, such as {@link #inject}. + * + * A very simple example: + * ```ts + * @Component({ template: 'Hello, {{name()}}!' }) + * class GreeterComponent { + * readonly name = input.required(); + * } + * + * it('greets you by name', () => { + * const ctx = new ComponentContext(GreeterComponent); + * ctx.assignInputs({ name: 'World' }); + * ctx.run(() => { + * expect(ctx.fixture.nativeElement.textContent).toBe('Hello, World!'); + * }); + * }); + * ``` + * + * A full example, with routing and a component harness. This is the full code for a tiny Angular app: + * ```ts + * ///////////////// + * // app-context.ts + * + * // To re-use your context setup, make a subclass of ComponentContext to import into any spec + * class AppContext extends ComponentContext { + * constructor() { + * // Import `appConfig` from `app.config.ts` + * super(AppComponent, appConfig); + * } + * } + * + * //////////////////////// + * // app.component.spec.ts + * + * describe('AppComponent', () => { + * let ctx: AppContext; + * beforeEach(() => { + * ctx = new AppContext(); + * }); + * + * it('can navigate to the first page', () => { + * ctx.run(async () => { + * const app = await ctx.getHarness(AppComponentHarness); + * await app.navigateToFirstPage(); + * expect(ctx.fixture.nativeElement.textContent).toContain( + * 'First works!', + * ); + * }); + * }); + * }); + * + * /////////////////////////// + * // app.component.harness.ts + * + * // A simple component harness to demonstrate its integration with component contexts + * class AppComponentHarness extends ComponentHarness { + * static hostSelector = 'app-root'; + * + * #getFirstPageLink = this.locatorFor('a'); + * + * async navigateToFirstPage(): Promise { + * const link = await this.#getFirstPageLink(); + * await link.click(); + * } + * } + * + * ///////////////////// + * // first.component.ts + * + * // A minimal component for demonstration purposes + * @Component({ template: '

First works!

' }) + * class FirstComponent {} + * + * /////////////////// + * // app.component.ts + * + * // A minimal app component with routing for demonstration purposes + * @Component({ + * selector: 'app-root', + * imports: [RouterOutlet, RouterLink], + * template: ` + * First Page + * + * `, + * }) + * class AppComponent {} + * + * //////////////////////// + * // app.routes.ts + * + * const routes: Routes = [{ path: 'first-page', component: FirstComponent }]; + * + * //////////////// + * // app.config.ts + * + * const appConfig: ApplicationConfig = { providers: [provideRouter(routes)] }; + * ``` + */ +export class ComponentContext extends AngularContext { + /** + * The {@link ComponentFixture} for a synthetic wrapper around your component. Available with the callback to `run()`. + */ + fixture!: ComponentFixture; + + readonly #componentType: Type; + readonly #propToTemplateNames: Record; + readonly #inputs = new RootStore>({}); + readonly #wrapperStyles = new RootStore>({}); + + /** + * @param componentType `run()` will create a component of this type before running the rest of your test. + * @param moduleMetadata passed along to {@linkcode https://angular.dev/api/core/testing/TestBedStatic#configureTestingModule | TestBed.configureTestingModule()}. Automatically includes those provided by {@link AngularContext}. + * @param unboundInputs By default a synthetic parent component will be created that binds to all your component's inputs. Pass input names here that should NOT be bound. This is useful e.g. to test the default value of an input. + */ + constructor(componentType: Type, moduleMetadata: TestModuleMetadata = {}) { + const mirror = reflectComponentType(componentType); + assert(mirror, 'That does not appear to be a component'); + const imports: any[] = [WrapperComponent]; + const declarations: any[] = []; + (mirror.isStandalone ? imports : declarations).push(componentType); + super(extendMetadata({ imports, declarations }, moduleMetadata)); + + this.#componentType = componentType; + this.#propToTemplateNames = mapToObject(mirror.inputs, (i) => [ + i.propName, + i.templateName, + ]); + } + + assignInitialWrapperStyles(styles: Record): void { + assert( + !this.#isInitialized(), + 'run() was already called; use `assignWrapperStyles()`', + ); + this.#wrapperStyles.assign(styles); + } + /** + * Assign css styles to the div wrapping your component. Can be called before or during `run()`. Accepts an object with the same structure as the {@link https://angular.dev/api/common/NgStyle | ngStyle directive}. + * + * ```ts + * ctx.assignWrapperStyles({ + * width: '400px', + * height: '600px', + * margin: '20px auto', + * border: '1px solid', + * }); + * ``` + */ + async assignWrapperStyles(styles: Record): Promise { + this.#wrapperStyles.assign(styles); + + if (this.#isInitialized()) { + await this.tick(); + } + } + + /** + * Assign inputs passed into your component. Can be called before `run()` to set the initial inputs, or within `run()` to update them and trigger all the appropriate change detection and lifecycle hooks. + */ + async assignInputs(inputs: Inputs): Promise { + forOwn(inputs, (value, propName) => { + const templateName = this.#propToTemplateNames[propName]; + if (templateName) { + this.#inputs(templateName).state = value; + } else { + throw new Error( + `Cannot bind to "${String(propName)}"; it is not an input`, + ); + } + }); + if (this.#isInitialized()) { + await this.tick(); + } + } + + /** + * Use within `run()` to get your instantiated component that is on the page. + */ + getComponentInstance(): T { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.fixture.debugElement.query(By.directive(this.#componentType)) + .componentInstance; + } + + /** + * Constructs and initializes your component. Called during `run()` before it executes the rest of your test. Runs in the same `fakeAsync` zone as the rest of your test. + */ + protected override async init(): Promise { + await super.init(); + + this.fixture = TestBed.createComponent(WrapperComponent, { + bindings: [ + inputBinding('componentType', () => this.#componentType), + inputBinding('inputs', () => this.#inputs.state), + inputBinding('styles', () => this.#wrapperStyles.state), + ], + }); + + // this.fixture.detectChanges(); + await this.tick(); + } + + protected override runChangeDetection(): void { + if (this.#isInitialized()) { + this.fixture.detectChanges(); + } else { + super.runChangeDetection(); + } + } + + /** + * Performs any cleanup needed at the end of each test. This implementation destroys {@link fixture} and calls the super implementation. + */ + protected override cleanUp(): void { + this.fixture.destroy(); + super.cleanUp(); + } + + #isInitialized(): boolean { + return !!this.fixture; + } +} diff --git a/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.spec.ts b/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.spec.ts new file mode 100644 index 00000000..8badc9c1 --- /dev/null +++ b/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.spec.ts @@ -0,0 +1,102 @@ +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { ComponentContext } from '../component-context/component-context'; +import { ComponentHarnessSuperclass } from './component-harness-superclass'; + +describe('ComponentHarnessSuperclass', () => { + it('has a working getTopLevelHarness()', async () => { + @Component({ + selector: 'sl-inner-component', + standalone: true, + template: ``, + }) + class InnerComponent {} + + @Component({ + imports: [InnerComponent, MatButtonModule], + template: ` + + + `, + }) + class TestComponent {} + + class InnerComponentHarness extends ComponentHarnessSuperclass { + static hostSelector = 'sl-inner-component'; + + async getButton(): Promise { + return this.getTopLevelHarness(MatButtonHarness); + } + } + + const ctx = new ComponentContext(TestComponent); + await ctx.run(async () => { + const innerComponent = await ctx.getHarness(InnerComponentHarness); + expect(await innerComponent.getButton()).toBeInstanceOf(MatButtonHarness); + }); + }); + + it('has a working getAllTopLevelHarnesses()', async () => { + @Component({ + selector: 'sl-inner-component', + standalone: true, + template: ``, + }) + class InnerComponent {} + + @Component({ + imports: [InnerComponent, MatButtonModule], + template: ` + + + + `, + }) + class TestComponent {} + + class InnerComponentHarness extends ComponentHarnessSuperclass { + static hostSelector = 'sl-inner-component'; + + async getButtons(): Promise { + return this.getAllTopLevelHarnesses(MatButtonHarness); + } + } + + const ctx = new ComponentContext(TestComponent); + await ctx.run(async () => { + const innerComponent = await ctx.getHarness(InnerComponentHarness); + const buttons = await innerComponent.getButtons(); + expect(buttons.length).toBe(2); + }); + }); + + it('allows harness to restrict their loaders to sub-components (a bug that bugged me for a long time!)', async () => { + @Component({ + selector: 'sl-inner-component', + imports: [MatButtonModule], + template: ``, + }) + class InnerComponent {} + + @Component({ + imports: [InnerComponent, MatButtonModule], + template: ` + + + `, + }) + class TestComponent {} + + class InnerComponentHarness extends ComponentHarnessSuperclass { + static hostSelector = 'sl-inner-component'; + } + + const ctx = new ComponentContext(TestComponent); + await ctx.run(async () => { + const myComponent = await ctx.getHarness(InnerComponentHarness); + const button = await myComponent.getHarness(MatButtonHarness); + expect(await button.getText()).toBe('Inner Button'); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.ts b/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.ts new file mode 100644 index 00000000..a4075380 --- /dev/null +++ b/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.ts @@ -0,0 +1,38 @@ +import { + ComponentHarness, + ContentContainerComponentHarness, + HarnessLoader, + HarnessQuery, +} from '@angular/cdk/testing'; + +/** + * Provides some shorthand utilities that component harnesses may want. + */ +export class ComponentHarnessSuperclass extends ContentContainerComponentHarness { + /** + * Searches for an instance of the component corresponding to the given harness type under the document root element, and returns a {@link ComponentHarness} for that instance. If multiple matching components are found, a harness for the first one is returned. If no matching component is found, an error is thrown. + */ + protected async getTopLevelHarness( + predicate: HarnessQuery, + ): Promise { + const loader = await this.getTopLevelLoader(); + return loader.getHarness(predicate); + } + + /** + * Searches for all instances of the component corresponding to the given harness type under the document root element, and returns a list {@link ComponentHarness} for each instance. + */ + protected async getAllTopLevelHarnesses( + predicate: HarnessQuery, + ): Promise { + const loader = await this.getTopLevelLoader(); + return loader.getAllHarnesses(predicate); + } + + /** + * Gets a {@link HarnessLoader} for the document root element. This loader can be used for elements that a component creates outside its own root element (e.g. by appending to document.body). + */ + protected async getTopLevelLoader(): Promise { + return this.documentRootLocatorFactory().rootHarnessLoader(); + } +} diff --git a/projects/ng-vitest/src/lib/expectations.ts b/projects/ng-vitest/src/lib/expectations.ts new file mode 100644 index 00000000..f65a878a --- /dev/null +++ b/projects/ng-vitest/src/lib/expectations.ts @@ -0,0 +1,13 @@ +import { DeeplyAllowMatchers } from 'vitest'; + +export function expectExactContents( + actual: T[], + expected: Array>, +): void { + expect(actual).toHaveLength(expected.length); + expect(actual).toEqual(expect.arrayContaining(expected)); +} + +export function arrayWithMatch(expected: string | RegExp): any { + return expect.arrayContaining([expect.stringMatching(expected)]); +} diff --git a/projects/ng-vitest/src/lib/interfaces.ts b/projects/ng-vitest/src/lib/interfaces.ts new file mode 100644 index 00000000..21606dac --- /dev/null +++ b/projects/ng-vitest/src/lib/interfaces.ts @@ -0,0 +1,5 @@ +export type Func = (...args: any[]) => any; + +export type ResolveType = F extends (...args: any[]) => Promise + ? U + : never; diff --git a/projects/ng-vitest/src/lib/mocks/async-method-controller.spec.ts b/projects/ng-vitest/src/lib/mocks/async-method-controller.spec.ts new file mode 100644 index 00000000..d119a133 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/async-method-controller.spec.ts @@ -0,0 +1,358 @@ +import { MockParameters } from '@vitest/spy'; +import { expectTypeOf } from 'expect-type'; +import { AngularContext } from '../angular-context'; +import { expectExactContents } from '../expectations'; +import { staticTest } from '../static-test/static-test'; +import { AsyncMethodController } from './async-method-controller'; + +describe('AsyncMethodController', () => { + function triggerRead(): void { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + navigator.clipboard.readText(); + } + + function triggerWrite(text: string): void { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + navigator.clipboard.writeText(text); + } + + describe('constructor', () => { + it('allows the controlled method to be called immediately', () => { + // eslint-disable-next-line no-new -- nothing more is needed for this test + new AsyncMethodController(navigator.clipboard, 'readText'); + + expect(triggerRead).not.toThrow(); + }); + }); + + describe('.expectOne()', () => { + it('finds a matching method call', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + triggerWrite('value 1'); + triggerWrite('value 2'); + + const match = controller.expectOne((args) => args[0] === 'value 2'); + + expect(match.args[0]).toEqual('value 2'); + }); + + it('throws when there is no match', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + triggerRead(); + + expect(() => { + controller.expectOne(() => false); + }).toThrow( + 'Expected one matching call(s) for criterion "Match by function: ", found 0', + ); + }); + + it('throws when there have been no calls', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + expect(() => { + controller.expectOne(() => true); + }).toThrow( + 'Expected one matching call(s) for criterion "Match by function: ", found 0', + ); + }); + + it('throws when there is more than one match', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + triggerRead(); + triggerRead(); + + expect(() => { + controller.expectOne(() => true); + }).toThrow( + 'Expected one matching call(s) for criterion "Match by function: ", found 2', + ); + }); + + it('has fancy typing', () => { + staticTest(() => { + const writeController = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + expectTypeOf(writeController.expectOne) + .parameter(0) + .toEqualTypeOf< + | ((args: MockParameters) => boolean) + | [data: string] + >(); + triggerWrite('fake text'); + writeController.expectOne((args) => { + expectTypeOf(args).toEqualTypeOf< + MockParameters<(data: string) => Promise> + >(); + return true; + }); + + const readController = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + expectTypeOf(readController.expectOne) + .parameter(0) + .toEqualTypeOf< + ((args: MockParameters) => boolean) | [] + >(); + triggerRead(); + readController.expectOne((args) => { + expectTypeOf(args).toEqualTypeOf< + MockParameters<() => Promise> + >(); + return true; + }); + }); + }); + }); + + describe('.expectNone()', () => { + it('throws if any call matches', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + triggerWrite('value 1'); + triggerWrite('value 2'); + + expect(() => { + controller.expectNone((args) => args[0] === 'value 2'); + }).toThrow( + 'Expected zero matching call(s) for criterion "Match by function: ", found 1', + ); + }); + + it('does not throw when no call matches', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + expect(() => { + controller.expectNone(() => false); + }).not.toThrow(); + }); + + it('accepts an array of arguments to match against', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + triggerWrite('value 1'); + triggerWrite('value 2'); + + expect(() => { + controller.expectNone(['value 2']); + }).toThrow(/found 1/u); + }); + }); + + describe('.match()', () => { + it('finds matching method calls', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + triggerWrite('value 1'); + triggerWrite('value 2'); + triggerWrite('value 3'); + + const matches = controller.match((args) => args[0] !== 'value 2'); + + expectExactContents( + matches.map((match) => match.args[0]), + ['value 1', 'value 3'], + ); + }); + + it('accepts an array of arguments to match against', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + triggerWrite('value 1'); + triggerWrite('value 2'); + triggerWrite('value 1'); + + const matches = controller.match(['value 1']); + + expectExactContents( + matches.map((match) => match.args[0]), + ['value 1', 'value 1'], + ); + }); + + it('uses deep equality matching for the arguments shorthand', () => { + const controller = new AsyncMethodController(window, 'fetch'); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetch('a fake url', { method: 'GET' }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetch('a fake url', { method: 'POST' }); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetch('a fake url', { method: 'GET' }); + + const matches = controller.match(['a fake url', { method: 'GET' }]); + + expectExactContents( + matches.map((match) => match.args[1]), + [{ method: 'GET' }, { method: 'GET' }], + ); + }); + + it('removes the matching calls from future matching', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + triggerWrite('value 1'); + triggerWrite('value 2'); + + controller.match((args) => args[0] === 'value 2'); + + expect(controller.match(() => true).length).toBe(1); + }); + + it('returns an empty array when there have been no calls', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + const matches = controller.match(() => false); + expect(matches).toEqual([]); + }); + + it('gracefully handles when no calls match', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + triggerRead(); + + const matches = controller.match(() => false); + + expect(matches).toEqual([]); + }); + }); + + describe('.verify()', () => { + it('does not throw when all calls have been expected', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + // no error when no calls were made at all + expect(() => { + controller.verify(); + }).not.toThrow(); + + // no error when a call was made, but also already expected + triggerRead(); + controller.expectOne([]); + expect(() => { + controller.verify(); + }).not.toThrow(); + }); + + it('throws if there is an outstanding call, including the number of them', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + + // when multiple calls have not been expected + triggerWrite('call 1'); + triggerWrite('call 2'); + expect(() => { + controller.verify(); + }).toThrow(/Expected no open call\(s\), found 2:/u); + + // when SOME calls have already been expected, but not all + controller.expectOne(['call 2']); + expect(() => { + controller.verify(); + }).toThrow(/Expected no open call\(s\), found 1:/u); + }); + + it('includes a nice representation of the outstanding calls in the error message', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + triggerWrite('call 1'); + triggerWrite('call 2'); + + expect(() => { + controller.verify(); + }).toThrow(/\n {2}\["call 1"\]\n {2}\["call 2"\]/u); + }); + }); + + describe('.#ensureCallInfoIsSet()', () => { + it('correctly initializes TestCall objects even after others have been matched', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + + // make call1, causing: + // testCalls: [call1] + // spy.calls: [call1] + triggerWrite('call1'); + + // match call1, causing: + // testCalls: [] + // spy.calls: [call1] + controller.expectOne(() => true); + + // make call2, causing: + // testCalls: [call2] + // spy.calls: [call1, call2] + triggerWrite('call2'); + + // try matching call2 + const testCall = controller.expectOne(() => true); + expect(testCall.args[0]).toBe('call2'); + }); + }); + + describe('example from the docs', () => { + it('can paste', async () => { + const { clipboard } = navigator; + const ctx = new AngularContext(); + + // mock the browser API for pasting + const controller = new AsyncMethodController(clipboard, 'readText'); + await ctx.run(async () => { + // BEGIN production code that copies to the clipboard + let pastedText: string; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clipboard.readText().then((text) => { + pastedText = text; + }); + // END production code that copies to the clipboard + + await controller.expectOne([]).flush('mock clipboard contents'); + + // BEGIN expect the correct results after a successful copy + expect(pastedText!).toBe('mock clipboard contents'); + // END expect the correct results after a successful copy + }); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/mocks/async-method-controller.ts b/projects/ng-vitest/src/lib/mocks/async-method-controller.ts new file mode 100644 index 00000000..22f6bd64 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/async-method-controller.ts @@ -0,0 +1,203 @@ +import { Deferred } from '@s-libs/js-core'; +import { isEqual, isUndefined, nth, remove } from '@s-libs/micro-dash'; +import { MockParameters } from '@vitest/spy'; +import { Mock, onTestFinished } from 'vitest'; +import { buildErrorMessage } from '../../ng-dev/spies/utils'; +import { TestCall } from './test-call'; + +type AsyncFunc = (...args: any[]) => Promise; + +type AsyncMethodKeys = keyof { + [k in keyof T as T[k] extends AsyncFunc ? k : never]: 1; +}; + +type AsyncMethod< + WrappingObject, + FunctionName extends keyof WrappingObject, +> = WrappingObject[FunctionName] extends (...args: any[]) => Promise + ? WrappingObject[FunctionName] + : never; + +type Match< + WrappingObject, + FunctionName extends AsyncMethodKeys, +> = + | Parameters> + | (( + args: MockParameters>, + ) => boolean); + +/** + * Controller to be used in tests, that allows for mocking and flushing any asynchronous function. For example, to mock the browser's paste functionality: + * + * ```ts + * it('can paste', () => { + * const clipboard = navigator.clipboard; + * const ctx = new AngularContext(); + * + * // mock the browser API for pasting + * const controller = new AsyncMethodController(clipboard, 'readText'); + * ctx.run(() => { + * // BEGIN production code that copies to the clipboard + * let pastedText: string; + * clipboard.readText().then((text) => { + * pastedText = text; + * }); + * // END production code that copies to the clipboard + * + * controller.expectOne([]).flush('mock clipboard contents'); + * + * // BEGIN expect the correct results after a successful copy + * expect(pastedText!).toBe('mock clipboard contents'); + * // END expect the correct results after a successful copy + * }); + * }); + * ``` + */ +export class AsyncMethodController< + WrappingObject extends object, + FunctionName extends AsyncMethodKeys, +> { + #mock: Mock>; + #testCalls: Array>> = []; + + /** + * If you are using an `AngularContext`, the default behavior is to automatically call `.tick()` after each `.flush()` and `.error()` to trigger promise handlers and changed detection. This is the normal production behavior of asynchronous browser APIs. However, if zone.js does not patch the function you are stubbing, change detection would not run automatically. In that case you many want to turn off this behavior by passing the option `autoTick: false`. See the list of functions that zone.js patches [here](https://github.com/angular/angular/blob/master/packages/zone.js/STANDARD-APIS.md). + */ + constructor( + obj: WrappingObject, + methodName: FunctionName, + { autoTick = true } = {}, + ) { + /* eslint-disable @typescript-eslint/no-unsafe-type-assertion,@typescript-eslint/no-unsafe-return -- it wasn't immediately clear how to avoid `any` in this constructor, and this will be invisible to users. So I gave up. (For now?) */ + this.#mock = vi.spyOn(obj, methodName) as any; + this.#mock.mockImplementation((async () => { + console.log('async method called'); + const deferred = new Deferred(); + this.#testCalls.push(new TestCall(deferred, autoTick)); + return deferred.promise; + }) as any); + onTestFinished(() => { + this.#mock.mockRestore(); + }); + } + + /** + * Expect that a single call was made that matches the given parameters or predicate, and return its mock. If no such call was made, or more than one, fail with a message including `description`, if provided. + */ + expectOne( + match: Match, + description?: string, + ): TestCall> { + const matches = this.match(match); + if (matches.length !== 1) { + throw new Error( + buildErrorMessage({ + matchType: 'one matching', + itemType: 'call', + matches, + stringifiedUserInput: this.#stringifyUserInput(match, description), + }), + ); + } + return matches[0]; + } + + /** + * Expect that no calls were made which match the given parameters or predicate. If a matching call was made, fail with a message including `description`, if provided. + */ + expectNone( + match: Match, + description?: string, + ): void { + const matches = this.match(match); + if (matches.length > 0) { + throw new Error( + buildErrorMessage({ + matchType: 'zero matching', + itemType: 'call', + stringifiedUserInput: this.#stringifyUserInput(match, description), + matches, + }), + ); + } + } + + /** + * Search for calls that match the given parameters or predicate, without any expectations. + */ + match( + match: Match, + ): Array>> { + this.#ensureArgsSet(); + let filterFn: ( + args: MockParameters>, + ) => boolean; + if (Array.isArray(match)) { + filterFn = this.#makeArgumentMatcher(match); + } else { + filterFn = match; + } + return remove( + this.#testCalls, + (testCall: TestCall>) => + filterFn(testCall.args), + ); + } + + /** + * Verify that no unmatched calls are outstanding. If any are, fail with a message indicating which calls were not matched. + */ + verify(): void { + if (this.#testCalls.length) { + this.#ensureArgsSet(); + let message = buildErrorMessage({ + matchType: 'no open', + itemType: 'call', + stringifiedUserInput: undefined, + matches: this.#testCalls, + }); + message += ':'; + for (const testCall of this.#testCalls) { + message += `\n ${stringifyArgs(testCall.args)}`; + } + throw new Error(message); + } + } + + #ensureArgsSet(): void { + for (let i = 1; i <= this.#testCalls.length; ++i) { + const testCall = nth(this.#testCalls, -i); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this is part of initialization + testCall.args ??= nth(this.#mock.mock.calls, -i); + } + } + + #makeArgumentMatcher( + expected: Parameters>, + ): ( + actual: MockParameters>, + ) => boolean { + return ( + actual: MockParameters>, + ): boolean => isEqual(actual, expected); + } + + #stringifyUserInput( + match: Match, + description?: string, + ): string { + if (isUndefined(description)) { + if (Array.isArray(match)) { + description = `Match by arguments: ${stringifyArgs(match)}`; + } else { + description = `Match by function: ${match.name}`; + } + } + return description; + } +} + +function stringifyArgs(args: any[]): string { + return JSON.stringify(args); +} diff --git a/projects/ng-vitest/src/lib/mocks/expect-single-call-and-reset.spec.ts b/projects/ng-vitest/src/lib/mocks/expect-single-call-and-reset.spec.ts new file mode 100644 index 00000000..abcaf487 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/expect-single-call-and-reset.spec.ts @@ -0,0 +1,25 @@ +import { expectSingleCallAndReset } from './expect-single-call-and-reset'; + +describe('expectSingleCallAndReset()', () => { + it('matches arguments', () => { + const mock = vi.fn(); + + mock('a thing', 'or two'); + expectSingleCallAndReset(mock, 'a thing', 'or two'); + + mock(); + expectSingleCallAndReset(mock); + }); + + it('resets the mock', () => { + const mock = vi.fn(); + + mock(); + expectSingleCallAndReset(mock); + expect(mock).not.toHaveBeenCalled(); + + mock(1); + expectSingleCallAndReset(mock, 1); + expect(mock).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/ng-vitest/src/lib/mocks/expect-single-call-and-reset.ts b/projects/ng-vitest/src/lib/mocks/expect-single-call-and-reset.ts new file mode 100644 index 00000000..f05a8c7d --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/expect-single-call-and-reset.ts @@ -0,0 +1,22 @@ +import { Mock } from 'vitest'; + +/** + * Expects exactly one call to have been made to a jasmine spy, for it to have received the given arguments, then resets the spy. + * + * ```ts + * const spy = jasmine.createSpy(); + * + * spy(1, 2); + * expectSingleCallAndReset(spy, 1, 2); // pass + * expectSingleCallAndReset(spy, 1, 2); // fail + * + * spy(3); + * spy(4); + * expectSingleCallAndReset(spy, 3); // fail + * ``` + */ +export function expectSingleCallAndReset(mock: Mock, ...args: unknown[]): void { + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(...args); + mock.mockClear(); +} diff --git a/projects/ng-vitest/src/lib/mocks/test-call.spec.ts b/projects/ng-vitest/src/lib/mocks/test-call.spec.ts new file mode 100644 index 00000000..78d4d307 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/test-call.spec.ts @@ -0,0 +1,169 @@ +import { expectTypeOf } from 'expect-type'; +import { AngularContext } from '../angular-context'; +import { staticTest } from '../static-test/static-test'; +import { AsyncMethodController } from './async-method-controller'; +import { expectSingleCallAndReset } from './expect-single-call-and-reset'; + +describe('TestCall', () => { + class TickDetector extends AngularContext { + ticked = false; + + constructor() { + super(); + setTimeout(() => { + this.ticked = true; + }); + } + } + + function triggerRead(): void { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + navigator.clipboard.readText(); + } + + describe('.args', () => { + it('is populated with the arguments passed to the method', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + navigator.clipboard.writeText('hi there'); + + const { args } = controller.expectOne(() => true); + + expect(args).toEqual(['hi there']); + }); + }); + + describe('.flush()', () => { + it('causes the call to be fulfilled with the given value', async () => { + vi.useFakeTimers(); + + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + const mock = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + navigator.clipboard.readText().then(mock); + const testCall = controller.match(() => true)[0]; + + await testCall.flush('the clipboard text'); + await vi.advanceTimersByTimeAsync(0); + + expectSingleCallAndReset(mock, 'the clipboard text'); + + vi.useRealTimers(); + }); + + it('triggers a tick if appropriate', async () => { + const ctx = new TickDetector(); + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + await ctx.run(async () => { + triggerRead(); + const testCall = controller.expectOne([]); + + await testCall.flush('this is the clipboard content'); + expect(ctx.ticked).toBe(true); + }); + }); + }); + + describe('.error()', () => { + it('causes the call to be rejected with the given reason', async () => { + vi.useFakeTimers(); + + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + const mock = vi.fn<(e: unknown) => void>(); + navigator.clipboard.readText().catch(mock); + const testCall = controller.match(() => true)[0]; + + await testCall.error('some problem'); + await vi.advanceTimersByTimeAsync(0); + + expectSingleCallAndReset(mock, 'some problem'); + + vi.useRealTimers(); + }); + + it('triggers a tick if appropriate', async () => { + const ctx = new TickDetector(); + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + await ctx.run(async () => { + triggerRead(); + const testCall = controller.expectOne([]); + + await testCall.error('permission denied'); + expect(ctx.ticked).toBe(true); + }); + }); + }); + + describe('.maybeTick()', () => { + it('does not call .tick() when autoTick is false', async () => { + const ctx = new TickDetector(); + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + { autoTick: false }, + ); + + await ctx.run(async () => { + triggerRead(); + await controller.expectOne([]).flush('this is the clipboard content'); + expect(ctx.ticked).toBe(false); + }); + }); + + it('gracefully handles being run outside an AngularContext', async () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + triggerRead(); + await expect( + controller.expectOne([]).flush('this is the clipboard content'), + ).resolves.not.toThrow(); + }); + }); + + it('has fancy typing', () => { + staticTest(() => { + const writeController = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + const readController = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + const writeTestCall = writeController.expectOne(['something I copied']); + const readTestCall = readController.expectOne([]); + + expectTypeOf(writeTestCall.args).toEqualTypeOf<[data: string]>(); + expectTypeOf(readTestCall.args).toEqualTypeOf<[]>(); + + expectTypeOf(writeTestCall.flush).toEqualTypeOf< + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + (value: void) => Promise + >(); + expectTypeOf(readTestCall.flush).toEqualTypeOf< + (value: string) => Promise + >(); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/mocks/test-call.ts b/projects/ng-vitest/src/lib/mocks/test-call.ts new file mode 100644 index 00000000..306fe6d3 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/test-call.ts @@ -0,0 +1,38 @@ +import { Deferred } from '@s-libs/js-core'; +import { MockParameters } from '@vitest/spy'; +import { AngularContext } from '../angular-context'; +import { Func, ResolveType } from '../interfaces'; + +/** + * A mock method call that was made and is ready to be answered. This interface allows access to the underlying jasmine.CallInfo, and allows resolving or rejecting the asynchronous call's result. + */ +export class TestCall { + args!: MockParameters; + + constructor( + private deferred: Deferred>, + private autoTick: boolean, + ) {} + + /** + * Resolve the call with the given value. + */ + async flush(value: ResolveType): Promise { + this.deferred.resolve(value); + await this.#maybeTick(); + } + + /** + * Reject the call with the given reason. + */ + async error(reason: unknown): Promise { + this.deferred.reject(reason); + await this.#maybeTick(); + } + + async #maybeTick(): Promise { + if (this.autoTick) { + await AngularContext.getCurrent()?.tick(); + } + } +} diff --git a/projects/ng-vitest/src/lib/ng-vitest.spec.ts b/projects/ng-vitest/src/lib/ng-vitest.spec.ts deleted file mode 100644 index c0e571a8..00000000 --- a/projects/ng-vitest/src/lib/ng-vitest.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { NgVitest } from './ng-vitest'; - -describe('NgVitest', () => { - let component: NgVitest; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NgVitest], - }).compileComponents(); - - fixture = TestBed.createComponent(NgVitest); - component = fixture.componentInstance; - await fixture.whenStable(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/projects/ng-vitest/src/lib/ng-vitest.ts b/projects/ng-vitest/src/lib/ng-vitest.ts deleted file mode 100644 index c30fb614..00000000 --- a/projects/ng-vitest/src/lib/ng-vitest.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'lib-ng-vitest', - imports: [], - template: `

ng-vitest works!

`, - styles: ``, -}) -export class NgVitest {} diff --git a/projects/ng-vitest/src/lib/service-harness-superclass.ts b/projects/ng-vitest/src/lib/service-harness-superclass.ts new file mode 100644 index 00000000..770d5d5c --- /dev/null +++ b/projects/ng-vitest/src/lib/service-harness-superclass.ts @@ -0,0 +1,10 @@ +import { assert } from '@s-libs/js-core'; +import { AngularContext } from './angular-context'; + +export abstract class ServiceHarnessSuperclass { + protected getCtx(): AngularContext { + const ctx = AngularContext.getCurrent(); + assert(ctx); + return ctx; + } +} diff --git a/projects/ng-vitest/src/lib/static-test/static-test.spec.ts b/projects/ng-vitest/src/lib/static-test/static-test.spec.ts new file mode 100644 index 00000000..c284ad84 --- /dev/null +++ b/projects/ng-vitest/src/lib/static-test/static-test.spec.ts @@ -0,0 +1,22 @@ +import { staticTest } from './static-test'; + +describe('staticTest()', () => { + it('does not execute the code', () => { + staticTest(() => { + assert.fail('this should not run'); + }); + }); + + describe('example from the docs', () => { + function reject(array: T[], predicate: (value: T) => boolean): T[] { + return array.filter((value) => !predicate(value)); + } + + it('requires the predicate type to match the array type', () => { + staticTest(() => { + // @ts-expect-error -- mismatch of number array w/ string function + reject([1, 2, 3], (value: string) => value === '2'); + }); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/static-test/static-test.ts b/projects/ng-vitest/src/lib/static-test/static-test.ts new file mode 100644 index 00000000..093206cc --- /dev/null +++ b/projects/ng-vitest/src/lib/static-test/static-test.ts @@ -0,0 +1,18 @@ +/** + * Use this when you want to write test code that doesn't actually run, instead relying only on your static tools like Typescript or a linter to raise errors. + * + * ```ts + * function reject(array: T[], predicate: (value: T) => boolean): T[] { + * return array.filter((value) => !predicate(value)); + * } + * + * it('requires the predicate type to match the array type', () => { + * staticTest(() => { + * // @ts-expect-error -- mismatch of number array w/ string function + * reject([1, 2, 3], (value: string) => value === '2'); + * }); + * }); * ``` + */ +export function staticTest(_: () => void | Promise): void { + // and that's all there is to it +} diff --git a/projects/ng-vitest/src/lib/test-request/expect-request.spec.ts b/projects/ng-vitest/src/lib/test-request/expect-request.spec.ts new file mode 100644 index 00000000..4061582b --- /dev/null +++ b/projects/ng-vitest/src/lib/test-request/expect-request.spec.ts @@ -0,0 +1,178 @@ +import { + HttpClient, + HttpRequest, + provideHttpClient, +} from '@angular/common/http'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { expectTypeOf } from 'expect-type'; +import { AngularContext } from '../angular-context'; +import { expectSingleCallAndReset } from '../mocks/expect-single-call-and-reset'; +import { expectRequest, HttpBody } from './expect-request'; +import { SlTestRequest } from './sl-test-request'; + +describe('expectRequest()', () => { + let ctx: AngularContext; + let http: HttpClient; + beforeEach(() => { + ctx = new AngularContext({ providers: [provideHttpClient()] }); + http = ctx.inject(HttpClient); + }); + + function cleanUpPendingRequests(): void { + ctx.inject(HttpTestingController).match(() => true); + } + + it('allows optionally declaring the response body type', async () => { + await ctx.run(() => { + http.get('url 1').subscribe(); + http.get('url 2').subscribe(); + + expectTypeOf(expectRequest<{ a: 1 }>('GET', 'url 1')).toEqualTypeOf< + SlTestRequest<{ a: 1 }> + >(); + expectTypeOf(expectRequest('GET', 'url 2')).toEqualTypeOf< + SlTestRequest + >(); + }); + }); + + it('returns a matching SlTestRequest', async () => { + await ctx.run(() => { + const method = 'GET'; + const url = 'a url'; + const request = new HttpRequest(method, url); + http.request(request).subscribe(); + + const req = expectRequest(method, url); + + expect(req.request).toBe(request); + }); + }); + + it('matches on method, url, params, headers and body', async () => { + await ctx.run(async () => { + const method = 'PUT'; + const url = 'correct_url'; + const body = 'correct_body'; + const options = { + body, + headers: { header: 'correct' }, + params: { param: 'correct' }, + }; + http.put(url, body, options).subscribe(); + + expect(() => { + expectRequest('DELETE', url, options); + }).toThrow(); + expect(() => { + expectRequest(method, 'wrong', options); + }).toThrow(); + expect(() => { + expectRequest(method, url, { + ...options, + params: { param: 'wrong' }, + }); + }).toThrow(); + expect(() => { + expectRequest(method, url, { + ...options, + headers: { header: 'wrong' }, + }); + }).toThrow(); + expect(() => { + expectRequest(method, url, { ...options, body: 'wrong' }); + }).toThrow(); + expect(() => { + expectRequest(method, url, options); + }).not.toThrow(); + }); + }); + + it('has nice defaults', async () => { + await ctx.run(async () => { + const method = 'GET'; + const url = 'correct_url'; + http.get(url).subscribe(); + + expect(() => { + expectRequest(method, url, { params: { default: 'false' } }); + }).toThrow(); + expect(() => { + expectRequest(method, url, { headers: { default: 'false' } }); + }).toThrow(); + expect(() => { + expectRequest(method, url, { body: 'not_default' }); + }).toThrow(); + expect(() => { + expectRequest(method, url); + }).not.toThrow(); + }); + }); + + it('throws a friendly message when there are no matches', async () => { + await ctx.run(async () => { + http.get('right').subscribe(); + http.get('right').subscribe(); + + expect(() => { + expectRequest('GET', 'wrong'); + }).toThrow( + `Expected 1 matching request, found 0. See details logged to the console.`, + ); + expect(() => { + expectRequest('GET', 'right'); + }).toThrow( + `Expected 1 matching request, found 2. See details logged to the console.`, + ); + + cleanUpPendingRequests(); + }); + }); + + it('logs helpful details when there are no matches', async () => { + const log = vi.spyOn(console, 'error'); + await ctx.run(async () => { + const request1 = new HttpRequest('GET', 'url 1'); + const request2 = new HttpRequest('DELETE', 'url 2'); + http.request(request1).subscribe(); + http.request(request2).subscribe(); + + expect(() => { + expectRequest('GET', 'bad url'); + }).toThrow(); + expectSingleCallAndReset( + log, + 'Expected 1 request to match:', + { method: 'GET', url: 'bad url', params: {}, headers: {}, body: null }, + 'Actual pending requests:', + [request1, request2], + ); + + cleanUpPendingRequests(); + }); + }); +}); + +describe('expectRequest() outside an AngularContext', () => { + it('throws a meaningful error', () => { + expect(() => { + expectRequest('GET', 'a url'); + }).toThrow('expectRequest only works while an AngularContext is in use'); + }); +}); + +describe('expectRequest() example in the docs', () => { + it('works', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + await ctx.run(async () => { + ctx + .inject(HttpClient) + .get('http://example.com', { params: { key: 'value' } }) + .subscribe(); + const request = expectRequest('GET', 'http://example.com', { + params: { key: 'value' }, + }); + await request.flush('my response body'); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/test-request/expect-request.ts b/projects/ng-vitest/src/lib/test-request/expect-request.ts new file mode 100644 index 00000000..dd38a034 --- /dev/null +++ b/projects/ng-vitest/src/lib/test-request/expect-request.ts @@ -0,0 +1,109 @@ +import { HttpRequest } from '@angular/common/http'; +import { + HttpTestingController, + TestRequest, +} from '@angular/common/http/testing'; +import { assert, mapAsKeys } from '@s-libs/js-core'; +import { isEqual } from '@s-libs/micro-dash'; +import { AngularContext } from '../angular-context'; +import { SlTestRequest } from './sl-test-request'; + +export type HttpMethod = 'DELETE' | 'GET' | 'POST' | 'PUT'; +export type HttpBody = Parameters[0]; +interface RequestOptions { + params?: Record; + headers?: Record; +} +type MatchOptions = Required & { + method: HttpMethod; + url: string; + body: HttpBody; +}; +interface AngularHttpMap { + keys: () => string[]; + get: (key: string) => string | null; +} + +let matchCount: number; +let pendingRequests: Array>; + +/** + * This convenience function is similar to {@linkcode https://angular.dev/api/common/http/testing/HttpTestingController | HttpTestingController.expectOne()}, with extra features. The returned request object will automatically trigger change detection when you flush a response, just like in production. + * + * This function is opinionated in that you must specify all aspects of the request to match. E.g. if the request specifies headers, you must also specify them in the arguments to this method. + * + * This function only works when you are using an {@linkcode AngularContext}. + * + * ```ts + * const ctx = new AngularContext({ providers: [provideHttpClient()] }); + * ctx.run(() => { + * inject(HttpClient) + * .get('http://example.com', { params: { key: 'value' } }) + * .subscribe(); + * const request = expectRequest('GET', 'http://example.com', { + * params: { key: 'value' }, + * }); + * request.flush('my response body'); + * }); + * ``` + */ +export function expectRequest( + method: HttpMethod, + url: string, + options: RequestOptions & { body?: HttpBody } = {}, +): SlTestRequest { + const ctx = AngularContext.getCurrent(); + assert(ctx, 'expectRequest only works while an AngularContext is in use'); + + const opts = { method, url, params: {}, headers: {}, body: null, ...options }; + try { + return matchRequest(ctx, opts); + } catch { + console.error( + 'Expected 1 request to match:', + opts, + 'Actual pending requests:', + pendingRequests, + ); + throw new Error( + `Expected 1 matching request, found ${matchCount}. See details logged to the console.`, + ); + } +} + +function matchRequest( + ctx: AngularContext, + options: MatchOptions, +): SlTestRequest { + const controller = ctx.inject(HttpTestingController); + matchCount = 0; + pendingRequests = []; + return new SlTestRequest( + controller.expectOne((req) => { + pendingRequests.push(req); + const found = isMatch(req, options); + if (found) { + ++matchCount; + } + return found; + }), + ); +} + +function isMatch(req: HttpRequest, options: MatchOptions): boolean { + return ( + req.method === options.method && + req.url === options.url && + matchAngularHttpMap(req.params, options.params) && + matchAngularHttpMap(req.headers, options.headers) && + isEqual(req.body, options.body) + ); +} + +function matchAngularHttpMap( + actual: AngularHttpMap, + expected: Record, +): boolean { + const actualObj = mapAsKeys(actual.keys(), (key) => actual.get(key)); + return isEqual(actualObj, expected); +} diff --git a/projects/ng-vitest/src/lib/test-request/sl-test-request.spec.ts b/projects/ng-vitest/src/lib/test-request/sl-test-request.spec.ts new file mode 100644 index 00000000..1965fe17 --- /dev/null +++ b/projects/ng-vitest/src/lib/test-request/sl-test-request.spec.ts @@ -0,0 +1,160 @@ +import { + HttpClient, + HttpErrorResponse, + HttpRequest, + HttpResponse, + provideHttpClient, +} from '@angular/common/http'; +import { TestRequest } from '@angular/common/http/testing'; +import { noop } from '@s-libs/micro-dash'; +import { Subject } from 'rxjs'; +import { AngularContext } from '../angular-context'; +import { expectSingleCallAndReset } from '../mocks/expect-single-call-and-reset'; +import { expectRequest } from './expect-request'; +import { SlTestRequest } from './sl-test-request'; + +describe('SlTestRequest', () => { + type ErrorFn = (error: any) => void; + + describe('.request', () => { + it('is available', () => { + const httpRequest = new HttpRequest('GET', 'url'); + const req = new SlTestRequest( + new TestRequest(httpRequest, new Subject()), + ); + expect(req.request).toBe(httpRequest); + }); + }); + + describe('.flush()', () => { + it('resolves the request with the given body and options', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + await ctx.run(async () => { + const mock = vi.fn<(value: unknown) => void>(); + ctx.inject(HttpClient).get('a url').subscribe(mock); + const req = expectRequest('GET', 'a url'); + + const body = 'the body'; + await req.flush(body); + + expectSingleCallAndReset(mock, body); + }); + }); + + it('passes along other arguments', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + await ctx.run(async () => { + const mock = vi.fn<(value: HttpResponse) => void>(); + ctx + .inject(HttpClient) + .request('GET', 'a url', { observe: 'response' }) + .subscribe(mock); + const req = expectRequest('GET', 'a url'); + + await req.flush('', { status: 249, statusText: '' }); + const resp = mock.mock.calls[0][0]; + expect(resp.status).toBe(249); + }); + }); + + it('runs tick if an AngularContext is in use', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + const spy = vi.spyOn(ctx, 'tick'); + await ctx.run(async () => { + ctx.inject(HttpClient).get('a url').subscribe(); + const req = expectRequest('GET', 'a url'); + + await req.flush('the body'); + + expectSingleCallAndReset(spy); + }); + }); + }); + + describe('.flushError()', () => { + it('rejects the request with the given args', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + await ctx.run(async () => { + const mock = vi.fn(); + ctx.inject(HttpClient).get('a url').subscribe({ error: mock }); + const req = expectRequest('GET', 'a url'); + + await req.flushError(123, { statusText: 'bad', body: 'stop it' }); + + const resp: HttpErrorResponse = mock.mock.calls[0][0]; + expect(resp.status).toBe(123); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(resp.statusText).toBe('bad'); + expect(resp.error).toBe('stop it'); + }); + }); + + it('has good default args', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + await ctx.run(async () => { + const mock = vi.fn(); + ctx.inject(HttpClient).get('a url').subscribe({ error: mock }); + const req = expectRequest('GET', 'a url'); + + await req.flushError(); + + const resp: HttpErrorResponse = mock.mock.calls[0][0]; + expect(resp.status).toBe(500); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(resp.statusText).toBe('simulated test error'); + expect(resp.error).toBeNull(); + }); + }); + + it('runs tick if an AngularContext is in use', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + const spy = vi.spyOn(ctx, 'tick'); + await ctx.run(async () => { + ctx.inject(HttpClient).get('a url').subscribe({ error: noop }); + const req = expectRequest('GET', 'a url'); + + await req.flushError(); + + expectSingleCallAndReset(spy); + }); + }); + }); + + describe('.isCancelled()', () => { + it('returns whether the request has been cancelled', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + await ctx.run(() => { + const subscription = ctx.inject(HttpClient).get('a url').subscribe(); + const req = expectRequest('GET', 'a url'); + + expect(req.isCancelled()).toBe(false); + subscription.unsubscribe(); + expect(req.isCancelled()).toBe(true); + }); + }); + }); + + describe('.tickIfPossible()', () => { + it('gracefully handles when there is no AngularContext', async () => { + const httpRequest = new HttpRequest('GET', 'url'); + const req = new SlTestRequest( + new TestRequest(httpRequest, new Subject()), + ); + await expect(req.flush('')).resolves.not.toThrow(); + }); + }); + + it('works for the example in the docs', async () => { + const ctx = new AngularContext({ providers: [provideHttpClient()] }); + await ctx.run(async () => { + ctx + .inject(HttpClient) + .get('http://example.com', { params: { key: 'value' } }) + .subscribe(); + const request = expectRequest('GET', 'http://example.com', { + params: { key: 'value' }, + }); + await request.flush('my response body'); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/test-request/sl-test-request.ts b/projects/ng-vitest/src/lib/test-request/sl-test-request.ts new file mode 100644 index 00000000..3fa8d67b --- /dev/null +++ b/projects/ng-vitest/src/lib/test-request/sl-test-request.ts @@ -0,0 +1,69 @@ +import { HttpRequest } from '@angular/common/http'; +import { TestRequest } from '@angular/common/http/testing'; +import { AngularContext } from '../angular-context'; +import { HttpBody } from './expect-request'; + +/** + * A class very similar to Angular's {@linkcode https://angular.dev/api/common/http/testing/TestRequest | TestRequest} for use with an {@linkcode AngularContext}. If you are using an `AngularContext`, this will trigger change detection automatically after you flush a response, like production behavior. + * + * Though it is possible to construct yourself, normally an instance of this class is obtained from {@linkcode expectRequest()}. + * + * ```ts + * const ctx = new AngularContext({ providers: [provideHttpClient()] }); + * ctx.run(() => { + * inject(HttpClient) + * .get('http://example.com', { params: { key: 'value' } }) + * .subscribe(); + * const request = expectRequest('GET', 'http://example.com', { + * params: { key: 'value' }, + * }); + * request.flush('my response body'); + * }); + * ``` + */ +export class SlTestRequest { + /** + * The underlying {@linkcode https://angular.dev/api/common/http/testing/TestRequest | TestRequest} object from Angular. + */ + request: HttpRequest; + + constructor(private req: TestRequest) { + this.request = this.req.request; + } + + /** + * Resolve the request with the given body and options, like {@linkcode https://angular.dev/api/common/http/testing/TestRequest#flush | TestRequest.flush()}. + */ + async flush( + body: Body, + opts?: Parameters[1], + ): Promise { + this.req.flush(body, opts); + await this.#tickIfPossible(); + } + + /** + * Convenience method to flush an error response. + */ + async flushError( + status = 500, + { + statusText = 'simulated test error', + body = null, + }: { statusText?: string; body?: HttpBody } = {}, + ): Promise { + this.req.flush(body, { status, statusText }); + await this.#tickIfPossible(); + } + + /** + * Returns whether the request has been cancelled. + */ + isCancelled(): boolean { + return this.req.cancelled; + } + + async #tickIfPossible(): Promise { + await AngularContext.getCurrent()?.tick(); + } +} diff --git a/projects/ng-vitest/src/lib/zone-polyfills.ts b/projects/ng-vitest/src/lib/zone-polyfills.ts new file mode 100644 index 00000000..c4f9c484 --- /dev/null +++ b/projects/ng-vitest/src/lib/zone-polyfills.ts @@ -0,0 +1,2 @@ +import 'zone.js'; +import 'zone.js/testing'; From a26c7c1e75cc70852de474c1537c7d52c8a344f6 Mon Sep 17 00:00:00 2001 From: Eric Simonton Date: Wed, 10 Jun 2026 07:01:48 -0400 Subject: [PATCH 04/14] unrelated config --- TODO.md | 7 ------- angular.json | 20 ++++++++++---------- 2 files changed, 10 insertions(+), 17 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index f4fcc4eb..00000000 --- a/TODO.md +++ /dev/null @@ -1,7 +0,0 @@ -- v12 - - Convert e2e tests to something. - -- Re-enable eslint rules to ban types in micro-dash. Figure it out. -- landing page to link to all API docs -- coveralls - - help may be here, to combine multiple coverage runs into one report: https://github.com/angular/angular-cli/issues/11268 diff --git a/angular.json b/angular.json index bc8db89d..03757905 100644 --- a/angular.json +++ b/angular.json @@ -26,7 +26,7 @@ "projectType": "library", "root": "projects/app-state", "sourceRoot": "projects/app-state/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:ng-packagr", @@ -68,7 +68,7 @@ }, "root": "projects/integration", "sourceRoot": "projects/integration/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:application", @@ -143,7 +143,7 @@ "projectType": "library", "root": "projects/js-core", "sourceRoot": "projects/js-core/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:ng-packagr", @@ -180,7 +180,7 @@ "projectType": "library", "root": "projects/micro-dash", "sourceRoot": "projects/micro-dash/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:ng-packagr", @@ -334,7 +334,7 @@ "projectType": "library", "root": "projects/ng-core", "sourceRoot": "projects/ng-core/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:ng-packagr", @@ -371,7 +371,7 @@ "projectType": "library", "root": "projects/ng-dev", "sourceRoot": "projects/ng-dev/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:ng-packagr", @@ -408,7 +408,7 @@ "projectType": "library", "root": "projects/ng-jasmine", "sourceRoot": "projects/ng-jasmine/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:ng-packagr", @@ -482,7 +482,7 @@ "projectType": "library", "root": "projects/rxjs-core", "sourceRoot": "projects/rxjs-core/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:ng-packagr", @@ -519,7 +519,7 @@ "projectType": "library", "root": "projects/signal-store", "sourceRoot": "projects/signal-store/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:ng-packagr", @@ -556,7 +556,7 @@ "projectType": "library", "root": "projects/ng-vitest", "sourceRoot": "projects/ng-vitest/src", - "prefix": "s", + "prefix": "sl", "architect": { "build": { "builder": "@angular/build:ng-packagr", From ddf9cd6d42d8db6b70ed22c38efec447de46982d Mon Sep 17 00:00:00 2001 From: Eric Simonton Date: Wed, 10 Jun 2026 07:12:42 -0400 Subject: [PATCH 05/14] copy in spies directory, get tests running --- angular.json | 2 +- package-lock.json | 177 ++++++++++++++++++ package.json | 8 +- .../src/lib/mocks/async-method-controller.ts | 2 +- .../src/lib/spies/build-mock-backend-kit.ts | 18 ++ .../src/lib/spies/create-spy-object.ts | 43 +++++ .../src/lib/spies/mock-controller.ts | 92 +++++++++ projects/ng-vitest/src/lib/spies/utils.ts | 20 ++ 8 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 projects/ng-vitest/src/lib/spies/build-mock-backend-kit.ts create mode 100644 projects/ng-vitest/src/lib/spies/create-spy-object.ts create mode 100644 projects/ng-vitest/src/lib/spies/mock-controller.ts create mode 100644 projects/ng-vitest/src/lib/spies/utils.ts diff --git a/angular.json b/angular.json index 03757905..1a5ef734 100644 --- a/angular.json +++ b/angular.json @@ -574,7 +574,7 @@ "builder": "@angular/build:unit-test", "options": { "tsConfig": "projects/ng-vitest/tsconfig.spec.json", - "browsers": "chromium" + "browsers": ["chromium"] } }, "lint": { diff --git a/package-lock.json b/package-lock.json index 62e124e0..dd1e87b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@types/jasmine": "~6.0.0", "@types/lodash-es": "^4.17.11", "@types/node": "^24.10.1", + "@vitest/browser-playwright": "^4.1.8", "angular-eslint": "21.0.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.5", @@ -41,6 +42,7 @@ "karma-jasmine-html-reporter": "~2.2.0", "lodash-es": "^4.17.21", "ng-packagr": "^21.2.0", + "playwright": "^1.60.0", "prettier": "^3.0.3", "sassdoc": "^2.7.4", "source-map-explorer": "^2.5.3", @@ -1268,6 +1270,13 @@ "node": ">=6.9.0" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3867,6 +3876,13 @@ "license": "MIT", "optional": true }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz", @@ -5604,6 +5620,85 @@ "vite": "^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/browser": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.8.tgz", + "integrity": "sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.8" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.8.tgz", + "integrity": "sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.8", + "@vitest/mocker": "4.1.8", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/browser/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", @@ -15095,6 +15190,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -16894,6 +17046,21 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -18206,6 +18373,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", diff --git a/package.json b/package.json index 0afceee9..357a9745 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/jasmine": "~6.0.0", "@types/lodash-es": "^4.17.11", "@types/node": "^24.10.1", + "@vitest/browser-playwright": "^4.1.8", "angular-eslint": "21.0.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.5", @@ -63,6 +64,7 @@ "karma-jasmine-html-reporter": "~2.2.0", "lodash-es": "^4.17.21", "ng-packagr": "^21.2.0", + "playwright": "^1.60.0", "prettier": "^3.0.3", "sassdoc": "^2.7.4", "source-map-explorer": "^2.5.3", @@ -116,12 +118,16 @@ "type": "json" }, { - "filename": "projects/signal-store/package.json", + "filename": "projects/ng-vitest/package.json", "type": "json" }, { "filename": "projects/rxjs-core/package.json", "type": "json" + }, + { + "filename": "projects/signal-store/package.json", + "type": "json" } ], "scripts": { diff --git a/projects/ng-vitest/src/lib/mocks/async-method-controller.ts b/projects/ng-vitest/src/lib/mocks/async-method-controller.ts index 22f6bd64..c2008c19 100644 --- a/projects/ng-vitest/src/lib/mocks/async-method-controller.ts +++ b/projects/ng-vitest/src/lib/mocks/async-method-controller.ts @@ -2,7 +2,7 @@ import { Deferred } from '@s-libs/js-core'; import { isEqual, isUndefined, nth, remove } from '@s-libs/micro-dash'; import { MockParameters } from '@vitest/spy'; import { Mock, onTestFinished } from 'vitest'; -import { buildErrorMessage } from '../../ng-dev/spies/utils'; +import { buildErrorMessage } from '../spies/utils'; import { TestCall } from './test-call'; type AsyncFunc = (...args: any[]) => Promise; diff --git a/projects/ng-vitest/src/lib/spies/build-mock-backend-kit.ts b/projects/ng-vitest/src/lib/spies/build-mock-backend-kit.ts new file mode 100644 index 00000000..8871ada8 --- /dev/null +++ b/projects/ng-vitest/src/lib/spies/build-mock-backend-kit.ts @@ -0,0 +1,18 @@ +import { InjectionToken, Provider, Type } from '@angular/core'; +import { createSpyObject, SpyObject } from './create-spy-object'; + +export class MockBackendKit { + token: InjectionToken>; + providers: Provider[]; + + constructor(type: Type, setUpMock?: (mock: SpyObject) => void) { + this.token = new InjectionToken('', { + factory: (): SpyObject => { + const mock = createSpyObject(type); + setUpMock?.(mock); + return mock; + }, + }); + this.providers = [{ provide: type, useExisting: this.token }]; + } +} diff --git a/projects/ng-vitest/src/lib/spies/create-spy-object.ts b/projects/ng-vitest/src/lib/spies/create-spy-object.ts new file mode 100644 index 00000000..be268197 --- /dev/null +++ b/projects/ng-vitest/src/lib/spies/create-spy-object.ts @@ -0,0 +1,43 @@ +import { Type } from '@angular/core'; +import { functions } from '@s-libs/micro-dash'; +import { Mock } from 'vitest'; +import { MockController } from './mock-controller'; + +// adapted from https://github.com/ngneat/spectator/blob/e13c9554778bdb179dfc7235aedb4b3b90302850/projects/spectator/src/lib/mock.ts + +export type SpyObject = { + [K in keyof T]: T[K] extends (...args: any[]) => any + ? Mock & { controller: MockController } + : never; +}; + +/** + * Creates a new object with jasmine spies for each method in `type`. + * + * ```ts + * class Greeter { + * greet(name: string) { + * return `Hello, ${name}!`; + * } + * } + * + * const spyObject = createSpyObject(Greeter); + * spyObject.greet.and.returnValue("Hello, stub!"); + * expect(spyObject.greet("Eric")).toBe("Hello, stub!"); + * expectSingleCallAndReset(spyObject.greet, "Eric"); + * ``` + */ +export function createSpyObject(type: Type): SpyObject { + const mock: any = {}; + for ( + let proto = type.prototype; + proto !== null; + proto = Object.getPrototypeOf(proto) + ) { + for (const key of functions(proto)) { + mock[key] = vi.fn(); + mock[key].controller = new MockController(mock[key]); + } + } + return mock; +} diff --git a/projects/ng-vitest/src/lib/spies/mock-controller.ts b/projects/ng-vitest/src/lib/spies/mock-controller.ts new file mode 100644 index 00000000..13bb6653 --- /dev/null +++ b/projects/ng-vitest/src/lib/spies/mock-controller.ts @@ -0,0 +1,92 @@ +import { isEqual, isUndefined, remove } from '@s-libs/micro-dash'; +import { MockParameters } from '@vitest/spy'; +import { Mock } from 'vitest'; +import { buildErrorMessage } from './utils'; + +export type CallMatcher any> = + | Parameters + | ((params: MockParameters) => boolean); + +export class MockController any> { + #calls: Array> = []; + #syncedCount = 0; + + constructor(private mockFn: Mock) {} + + expectOne(matcher: CallMatcher, description?: string): MockParameters { + const matches = this.match(matcher); + if (matches.length !== 1) { + throw new Error( + buildErrorMessage({ + matchType: 'one matching', + itemType: 'call', + matches, + stringifiedUserInput: this.#stringifyUserInput(matcher, description), + }), + ); + } + return matches[0]; + } + + expectNone(matcher: CallMatcher, description?: string): void { + const matches = this.match(matcher); + if (matches.length > 0) { + throw new Error( + buildErrorMessage({ + matchType: 'zero matching', + itemType: 'call', + stringifiedUserInput: this.#stringifyUserInput(matcher, description), + matches, + }), + ); + } + } + + match(matcher: CallMatcher): Array> { + this.#syncCalls(); + let filterFn: (args: MockParameters) => boolean; + if (Array.isArray(matcher)) { + filterFn = (args) => isEqual(args, matcher); + } else { + filterFn = matcher; + } + return remove(this.#calls, filterFn); + } + + verify(): void { + if (this.#calls.length) { + this.#syncCalls(); + let message = `${buildErrorMessage({ + matchType: 'no open', + itemType: 'call', + stringifiedUserInput: undefined, + matches: this.#calls, + })}:`; + for (const call of this.#calls) { + message += `\n ${stringifyArgs(call)}`; + } + throw new Error(message); + } + } + + #syncCalls(): void { + const { calls } = this.mockFn.mock; + this.#calls.push(...calls.slice(this.#syncedCount)); + this.#syncedCount = calls.length; + } + + #stringifyUserInput(matcher: CallMatcher, description?: string): string { + if (isUndefined(description)) { + if (Array.isArray(matcher)) { + description = `Match by arguments: ${stringifyArgs(matcher)}`; + } else { + description = `Match by function: ${matcher.name}`; + } + } + return description; + } +} + +function stringifyArgs(args: any[]): string { + return JSON.stringify(args); +} diff --git a/projects/ng-vitest/src/lib/spies/utils.ts b/projects/ng-vitest/src/lib/spies/utils.ts new file mode 100644 index 00000000..295d5ebe --- /dev/null +++ b/projects/ng-vitest/src/lib/spies/utils.ts @@ -0,0 +1,20 @@ +import { isDefined } from '@s-libs/js-core'; + +export function buildErrorMessage({ + matchType, + itemType, + stringifiedUserInput, + matches, +}: { + matchType: string; + itemType: string; + stringifiedUserInput?: string; + matches: unknown[]; +}): string { + let message = `Expected ${matchType} ${itemType}(s)`; + if (isDefined(stringifiedUserInput)) { + message += ` for criterion "${stringifiedUserInput}"`; + } + message += `, found ${matches.length}`; + return message; +} From 7353748bf9c611f7c9073a5fc076ff39e2276578 Mon Sep 17 00:00:00 2001 From: Eric Simonton Date: Wed, 10 Jun 2026 07:29:22 -0400 Subject: [PATCH 06/14] export public API --- .../src/app/api-tests/ng-jasmine.spec.ts | 2 +- .../src/app/api-tests/ng-vitest.spec.ts | 64 +++++++++++++++++++ .../src/lib/mocks/async-method-controller.ts | 2 +- .../build-mock-backend-kit.ts | 10 +-- .../create-mock-object.ts} | 4 +- projects/ng-vitest/src/lib/mocks/index.ts | 4 ++ .../lib/{spies => mocks}/mock-controller.ts | 0 .../src/lib/{spies => mocks}/utils.ts | 0 .../src/lib/service-harness-superclass.ts | 10 --- .../ng-vitest/src/lib/test-request/index.ts | 2 + projects/ng-vitest/src/public-api.ts | 8 ++- 11 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 projects/integration/src/app/api-tests/ng-vitest.spec.ts rename projects/ng-vitest/src/lib/{spies => mocks}/build-mock-backend-kit.ts (53%) rename projects/ng-vitest/src/lib/{spies/create-spy-object.ts => mocks/create-mock-object.ts} (91%) create mode 100644 projects/ng-vitest/src/lib/mocks/index.ts rename projects/ng-vitest/src/lib/{spies => mocks}/mock-controller.ts (100%) rename projects/ng-vitest/src/lib/{spies => mocks}/utils.ts (100%) delete mode 100644 projects/ng-vitest/src/lib/service-harness-superclass.ts create mode 100644 projects/ng-vitest/src/lib/test-request/index.ts diff --git a/projects/integration/src/app/api-tests/ng-jasmine.spec.ts b/projects/integration/src/app/api-tests/ng-jasmine.spec.ts index 3c61985c..4d78c40b 100644 --- a/projects/integration/src/app/api-tests/ng-jasmine.spec.ts +++ b/projects/integration/src/app/api-tests/ng-jasmine.spec.ts @@ -13,7 +13,7 @@ import { TestCall, } from '@s-libs/ng-jasmine'; -describe('ng-dev', () => { +describe('ng-jasmine', () => { it('has AngularContext', () => { expect(AngularContext).toBeDefined(); }); diff --git a/projects/integration/src/app/api-tests/ng-vitest.spec.ts b/projects/integration/src/app/api-tests/ng-vitest.spec.ts new file mode 100644 index 00000000..893ce8ed --- /dev/null +++ b/projects/integration/src/app/api-tests/ng-vitest.spec.ts @@ -0,0 +1,64 @@ +import { + AngularContext, + arrayWithMatch, + AsyncMethodController, + ComponentContext, + ComponentHarnessSuperclass, + createMockObject, + expectExactContents, + expectRequest, + expectSingleCallAndReset, + SlTestRequest, + staticTest, + TestCall, +} from '@s-libs/ng-vitest'; + +describe('ng-vitest', () => { + it('has AngularContext', () => { + expect(AngularContext).toBeDefined(); + }); + + it('has AsyncMethodController', () => { + expect(AsyncMethodController).toBeDefined(); + }); + + it('has ComponentContext', () => { + expect(ComponentContext).toBeDefined(); + }); + + it('has ComponentHarnessSuperclass', () => { + expect(ComponentHarnessSuperclass).toBeDefined(); + }); + + it('has TestCall', () => { + expect(TestCall).toBeDefined(); + }); + + it('has SlTestRequest', () => { + expect(SlTestRequest).toBeDefined(); + }); + + it('has arrayWithMatch()', () => { + expect(arrayWithMatch).toBeDefined(); + }); + + it('has createMockObject()', () => { + expect(createMockObject).toBeDefined(); + }); + + it('has expectExactContents()', () => { + expect(expectExactContents).toBeDefined(); + }); + + it('has expectRequest()', () => { + expect(expectRequest).toBeDefined(); + }); + + it('has expectSingleCallAndReset()', () => { + expect(expectSingleCallAndReset).toBeDefined(); + }); + + it('has staticTest', () => { + expect(staticTest).toBeDefined(); + }); +}); diff --git a/projects/ng-vitest/src/lib/mocks/async-method-controller.ts b/projects/ng-vitest/src/lib/mocks/async-method-controller.ts index c2008c19..69eb2cc7 100644 --- a/projects/ng-vitest/src/lib/mocks/async-method-controller.ts +++ b/projects/ng-vitest/src/lib/mocks/async-method-controller.ts @@ -2,8 +2,8 @@ import { Deferred } from '@s-libs/js-core'; import { isEqual, isUndefined, nth, remove } from '@s-libs/micro-dash'; import { MockParameters } from '@vitest/spy'; import { Mock, onTestFinished } from 'vitest'; -import { buildErrorMessage } from '../spies/utils'; import { TestCall } from './test-call'; +import { buildErrorMessage } from './utils'; type AsyncFunc = (...args: any[]) => Promise; diff --git a/projects/ng-vitest/src/lib/spies/build-mock-backend-kit.ts b/projects/ng-vitest/src/lib/mocks/build-mock-backend-kit.ts similarity index 53% rename from projects/ng-vitest/src/lib/spies/build-mock-backend-kit.ts rename to projects/ng-vitest/src/lib/mocks/build-mock-backend-kit.ts index 8871ada8..d3d68d53 100644 --- a/projects/ng-vitest/src/lib/spies/build-mock-backend-kit.ts +++ b/projects/ng-vitest/src/lib/mocks/build-mock-backend-kit.ts @@ -1,14 +1,14 @@ import { InjectionToken, Provider, Type } from '@angular/core'; -import { createSpyObject, SpyObject } from './create-spy-object'; +import { createMockObject, MockObject } from './create-mock-object'; export class MockBackendKit { - token: InjectionToken>; + token: InjectionToken>; providers: Provider[]; - constructor(type: Type, setUpMock?: (mock: SpyObject) => void) { + constructor(type: Type, setUpMock?: (mock: MockObject) => void) { this.token = new InjectionToken('', { - factory: (): SpyObject => { - const mock = createSpyObject(type); + factory: (): MockObject => { + const mock = createMockObject(type); setUpMock?.(mock); return mock; }, diff --git a/projects/ng-vitest/src/lib/spies/create-spy-object.ts b/projects/ng-vitest/src/lib/mocks/create-mock-object.ts similarity index 91% rename from projects/ng-vitest/src/lib/spies/create-spy-object.ts rename to projects/ng-vitest/src/lib/mocks/create-mock-object.ts index be268197..3f256ab7 100644 --- a/projects/ng-vitest/src/lib/spies/create-spy-object.ts +++ b/projects/ng-vitest/src/lib/mocks/create-mock-object.ts @@ -5,7 +5,7 @@ import { MockController } from './mock-controller'; // adapted from https://github.com/ngneat/spectator/blob/e13c9554778bdb179dfc7235aedb4b3b90302850/projects/spectator/src/lib/mock.ts -export type SpyObject = { +export type MockObject = { [K in keyof T]: T[K] extends (...args: any[]) => any ? Mock & { controller: MockController } : never; @@ -27,7 +27,7 @@ export type SpyObject = { * expectSingleCallAndReset(spyObject.greet, "Eric"); * ``` */ -export function createSpyObject(type: Type): SpyObject { +export function createMockObject(type: Type): MockObject { const mock: any = {}; for ( let proto = type.prototype; diff --git a/projects/ng-vitest/src/lib/mocks/index.ts b/projects/ng-vitest/src/lib/mocks/index.ts new file mode 100644 index 00000000..e38e8756 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/index.ts @@ -0,0 +1,4 @@ +export { AsyncMethodController } from './async-method-controller'; +export { createMockObject } from './create-mock-object'; +export { expectSingleCallAndReset } from './expect-single-call-and-reset'; +export { TestCall } from './test-call'; diff --git a/projects/ng-vitest/src/lib/spies/mock-controller.ts b/projects/ng-vitest/src/lib/mocks/mock-controller.ts similarity index 100% rename from projects/ng-vitest/src/lib/spies/mock-controller.ts rename to projects/ng-vitest/src/lib/mocks/mock-controller.ts diff --git a/projects/ng-vitest/src/lib/spies/utils.ts b/projects/ng-vitest/src/lib/mocks/utils.ts similarity index 100% rename from projects/ng-vitest/src/lib/spies/utils.ts rename to projects/ng-vitest/src/lib/mocks/utils.ts diff --git a/projects/ng-vitest/src/lib/service-harness-superclass.ts b/projects/ng-vitest/src/lib/service-harness-superclass.ts deleted file mode 100644 index 770d5d5c..00000000 --- a/projects/ng-vitest/src/lib/service-harness-superclass.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { assert } from '@s-libs/js-core'; -import { AngularContext } from './angular-context'; - -export abstract class ServiceHarnessSuperclass { - protected getCtx(): AngularContext { - const ctx = AngularContext.getCurrent(); - assert(ctx); - return ctx; - } -} diff --git a/projects/ng-vitest/src/lib/test-request/index.ts b/projects/ng-vitest/src/lib/test-request/index.ts new file mode 100644 index 00000000..861d67a9 --- /dev/null +++ b/projects/ng-vitest/src/lib/test-request/index.ts @@ -0,0 +1,2 @@ +export { expectRequest } from './expect-request'; +export { SlTestRequest } from './sl-test-request'; diff --git a/projects/ng-vitest/src/public-api.ts b/projects/ng-vitest/src/public-api.ts index 2e47114e..622d7bc4 100644 --- a/projects/ng-vitest/src/public-api.ts +++ b/projects/ng-vitest/src/public-api.ts @@ -2,4 +2,10 @@ * Public API Surface of ng-vitest */ -export * from './lib/ng-vitest'; +export * from './lib/angular-context'; +export { ComponentContext } from './lib/component-context/component-context'; +export * from './lib/component-harness/component-harness-superclass'; +export * from './lib/mocks'; +export { staticTest } from './lib/static-test/static-test'; +export * from './lib/test-request'; +export * from './lib/expectations'; From cdbe0ccf9631107cd7e98ac16be3c5f2f18a9474 Mon Sep 17 00:00:00 2001 From: Eric Simonton Date: Fri, 12 Jun 2026 06:10:27 -0400 Subject: [PATCH 07/14] update docs, some tweaks --- .idea/runConfigurations/ng_vitest___build.xml | 13 ++++ .../ng_vitest___test_server.xml | 13 ++++ .../src/app/api-tests/ng-vitest.spec.ts | 7 +- .../angular-context/angular-context.spec.ts | 27 +------- .../lib/angular-context/angular-context.ts | 36 +++++------ .../component-context/component-context.ts | 64 ++++++------------- .../component-harness-superclass.ts | 2 +- .../src/lib/static-test/static-test.ts | 5 +- 8 files changed, 74 insertions(+), 93 deletions(-) create mode 100644 .idea/runConfigurations/ng_vitest___build.xml create mode 100644 .idea/runConfigurations/ng_vitest___test_server.xml diff --git a/.idea/runConfigurations/ng_vitest___build.xml b/.idea/runConfigurations/ng_vitest___build.xml new file mode 100644 index 00000000..f7af8364 --- /dev/null +++ b/.idea/runConfigurations/ng_vitest___build.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file + }" data-collapsed="@mixin light-theme() { ... }">@mixin light-theme() { ... }

Description

Deprecated. Angular Material's latest API is much easier to use. Recommend updating to latest techniques here: https://material.angular.dev/guide/theming.

Parameters

None.

\ No newline at end of file diff --git a/docs/ng-vitest/.nojekyll b/docs/ng-vitest/.nojekyll new file mode 100644 index 00000000..e2ac6616 --- /dev/null +++ b/docs/ng-vitest/.nojekyll @@ -0,0 +1 @@ +TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. \ No newline at end of file diff --git a/docs/ng-vitest/assets/hierarchy.js b/docs/ng-vitest/assets/hierarchy.js new file mode 100644 index 00000000..7de35b12 --- /dev/null +++ b/docs/ng-vitest/assets/hierarchy.js @@ -0,0 +1 @@ +window.hierarchyData = "eJyNj0EKgzAQRe/y12mLUcFkV7xCd+JCNK3SOClJhBbJ3Yu4iV1UN7OYeX8ef4Y1xjvIKskzxkVWM1h116r1gyEHOaNIl0nNqCBRmvFlSJEvDXn19mB4DtRBJrxgmKyGRKsb55S7/LLn3o8abL1DwrvutIRP6yIwJHkWua70mHRjD5i25I6Hoe0H3VlFkFWR1oGBcxF73Yfam3K+bLT+q43B/XZcxO2OCA7+3jTiXNQhhC9M5JvQ" \ No newline at end of file diff --git a/docs/ng-vitest/assets/highlight.css b/docs/ng-vitest/assets/highlight.css new file mode 100644 index 00000000..33dd539b --- /dev/null +++ b/docs/ng-vitest/assets/highlight.css @@ -0,0 +1,120 @@ +:root { + --light-hl-0: #001080; + --dark-hl-0: #9CDCFE; + --light-hl-1: #000000; + --dark-hl-1: #D4D4D4; + --light-hl-2: #008000; + --dark-hl-2: #6A9955; + --light-hl-3: #795E26; + --dark-hl-3: #DCDCAA; + --light-hl-4: #A31515; + --dark-hl-4: #CE9178; + --light-hl-5: #0000FF; + --dark-hl-5: #569CD6; + --light-hl-6: #267F99; + --dark-hl-6: #4EC9B0; + --light-hl-7: #0070C1; + --dark-hl-7: #4FC1FF; + --light-hl-8: #098658; + --dark-hl-8: #B5CEA8; + --light-hl-9: #AF00DB; + --dark-hl-9: #CE92A4; + --light-hl-10: #800000; + --dark-hl-10: #808080; + --light-hl-11: #800000; + --dark-hl-11: #569CD6; + --light-hl-12: #E50000; + --dark-hl-12: #9CDCFE; + --light-hl-13: #0000FF; + --dark-hl-13: #CE9178; + --light-code-background: #FFFFFF; + --dark-code-background: #1E1E1E; +} + +@media (prefers-color-scheme: light) { :root { + --hl-0: var(--light-hl-0); + --hl-1: var(--light-hl-1); + --hl-2: var(--light-hl-2); + --hl-3: var(--light-hl-3); + --hl-4: var(--light-hl-4); + --hl-5: var(--light-hl-5); + --hl-6: var(--light-hl-6); + --hl-7: var(--light-hl-7); + --hl-8: var(--light-hl-8); + --hl-9: var(--light-hl-9); + --hl-10: var(--light-hl-10); + --hl-11: var(--light-hl-11); + --hl-12: var(--light-hl-12); + --hl-13: var(--light-hl-13); + --code-background: var(--light-code-background); +} } + +@media (prefers-color-scheme: dark) { :root { + --hl-0: var(--dark-hl-0); + --hl-1: var(--dark-hl-1); + --hl-2: var(--dark-hl-2); + --hl-3: var(--dark-hl-3); + --hl-4: var(--dark-hl-4); + --hl-5: var(--dark-hl-5); + --hl-6: var(--dark-hl-6); + --hl-7: var(--dark-hl-7); + --hl-8: var(--dark-hl-8); + --hl-9: var(--dark-hl-9); + --hl-10: var(--dark-hl-10); + --hl-11: var(--dark-hl-11); + --hl-12: var(--dark-hl-12); + --hl-13: var(--dark-hl-13); + --code-background: var(--dark-code-background); +} } + +:root[data-theme='light'] { + --hl-0: var(--light-hl-0); + --hl-1: var(--light-hl-1); + --hl-2: var(--light-hl-2); + --hl-3: var(--light-hl-3); + --hl-4: var(--light-hl-4); + --hl-5: var(--light-hl-5); + --hl-6: var(--light-hl-6); + --hl-7: var(--light-hl-7); + --hl-8: var(--light-hl-8); + --hl-9: var(--light-hl-9); + --hl-10: var(--light-hl-10); + --hl-11: var(--light-hl-11); + --hl-12: var(--light-hl-12); + --hl-13: var(--light-hl-13); + --code-background: var(--light-code-background); +} + +:root[data-theme='dark'] { + --hl-0: var(--dark-hl-0); + --hl-1: var(--dark-hl-1); + --hl-2: var(--dark-hl-2); + --hl-3: var(--dark-hl-3); + --hl-4: var(--dark-hl-4); + --hl-5: var(--dark-hl-5); + --hl-6: var(--dark-hl-6); + --hl-7: var(--dark-hl-7); + --hl-8: var(--dark-hl-8); + --hl-9: var(--dark-hl-9); + --hl-10: var(--dark-hl-10); + --hl-11: var(--dark-hl-11); + --hl-12: var(--dark-hl-12); + --hl-13: var(--dark-hl-13); + --code-background: var(--dark-code-background); +} + +.hl-0 { color: var(--hl-0); } +.hl-1 { color: var(--hl-1); } +.hl-2 { color: var(--hl-2); } +.hl-3 { color: var(--hl-3); } +.hl-4 { color: var(--hl-4); } +.hl-5 { color: var(--hl-5); } +.hl-6 { color: var(--hl-6); } +.hl-7 { color: var(--hl-7); } +.hl-8 { color: var(--hl-8); } +.hl-9 { color: var(--hl-9); } +.hl-10 { color: var(--hl-10); } +.hl-11 { color: var(--hl-11); } +.hl-12 { color: var(--hl-12); } +.hl-13 { color: var(--hl-13); } +pre, code { background: var(--code-background); } diff --git a/docs/ng-vitest/assets/icons.js b/docs/ng-vitest/assets/icons.js new file mode 100644 index 00000000..58882d76 --- /dev/null +++ b/docs/ng-vitest/assets/icons.js @@ -0,0 +1,18 @@ +(function() { + addIcons(); + function addIcons() { + if (document.readyState === "loading") return document.addEventListener("DOMContentLoaded", addIcons); + const svg = document.body.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "svg")); + svg.innerHTML = `MMNEPVFCICPMFPCPTTAAATR`; + svg.style.display = "none"; + if (location.protocol === "file:") updateUseElements(); + } + + function updateUseElements() { + document.querySelectorAll("use").forEach(el => { + if (el.getAttribute("href").includes("#icon-")) { + el.setAttribute("href", el.getAttribute("href").replace(/.*#/, "#")); + } + }); + } +})() \ No newline at end of file diff --git a/docs/ng-vitest/assets/icons.svg b/docs/ng-vitest/assets/icons.svg new file mode 100644 index 00000000..50ad5799 --- /dev/null +++ b/docs/ng-vitest/assets/icons.svg @@ -0,0 +1 @@ +MMNEPVFCICPMFPCPTTAAATR \ No newline at end of file diff --git a/docs/ng-vitest/assets/main.js b/docs/ng-vitest/assets/main.js new file mode 100644 index 00000000..64b80ab2 --- /dev/null +++ b/docs/ng-vitest/assets/main.js @@ -0,0 +1,60 @@ +"use strict"; +window.translations={"copy":"Copy","copied":"Copied!","normally_hidden":"This member is normally hidden due to your filter settings.","hierarchy_expand":"Expand","hierarchy_collapse":"Collapse","folder":"Folder","search_index_not_available":"The search index is not available","search_no_results_found_for_0":"No results found for {0}","kind_1":"Project","kind_2":"Module","kind_4":"Namespace","kind_8":"Enumeration","kind_16":"Enumeration Member","kind_32":"Variable","kind_64":"Function","kind_128":"Class","kind_256":"Interface","kind_512":"Constructor","kind_1024":"Property","kind_2048":"Method","kind_4096":"Call Signature","kind_8192":"Index Signature","kind_16384":"Constructor Signature","kind_32768":"Parameter","kind_65536":"Type Literal","kind_131072":"Type Parameter","kind_262144":"Accessor","kind_524288":"Get Signature","kind_1048576":"Set Signature","kind_2097152":"Type Alias","kind_4194304":"Reference","kind_8388608":"Document"}; +"use strict";(()=>{var Ke=Object.create;var he=Object.defineProperty;var Ge=Object.getOwnPropertyDescriptor;var Ze=Object.getOwnPropertyNames;var Xe=Object.getPrototypeOf,Ye=Object.prototype.hasOwnProperty;var et=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports);var tt=(t,e,n,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of Ze(e))!Ye.call(t,i)&&i!==n&&he(t,i,{get:()=>e[i],enumerable:!(r=Ge(e,i))||r.enumerable});return t};var nt=(t,e,n)=>(n=t!=null?Ke(Xe(t)):{},tt(e||!t||!t.__esModule?he(n,"default",{value:t,enumerable:!0}):n,t));var ye=et((me,ge)=>{(function(){var t=function(e){var n=new t.Builder;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),n.searchPipeline.add(t.stemmer),e.call(n,n),n.build()};t.version="2.3.9";t.utils={},t.utils.warn=(function(e){return function(n){e.console&&console.warn&&console.warn(n)}})(this),t.utils.asString=function(e){return e==null?"":e.toString()},t.utils.clone=function(e){if(e==null)return e;for(var n=Object.create(null),r=Object.keys(e),i=0;i0){var d=t.utils.clone(n)||{};d.position=[a,l],d.index=s.length,s.push(new t.Token(r.slice(a,o),d))}a=o+1}}return s},t.tokenizer.separator=/[\s\-]+/;t.Pipeline=function(){this._stack=[]},t.Pipeline.registeredFunctions=Object.create(null),t.Pipeline.registerFunction=function(e,n){n in this.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[e.label]=e},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn(`Function is not registered with pipeline. This may cause problems when serialising the index. +`,e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(r){var i=t.Pipeline.registeredFunctions[r];if(i)n.add(i);else throw new Error("Cannot load unregistered function: "+r)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(n){t.Pipeline.warnIfFunctionNotRegistered(n),this._stack.push(n)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var r=this._stack.indexOf(e);if(r==-1)throw new Error("Cannot find existingFn");r=r+1,this._stack.splice(r,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var r=this._stack.indexOf(e);if(r==-1)throw new Error("Cannot find existingFn");this._stack.splice(r,0,n)},t.Pipeline.prototype.remove=function(e){var n=this._stack.indexOf(e);n!=-1&&this._stack.splice(n,1)},t.Pipeline.prototype.run=function(e){for(var n=this._stack.length,r=0;r1&&(oe&&(r=s),o!=e);)i=r-n,s=n+Math.floor(i/2),o=this.elements[s*2];if(o==e||o>e)return s*2;if(oc?d+=2:a==c&&(n+=r[l+1]*i[d+1],l+=2,d+=2);return n},t.Vector.prototype.similarity=function(e){return this.dot(e)/this.magnitude()||0},t.Vector.prototype.toArray=function(){for(var e=new Array(this.elements.length/2),n=1,r=0;n0){var o=s.str.charAt(0),a;o in s.node.edges?a=s.node.edges[o]:(a=new t.TokenSet,s.node.edges[o]=a),s.str.length==1&&(a.final=!0),i.push({node:a,editsRemaining:s.editsRemaining,str:s.str.slice(1)})}if(s.editsRemaining!=0){if("*"in s.node.edges)var c=s.node.edges["*"];else{var c=new t.TokenSet;s.node.edges["*"]=c}if(s.str.length==0&&(c.final=!0),i.push({node:c,editsRemaining:s.editsRemaining-1,str:s.str}),s.str.length>1&&i.push({node:s.node,editsRemaining:s.editsRemaining-1,str:s.str.slice(1)}),s.str.length==1&&(s.node.final=!0),s.str.length>=1){if("*"in s.node.edges)var l=s.node.edges["*"];else{var l=new t.TokenSet;s.node.edges["*"]=l}s.str.length==1&&(l.final=!0),i.push({node:l,editsRemaining:s.editsRemaining-1,str:s.str.slice(1)})}if(s.str.length>1){var d=s.str.charAt(0),f=s.str.charAt(1),p;f in s.node.edges?p=s.node.edges[f]:(p=new t.TokenSet,s.node.edges[f]=p),s.str.length==1&&(p.final=!0),i.push({node:p,editsRemaining:s.editsRemaining-1,str:d+s.str.slice(2)})}}}return r},t.TokenSet.fromString=function(e){for(var n=new t.TokenSet,r=n,i=0,s=e.length;i=e;n--){var r=this.uncheckedNodes[n],i=r.child.toString();i in this.minimizedNodes?r.parent.edges[r.char]=this.minimizedNodes[i]:(r.child._str=i,this.minimizedNodes[i]=r.child),this.uncheckedNodes.pop()}};t.Index=function(e){this.invertedIndex=e.invertedIndex,this.fieldVectors=e.fieldVectors,this.tokenSet=e.tokenSet,this.fields=e.fields,this.pipeline=e.pipeline},t.Index.prototype.search=function(e){return this.query(function(n){var r=new t.QueryParser(e,n);r.parse()})},t.Index.prototype.query=function(e){for(var n=new t.Query(this.fields),r=Object.create(null),i=Object.create(null),s=Object.create(null),o=Object.create(null),a=Object.create(null),c=0;c1?this._b=1:this._b=e},t.Builder.prototype.k1=function(e){this._k1=e},t.Builder.prototype.add=function(e,n){var r=e[this._ref],i=Object.keys(this._fields);this._documents[r]=n||{},this.documentCount+=1;for(var s=0;s=this.length)return t.QueryLexer.EOS;var e=this.str.charAt(this.pos);return this.pos+=1,e},t.QueryLexer.prototype.width=function(){return this.pos-this.start},t.QueryLexer.prototype.ignore=function(){this.start==this.pos&&(this.pos+=1),this.start=this.pos},t.QueryLexer.prototype.backup=function(){this.pos-=1},t.QueryLexer.prototype.acceptDigitRun=function(){var e,n;do e=this.next(),n=e.charCodeAt(0);while(n>47&&n<58);e!=t.QueryLexer.EOS&&this.backup()},t.QueryLexer.prototype.more=function(){return this.pos1&&(e.backup(),e.emit(t.QueryLexer.TERM)),e.ignore(),e.more())return t.QueryLexer.lexText},t.QueryLexer.lexEditDistance=function(e){return e.ignore(),e.acceptDigitRun(),e.emit(t.QueryLexer.EDIT_DISTANCE),t.QueryLexer.lexText},t.QueryLexer.lexBoost=function(e){return e.ignore(),e.acceptDigitRun(),e.emit(t.QueryLexer.BOOST),t.QueryLexer.lexText},t.QueryLexer.lexEOS=function(e){e.width()>0&&e.emit(t.QueryLexer.TERM)},t.QueryLexer.termSeparator=t.tokenizer.separator,t.QueryLexer.lexText=function(e){for(;;){var n=e.next();if(n==t.QueryLexer.EOS)return t.QueryLexer.lexEOS;if(n.charCodeAt(0)==92){e.escapeCharacter();continue}if(n==":")return t.QueryLexer.lexField;if(n=="~")return e.backup(),e.width()>0&&e.emit(t.QueryLexer.TERM),t.QueryLexer.lexEditDistance;if(n=="^")return e.backup(),e.width()>0&&e.emit(t.QueryLexer.TERM),t.QueryLexer.lexBoost;if(n=="+"&&e.width()===1||n=="-"&&e.width()===1)return e.emit(t.QueryLexer.PRESENCE),t.QueryLexer.lexText;if(n.match(t.QueryLexer.termSeparator))return t.QueryLexer.lexTerm}},t.QueryParser=function(e,n){this.lexer=new t.QueryLexer(e),this.query=n,this.currentClause={},this.lexemeIdx=0},t.QueryParser.prototype.parse=function(){this.lexer.run(),this.lexemes=this.lexer.lexemes;for(var e=t.QueryParser.parseClause;e;)e=e(this);return this.query},t.QueryParser.prototype.peekLexeme=function(){return this.lexemes[this.lexemeIdx]},t.QueryParser.prototype.consumeLexeme=function(){var e=this.peekLexeme();return this.lexemeIdx+=1,e},t.QueryParser.prototype.nextClause=function(){var e=this.currentClause;this.query.clause(e),this.currentClause={}},t.QueryParser.parseClause=function(e){var n=e.peekLexeme();if(n!=null)switch(n.type){case t.QueryLexer.PRESENCE:return t.QueryParser.parsePresence;case t.QueryLexer.FIELD:return t.QueryParser.parseField;case t.QueryLexer.TERM:return t.QueryParser.parseTerm;default:var r="expected either a field or a term, found "+n.type;throw n.str.length>=1&&(r+=" with value '"+n.str+"'"),new t.QueryParseError(r,n.start,n.end)}},t.QueryParser.parsePresence=function(e){var n=e.consumeLexeme();if(n!=null){switch(n.str){case"-":e.currentClause.presence=t.Query.presence.PROHIBITED;break;case"+":e.currentClause.presence=t.Query.presence.REQUIRED;break;default:var r="unrecognised presence operator'"+n.str+"'";throw new t.QueryParseError(r,n.start,n.end)}var i=e.peekLexeme();if(i==null){var r="expecting term or field, found nothing";throw new t.QueryParseError(r,n.start,n.end)}switch(i.type){case t.QueryLexer.FIELD:return t.QueryParser.parseField;case t.QueryLexer.TERM:return t.QueryParser.parseTerm;default:var r="expecting term or field, found '"+i.type+"'";throw new t.QueryParseError(r,i.start,i.end)}}},t.QueryParser.parseField=function(e){var n=e.consumeLexeme();if(n!=null){if(e.query.allFields.indexOf(n.str)==-1){var r=e.query.allFields.map(function(o){return"'"+o+"'"}).join(", "),i="unrecognised field '"+n.str+"', possible fields: "+r;throw new t.QueryParseError(i,n.start,n.end)}e.currentClause.fields=[n.str];var s=e.peekLexeme();if(s==null){var i="expecting term, found nothing";throw new t.QueryParseError(i,n.start,n.end)}switch(s.type){case t.QueryLexer.TERM:return t.QueryParser.parseTerm;default:var i="expecting term, found '"+s.type+"'";throw new t.QueryParseError(i,s.start,s.end)}}},t.QueryParser.parseTerm=function(e){var n=e.consumeLexeme();if(n!=null){e.currentClause.term=n.str.toLowerCase(),n.str.indexOf("*")!=-1&&(e.currentClause.usePipeline=!1);var r=e.peekLexeme();if(r==null){e.nextClause();return}switch(r.type){case t.QueryLexer.TERM:return e.nextClause(),t.QueryParser.parseTerm;case t.QueryLexer.FIELD:return e.nextClause(),t.QueryParser.parseField;case t.QueryLexer.EDIT_DISTANCE:return t.QueryParser.parseEditDistance;case t.QueryLexer.BOOST:return t.QueryParser.parseBoost;case t.QueryLexer.PRESENCE:return e.nextClause(),t.QueryParser.parsePresence;default:var i="Unexpected lexeme type '"+r.type+"'";throw new t.QueryParseError(i,r.start,r.end)}}},t.QueryParser.parseEditDistance=function(e){var n=e.consumeLexeme();if(n!=null){var r=parseInt(n.str,10);if(isNaN(r)){var i="edit distance must be numeric";throw new t.QueryParseError(i,n.start,n.end)}e.currentClause.editDistance=r;var s=e.peekLexeme();if(s==null){e.nextClause();return}switch(s.type){case t.QueryLexer.TERM:return e.nextClause(),t.QueryParser.parseTerm;case t.QueryLexer.FIELD:return e.nextClause(),t.QueryParser.parseField;case t.QueryLexer.EDIT_DISTANCE:return t.QueryParser.parseEditDistance;case t.QueryLexer.BOOST:return t.QueryParser.parseBoost;case t.QueryLexer.PRESENCE:return e.nextClause(),t.QueryParser.parsePresence;default:var i="Unexpected lexeme type '"+s.type+"'";throw new t.QueryParseError(i,s.start,s.end)}}},t.QueryParser.parseBoost=function(e){var n=e.consumeLexeme();if(n!=null){var r=parseInt(n.str,10);if(isNaN(r)){var i="boost must be numeric";throw new t.QueryParseError(i,n.start,n.end)}e.currentClause.boost=r;var s=e.peekLexeme();if(s==null){e.nextClause();return}switch(s.type){case t.QueryLexer.TERM:return e.nextClause(),t.QueryParser.parseTerm;case t.QueryLexer.FIELD:return e.nextClause(),t.QueryParser.parseField;case t.QueryLexer.EDIT_DISTANCE:return t.QueryParser.parseEditDistance;case t.QueryLexer.BOOST:return t.QueryParser.parseBoost;case t.QueryLexer.PRESENCE:return e.nextClause(),t.QueryParser.parsePresence;default:var i="Unexpected lexeme type '"+s.type+"'";throw new t.QueryParseError(i,s.start,s.end)}}},(function(e,n){typeof define=="function"&&define.amd?define(n):typeof me=="object"?ge.exports=n():e.lunr=n()})(this,function(){return t})})()});var M,G={getItem(){return null},setItem(){}},K;try{K=localStorage,M=K}catch{K=G,M=G}var S={getItem:t=>M.getItem(t),setItem:(t,e)=>M.setItem(t,e),disableWritingLocalStorage(){M=G},disable(){localStorage.clear(),M=G},enable(){M=K}};window.TypeDoc||={disableWritingLocalStorage(){S.disableWritingLocalStorage()},disableLocalStorage:()=>{S.disable()},enableLocalStorage:()=>{S.enable()}};window.translations||={copy:"Copy",copied:"Copied!",normally_hidden:"This member is normally hidden due to your filter settings.",hierarchy_expand:"Expand",hierarchy_collapse:"Collapse",search_index_not_available:"The search index is not available",search_no_results_found_for_0:"No results found for {0}",folder:"Folder",kind_1:"Project",kind_2:"Module",kind_4:"Namespace",kind_8:"Enumeration",kind_16:"Enumeration Member",kind_32:"Variable",kind_64:"Function",kind_128:"Class",kind_256:"Interface",kind_512:"Constructor",kind_1024:"Property",kind_2048:"Method",kind_4096:"Call Signature",kind_8192:"Index Signature",kind_16384:"Constructor Signature",kind_32768:"Parameter",kind_65536:"Type Literal",kind_131072:"Type Parameter",kind_262144:"Accessor",kind_524288:"Get Signature",kind_1048576:"Set Signature",kind_2097152:"Type Alias",kind_4194304:"Reference",kind_8388608:"Document"};var pe=[];function X(t,e){pe.push({selector:e,constructor:t})}var Z=class{alwaysVisibleMember=null;constructor(){this.createComponents(document.body),this.ensureFocusedElementVisible(),this.listenForCodeCopies(),window.addEventListener("hashchange",()=>this.ensureFocusedElementVisible()),document.body.style.display||(this.ensureFocusedElementVisible(),this.updateIndexVisibility(),this.scrollToHash())}createComponents(e){pe.forEach(n=>{e.querySelectorAll(n.selector).forEach(r=>{r.dataset.hasInstance||(new n.constructor({el:r,app:this}),r.dataset.hasInstance=String(!0))})})}filterChanged(){this.ensureFocusedElementVisible()}showPage(){document.body.style.display&&(document.body.style.removeProperty("display"),this.ensureFocusedElementVisible(),this.updateIndexVisibility(),this.scrollToHash())}scrollToHash(){if(location.hash){let e=document.getElementById(location.hash.substring(1));if(!e)return;e.scrollIntoView({behavior:"instant",block:"start"})}}ensureActivePageVisible(){let e=document.querySelector(".tsd-navigation .current"),n=e?.parentElement;for(;n&&!n.classList.contains(".tsd-navigation");)n instanceof HTMLDetailsElement&&(n.open=!0),n=n.parentElement;if(e&&!rt(e)){let r=e.getBoundingClientRect().top-document.documentElement.clientHeight/4;document.querySelector(".site-menu").scrollTop=r,document.querySelector(".col-sidebar").scrollTop=r}}updateIndexVisibility(){let e=document.querySelector(".tsd-index-content"),n=e?.open;e&&(e.open=!0),document.querySelectorAll(".tsd-index-section").forEach(r=>{r.style.display="block";let i=Array.from(r.querySelectorAll(".tsd-index-link")).every(s=>s.offsetParent==null);r.style.display=i?"none":"block"}),e&&(e.open=n)}ensureFocusedElementVisible(){if(this.alwaysVisibleMember&&(this.alwaysVisibleMember.classList.remove("always-visible"),this.alwaysVisibleMember.firstElementChild.remove(),this.alwaysVisibleMember=null),!location.hash)return;let e=document.getElementById(location.hash.substring(1));if(!e)return;let n=e.parentElement;for(;n&&n.tagName!=="SECTION";)n=n.parentElement;if(!n)return;let r=n.offsetParent==null,i=n;for(;i!==document.body;)i instanceof HTMLDetailsElement&&(i.open=!0),i=i.parentElement;if(n.offsetParent==null){this.alwaysVisibleMember=n,n.classList.add("always-visible");let s=document.createElement("p");s.classList.add("warning"),s.textContent=window.translations.normally_hidden,n.prepend(s)}r&&e.scrollIntoView()}listenForCodeCopies(){document.querySelectorAll("pre > button").forEach(e=>{let n;e.addEventListener("click",()=>{e.previousElementSibling instanceof HTMLElement&&navigator.clipboard.writeText(e.previousElementSibling.innerText.trim()),e.textContent=window.translations.copied,e.classList.add("visible"),clearTimeout(n),n=setTimeout(()=>{e.classList.remove("visible"),n=setTimeout(()=>{e.textContent=window.translations.copy},100)},1e3)})})}};function rt(t){let e=t.getBoundingClientRect(),n=Math.max(document.documentElement.clientHeight,window.innerHeight);return!(e.bottom<0||e.top-n>=0)}var fe=(t,e=100)=>{let n;return()=>{clearTimeout(n),n=setTimeout(()=>t(),e)}};var Ie=nt(ye(),1);async function R(t){let e=Uint8Array.from(atob(t),s=>s.charCodeAt(0)),r=new Blob([e]).stream().pipeThrough(new DecompressionStream("deflate")),i=await new Response(r).text();return JSON.parse(i)}var Y="closing",ae="tsd-overlay";function it(){let t=Math.abs(window.innerWidth-document.documentElement.clientWidth);document.body.style.overflow="hidden",document.body.style.paddingRight=`${t}px`}function st(){document.body.style.removeProperty("overflow"),document.body.style.removeProperty("padding-right")}function xe(t,e){t.addEventListener("animationend",()=>{t.classList.contains(Y)&&(t.classList.remove(Y),document.getElementById(ae)?.remove(),t.close(),st())}),t.addEventListener("cancel",n=>{n.preventDefault(),ve(t)}),e?.closeOnClick&&document.addEventListener("click",n=>{t.open&&!t.contains(n.target)&&ve(t)},!0)}function Ee(t){if(t.open)return;let e=document.createElement("div");e.id=ae,document.body.appendChild(e),t.showModal(),it()}function ve(t){if(!t.open)return;document.getElementById(ae)?.classList.add(Y),t.classList.add(Y)}var I=class{el;app;constructor(e){this.el=e.el,this.app=e.app}};var be=document.head.appendChild(document.createElement("style"));be.dataset.for="filters";var le={};function we(t){for(let e of t.split(/\s+/))if(le.hasOwnProperty(e)&&!le[e])return!0;return!1}var ee=class extends I{key;value;constructor(e){super(e),this.key=`filter-${this.el.name}`,this.value=this.el.checked,this.el.addEventListener("change",()=>{this.setLocalStorage(this.el.checked)}),this.setLocalStorage(this.fromLocalStorage()),be.innerHTML+=`html:not(.${this.key}) .tsd-is-${this.el.name} { display: none; } +`,this.app.updateIndexVisibility()}fromLocalStorage(){let e=S.getItem(this.key);return e?e==="true":this.el.checked}setLocalStorage(e){S.setItem(this.key,e.toString()),this.value=e,this.handleValueChange()}handleValueChange(){this.el.checked=this.value,document.documentElement.classList.toggle(this.key,this.value),le[`tsd-is-${this.el.name}`]=this.value,this.app.filterChanged(),this.app.updateIndexVisibility()}};var Le=0;async function Se(t,e){if(!window.searchData)return;let n=await R(window.searchData);t.data=n,t.index=Ie.Index.load(n.index),e.innerHTML=""}function _e(){let t=document.getElementById("tsd-search-trigger"),e=document.getElementById("tsd-search"),n=document.getElementById("tsd-search-input"),r=document.getElementById("tsd-search-results"),i=document.getElementById("tsd-search-script"),s=document.getElementById("tsd-search-status");if(!(t&&e&&n&&r&&i&&s))throw new Error("Search controls missing");let o={base:document.documentElement.dataset.base};o.base.endsWith("/")||(o.base+="/"),i.addEventListener("error",()=>{let a=window.translations.search_index_not_available;Pe(s,a)}),i.addEventListener("load",()=>{Se(o,s)}),Se(o,s),ot({trigger:t,searchEl:e,results:r,field:n,status:s},o)}function ot(t,e){let{field:n,results:r,searchEl:i,status:s,trigger:o}=t;xe(i,{closeOnClick:!0});function a(){Ee(i),n.setSelectionRange(0,n.value.length)}o.addEventListener("click",a),n.addEventListener("input",fe(()=>{at(r,n,s,e)},200)),n.addEventListener("keydown",l=>{if(r.childElementCount===0||l.ctrlKey||l.metaKey||l.altKey)return;let d=n.getAttribute("aria-activedescendant"),f=d?document.getElementById(d):null;if(f){let p=!1,v=!1;switch(l.key){case"Home":case"End":case"ArrowLeft":case"ArrowRight":v=!0;break;case"ArrowDown":case"ArrowUp":p=l.shiftKey;break}(p||v)&&ke(n)}if(!l.shiftKey)switch(l.key){case"Enter":f?.querySelector("a")?.click();break;case"ArrowUp":Te(r,n,f,-1),l.preventDefault();break;case"ArrowDown":Te(r,n,f,1),l.preventDefault();break}});function c(){ke(n)}n.addEventListener("change",c),n.addEventListener("blur",c),n.addEventListener("click",c),document.body.addEventListener("keydown",l=>{if(l.altKey||l.metaKey||l.shiftKey)return;let d=l.ctrlKey&&l.key==="k",f=!l.ctrlKey&&!ut()&&l.key==="/";(d||f)&&(l.preventDefault(),a())})}function at(t,e,n,r){if(!r.index||!r.data)return;t.innerHTML="",n.innerHTML="",Le+=1;let i=e.value.trim(),s;if(i){let a=i.split(" ").map(c=>c.length?`*${c}*`:"").join(" ");s=r.index.search(a).filter(({ref:c})=>{let l=r.data.rows[Number(c)].classes;return!l||!we(l)})}else s=[];if(s.length===0&&i){let a=window.translations.search_no_results_found_for_0.replace("{0}",` "${te(i)}" `);Pe(n,a);return}for(let a=0;ac.score-a.score);let o=Math.min(10,s.length);for(let a=0;a`,f=Ce(c.name,i);globalThis.DEBUG_SEARCH_WEIGHTS&&(f+=` (score: ${s[a].score.toFixed(2)})`),c.parent&&(f=` + ${Ce(c.parent,i)}.${f}`);let p=document.createElement("li");p.id=`tsd-search:${Le}-${a}`,p.role="option",p.ariaSelected="false",p.classList.value=c.classes??"";let v=document.createElement("a");v.tabIndex=-1,v.href=r.base+c.url,v.innerHTML=d+`${f}`,p.append(v),t.appendChild(p)}}function Te(t,e,n,r){let i;if(r===1?i=n?.nextElementSibling||t.firstElementChild:i=n?.previousElementSibling||t.lastElementChild,i!==n){if(!i||i.role!=="option"){console.error("Option missing");return}i.ariaSelected="true",i.scrollIntoView({behavior:"smooth",block:"nearest"}),e.setAttribute("aria-activedescendant",i.id),n?.setAttribute("aria-selected","false")}}function ke(t){let e=t.getAttribute("aria-activedescendant");(e?document.getElementById(e):null)?.setAttribute("aria-selected","false"),t.setAttribute("aria-activedescendant","")}function Ce(t,e){if(e==="")return t;let n=t.toLocaleLowerCase(),r=e.toLocaleLowerCase(),i=[],s=0,o=n.indexOf(r);for(;o!=-1;)i.push(te(t.substring(s,o)),`${te(t.substring(o,o+r.length))}`),s=o+r.length,o=n.indexOf(r,s);return i.push(te(t.substring(s))),i.join("")}var lt={"&":"&","<":"<",">":">","'":"'",'"':"""};function te(t){return t.replace(/[&<>"'"]/g,e=>lt[e])}function Pe(t,e){t.innerHTML=e?`
${e}
`:""}var ct=["button","checkbox","file","hidden","image","radio","range","reset","submit"];function ut(){let t=document.activeElement;return t?t.isContentEditable||t.tagName==="TEXTAREA"||t.tagName==="SEARCH"?!0:t.tagName==="INPUT"&&!ct.includes(t.type):!1}var D="mousedown",Me="mousemove",$="mouseup",ne={x:0,y:0},Qe=!1,ce=!1,dt=!1,F=!1,Oe=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);document.documentElement.classList.add(Oe?"is-mobile":"not-mobile");Oe&&"ontouchstart"in document.documentElement&&(dt=!0,D="touchstart",Me="touchmove",$="touchend");document.addEventListener(D,t=>{ce=!0,F=!1;let e=D=="touchstart"?t.targetTouches[0]:t;ne.y=e.pageY||0,ne.x=e.pageX||0});document.addEventListener(Me,t=>{if(ce&&!F){let e=D=="touchstart"?t.targetTouches[0]:t,n=ne.x-(e.pageX||0),r=ne.y-(e.pageY||0);F=Math.sqrt(n*n+r*r)>10}});document.addEventListener($,()=>{ce=!1});document.addEventListener("click",t=>{Qe&&(t.preventDefault(),t.stopImmediatePropagation(),Qe=!1)});var re=class extends I{active;className;constructor(e){super(e),this.className=this.el.dataset.toggle||"",this.el.addEventListener($,n=>this.onPointerUp(n)),this.el.addEventListener("click",n=>n.preventDefault()),document.addEventListener(D,n=>this.onDocumentPointerDown(n)),document.addEventListener($,n=>this.onDocumentPointerUp(n))}setActive(e){if(this.active==e)return;this.active=e,document.documentElement.classList.toggle("has-"+this.className,e),this.el.classList.toggle("active",e);let n=(this.active?"to-has-":"from-has-")+this.className;document.documentElement.classList.add(n),setTimeout(()=>document.documentElement.classList.remove(n),500)}onPointerUp(e){F||(this.setActive(!0),e.preventDefault())}onDocumentPointerDown(e){if(this.active){if(e.target.closest(".col-sidebar, .tsd-filter-group"))return;this.setActive(!1)}}onDocumentPointerUp(e){if(!F&&this.active&&e.target.closest(".col-sidebar")){let n=e.target.closest("a");if(n){let r=window.location.href;r.indexOf("#")!=-1&&(r=r.substring(0,r.indexOf("#"))),n.href.substring(0,r.length)==r&&setTimeout(()=>this.setActive(!1),250)}}}};var ue=new Map,de=class{open;accordions=[];key;constructor(e,n){this.key=e,this.open=n}add(e){this.accordions.push(e),e.open=this.open,e.addEventListener("toggle",()=>{this.toggle(e.open)})}toggle(e){for(let n of this.accordions)n.open=e;S.setItem(this.key,e.toString())}},ie=class extends I{constructor(e){super(e);let n=this.el.querySelector("summary"),r=n.querySelector("a");r&&r.addEventListener("click",()=>{location.assign(r.href)});let i=`tsd-accordion-${n.dataset.key??n.textContent.trim().replace(/\s+/g,"-").toLowerCase()}`,s;if(ue.has(i))s=ue.get(i);else{let o=S.getItem(i),a=o?o==="true":this.el.open;s=new de(i,a),ue.set(i,s)}s.add(this.el)}};function He(t){let e=S.getItem("tsd-theme")||"os";t.value=e,Ae(e),t.addEventListener("change",()=>{S.setItem("tsd-theme",t.value),Ae(t.value)})}function Ae(t){document.documentElement.dataset.theme=t}var se;function Ne(){let t=document.getElementById("tsd-nav-script");t&&(t.addEventListener("load",Re),Re())}async function Re(){let t=document.getElementById("tsd-nav-container");if(!t||!window.navigationData)return;let e=await R(window.navigationData);se=document.documentElement.dataset.base,se.endsWith("/")||(se+="/"),t.innerHTML="";for(let n of e)Be(n,t,[]);window.app.createComponents(t),window.app.showPage(),window.app.ensureActivePageVisible()}function Be(t,e,n){let r=e.appendChild(document.createElement("li"));if(t.children){let i=[...n,t.text],s=r.appendChild(document.createElement("details"));s.className=t.class?`${t.class} tsd-accordion`:"tsd-accordion";let o=s.appendChild(document.createElement("summary"));o.className="tsd-accordion-summary",o.dataset.key=i.join("$"),o.innerHTML='',De(t,o);let a=s.appendChild(document.createElement("div"));a.className="tsd-accordion-details";let c=a.appendChild(document.createElement("ul"));c.className="tsd-nested-navigation";for(let l of t.children)Be(l,c,i)}else De(t,r,t.class)}function De(t,e,n){if(t.path){let r=e.appendChild(document.createElement("a"));if(r.href=se+t.path,n&&(r.className=n),location.pathname===r.pathname&&!r.href.includes("#")&&(r.classList.add("current"),r.ariaCurrent="page"),t.kind){let i=window.translations[`kind_${t.kind}`].replaceAll('"',""");r.innerHTML=``}r.appendChild(Fe(t.text,document.createElement("span")))}else{let r=e.appendChild(document.createElement("span")),i=window.translations.folder.replaceAll('"',""");r.innerHTML=``,r.appendChild(Fe(t.text,document.createElement("span")))}}function Fe(t,e){let n=t.split(/(?<=[^A-Z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[_-])(?=[^_-])/);for(let r=0;r{let i=r.target;for(;i.parentElement&&i.parentElement.tagName!="LI";)i=i.parentElement;i.dataset.dropdown&&(i.dataset.dropdown=String(i.dataset.dropdown!=="true"))});let t=new Map,e=new Set;for(let r of document.querySelectorAll(".tsd-full-hierarchy [data-refl]")){let i=r.querySelector("ul");t.has(r.dataset.refl)?e.add(r.dataset.refl):i&&t.set(r.dataset.refl,i)}for(let r of e)n(r);function n(r){let i=t.get(r).cloneNode(!0);i.querySelectorAll("[id]").forEach(s=>{s.removeAttribute("id")}),i.querySelectorAll("[data-dropdown]").forEach(s=>{s.dataset.dropdown="false"});for(let s of document.querySelectorAll(`[data-refl="${r}"]`)){let o=gt(),a=s.querySelector("ul");s.insertBefore(o,a),o.dataset.dropdown=String(!!a),a||s.appendChild(i.cloneNode(!0))}}}function pt(){let t=document.getElementById("tsd-hierarchy-script");t&&(t.addEventListener("load",Ve),Ve())}async function Ve(){let t=document.querySelector(".tsd-panel.tsd-hierarchy:has(h4 a)");if(!t||!window.hierarchyData)return;let e=+t.dataset.refl,n=await R(window.hierarchyData),r=t.querySelector("ul"),i=document.createElement("ul");if(i.classList.add("tsd-hierarchy"),ft(i,n,e),r.querySelectorAll("li").length==i.querySelectorAll("li").length)return;let s=document.createElement("span");s.classList.add("tsd-hierarchy-toggle"),s.textContent=window.translations.hierarchy_expand,t.querySelector("h4 a")?.insertAdjacentElement("afterend",s),s.insertAdjacentText("beforebegin",", "),s.addEventListener("click",()=>{s.textContent===window.translations.hierarchy_expand?(r.insertAdjacentElement("afterend",i),r.remove(),s.textContent=window.translations.hierarchy_collapse):(i.insertAdjacentElement("afterend",r),i.remove(),s.textContent=window.translations.hierarchy_expand)})}function ft(t,e,n){let r=e.roots.filter(i=>mt(e,i,n));for(let i of r)t.appendChild(je(e,i,n))}function je(t,e,n,r=new Set){if(r.has(e))return;r.add(e);let i=t.reflections[e],s=document.createElement("li");if(s.classList.add("tsd-hierarchy-item"),e===n){let o=s.appendChild(document.createElement("span"));o.textContent=i.name,o.classList.add("tsd-hierarchy-target")}else{for(let a of i.uniqueNameParents||[]){let c=t.reflections[a],l=s.appendChild(document.createElement("a"));l.textContent=c.name,l.href=oe+c.url,l.className=c.class+" tsd-signature-type",s.append(document.createTextNode("."))}let o=s.appendChild(document.createElement("a"));o.textContent=t.reflections[e].name,o.href=oe+i.url,o.className=i.class+" tsd-signature-type"}if(i.children){let o=s.appendChild(document.createElement("ul"));o.classList.add("tsd-hierarchy");for(let a of i.children){let c=je(t,a,n,r);c&&o.appendChild(c)}}return r.delete(e),s}function mt(t,e,n){if(e===n)return!0;let r=new Set,i=[t.reflections[e]];for(;i.length;){let s=i.pop();if(!r.has(s)){r.add(s);for(let o of s.children||[]){if(o===n)return!0;i.push(t.reflections[o])}}}return!1}function gt(){let t=document.createElementNS("http://www.w3.org/2000/svg","svg");return t.setAttribute("width","20"),t.setAttribute("height","20"),t.setAttribute("viewBox","0 0 24 24"),t.setAttribute("fill","none"),t.innerHTML='',t}X(re,"a[data-toggle]");X(ie,".tsd-accordion");X(ee,".tsd-filter-item input[type=checkbox]");var qe=document.getElementById("tsd-theme");qe&&He(qe);var yt=new Z;Object.defineProperty(window,"app",{value:yt});_e();Ne();$e();"virtualKeyboard"in navigator&&(navigator.virtualKeyboard.overlaysContent=!0);})(); +/*! Bundled license information: + +lunr/lunr.js: + (** + * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 2.3.9 + * Copyright (C) 2020 Oliver Nightingale + * @license MIT + *) + (*! + * lunr.utils + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Set + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.tokenizer + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Pipeline + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Vector + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.stemmer + * Copyright (C) 2020 Oliver Nightingale + * Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt + *) + (*! + * lunr.stopWordFilter + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.trimmer + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.TokenSet + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Index + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Builder + * Copyright (C) 2020 Oliver Nightingale + *) +*/ diff --git a/docs/ng-vitest/assets/navigation.js b/docs/ng-vitest/assets/navigation.js new file mode 100644 index 00000000..12fa60e1 --- /dev/null +++ b/docs/ng-vitest/assets/navigation.js @@ -0,0 +1 @@ +window.navigationData = "eJyN001PhDAQBuD/0jNxXeIntw0x8UJMFhMPxkMt44LbnWJnSCDG/25gNXyV7l6Ztw+dGXj9Fgw1i0hscFdpaWOD3YNAlJJzEQmlJRHQaly/yPmgRSD2BWYiWod3P0EvUYMqAc5N1qat0RqsA3TFTrrPQBxLrRe8/7LPic2hNAjIi71OE2dpj9IiEKVVCbaDPO4s63tDYtTeN8hx3Selup3PFr4qIEfbo7LPWV7COfNv7/v0/glqcAVuyr9OjpXx+fDy/nZ9HQ4Maa1sXgrOE8kq752PChUXBmk1Toy9m6sBpSxIBtelemya8XBQl6D4oZbq+PUgk0t0xE6is8VNOefqHFBa4E5Du6cNZlsg8JjzrIcnllx0P6EL7Ksz4u0X1lKUsw==" \ No newline at end of file diff --git a/docs/ng-vitest/assets/search.js b/docs/ng-vitest/assets/search.js new file mode 100644 index 00000000..c1bb76ba --- /dev/null +++ b/docs/ng-vitest/assets/search.js @@ -0,0 +1 @@ +window.searchData = "eJy9m99vnEgSx/+VE3llHfcvesZvkW+li7TZnJLc7cPIOnEMsblgmAMmiWX5fz81MEPVVPW4sdl7yq6nq+rbzYfqomgeo6b+0UZXm8foW1FtoyshV3FUpfd5dBW9q273Zdpc11WX/+yiONo3ZXQVZWXatnn7Fv98cdfdl1F8+DW6iqKn+ODVCHn0mtVV2zX7rKubEJdv8HjgPo52aZNXHVU6BRaXUh8jt13adF+K+zwobj+6G0bPjCov9bSKWZmn1T92YXN1Y/c7FLFrt78U7S+7pu7yrMu38xXc5t27svxb2lR57zNEyW3epWV5B2xeswa3eTeGDw1+dxy+TNyPze/7spwZvW6qweg1Gu7Sds7c79J2mbkXVRF0z74ZBy5JXFH9J89Co49DXzXX9tO+qorqNixk2xxHvyZqs6+C4g3jXhnp+i6tbvO/5u6SFHVw4Kw32wKzJa9zV2TfgpSMA1+zBt/zpvj68Pe67b7kbXddV9vCzSjsrhqMd3XbdXnbZdB44Ux7vW96k8BMkx2Hz93Z4EbdPlTZh7y7q7duYFOXZc5srtyoZbZtr+ew3ZuV71ni/Ocuz7rf64rZxf0yBqtqsCJXvKju8qY4veLzVX18iag/TdN92mV3c/QcDP4ELcP9N0fM0WIZNeR26ZNIypUE8Nflbg/kMfy2OMr0gdc0YREPA18R62u5bz1A4ViHga+I5WrW5pZJ7jSaK1WHoTNQCYjvfe5hJWTH0cuqeF+1XVplnsRCZBTT8KV1fK+z1G2abuDHZuvbYRhJB8ssLct6tFxW3ae83Zehl6o5DF5Ww+e868p8O0tKO9gspAgmuev6fldXeeXF+HTAIqmOdRqU7Yhez0P81+Jnt2/o7cBHnka/Lqq/dcDH5ZsH567pGSUItLRti9vqfbXbdzQ78mIGk+JgMnslmPh/NOlulzefu4eSaSick/FjsGwPlq9T42uueDic2V4JVfFcg4VXc67FsgQp/RY2DvXuIl5p2eEHdkd5oR5fI8SrgmuFLLQ25xtDzyliWkNL6DrTLOIVedpFS2hhG0i8ilktpPD4bBPJp4C0kRZZA29jySODbS0toYRrNvEaTttNC0V/rgHlFfPSFlSoNrYJxashbagl1ia4McVrelFr6i9LbRCebpV/W2D6VS/UwlarY/b7vN/lTR/BL40MPVvBMhv2l3r3W/49D9m4+VjjFt7Vu9L5ObuVn0ecTtuv/ET2S0SfKP7/6P2tTrmHxhlyy4OHBdVCCj/U2bczDVT88yLPS4zLoKelE6Xze6Vc4Bc0Sefo4LqjfhnLq+D7oZyCeY3QsOieDigXfmbr0xsfov25dJvTp/y/+7yl6R79ugjY1GMQ11im52G8CZ7Fm2novHgBfU8m2vm+Z3isX9mmri/g+c5uUNSivXaPeGXZk/Vs2KLNwPCZVxJA6e3BL9p+n995f2lz+vm+9Mwovt5dUPd5ZixviyCsxzw72vPt5Nmd5JkaPJ3akH7xzEjnW8MzusLPxl1bYSQqaD7+Gz1Gdw+7cdsZfjh7iyVT0k2bJn34o+juPqBt9Ou+6p/q2rd4QKjbrMnTLmd0To5Ph4S6HuqIX3+m2XAfVaBhOnlnRs0LcLqnnroO2VWJ089FdVvm7uq+qxw2ud8/HRoaqu3SrujfIzDOpx85dzdxVFTb/Gd09egqm9Y9119F8kJdrKM4+lrk5dadKhwixVFW398PT43bOtv3/3kzDvunq/UaN3gY/fYyijeXsb68kEbf3MSbg3H/Q/+Hg4/pL72hiOKNiKW+EGaNDAUxFMhQRvFGxspcCG2QoSSGEhmqKN4ozlARQ4UMdRRvNGeoiaFGhiaKN4YzNMTQIMMkijcJZ5gQwwQZ2ijeWM7QEkOLDFdRvFlxhitiuEKG6yjerDnDNTFcYwAcD+KSMxUUHnFCT4+PYI0ZgDBBwnEhWIYEhUhgioRjQ7AcCQqSwCQJx4dgWRIUJoFpEo4RwfIkKFACEyUcJyLh7lRBoRKYKmG9NyvlSmCwhMNFsEwKypbAcAmHjGC5FJQvgQGTPWAsm5ICJjFg0iEjWTolBUyepKg+RwlusSWTpTBgUvkWW1K+JOZLOmKkZANTviTmSzpiJEu2pHxJzJd0xEiWbEn5kpgv6ZCRLNmSAiYxYNIhI9lsKSlgEgMmHTKSpVNSwCQGTDlkJEunooApDJjqAWPpVBQwhQFTDhnF7ruKAqZO9kEvYIrZCTFgyiGjWLIVBUxhwJTxbd2K8qUwX8oRo1iyFeVLYb6UI0Yp1pjypTBfauWtGyheCuOl1r7SQVG6FKZLO16U5kRrSpfGdGnhKz00hUtjuLT0VR+asqUxW1r5ChBN2dInZZb21SCaKbQwWtr4yhBN0dIYLZ14CxFN0dIYLW29hYimaGmMll55CxFN2dKYLb32FiKawqUxXObSW4gYCpfBcBnhLUQMpctgukyfugyHtaF4GYyX6Wt4tooxlC+D+TJ97rKsMQXMnJTyjhm1Yo2Zah4TZvrktWaNKWEGE2a8xZehgBkMmPEXX4YCZjBgxl98GQqYwYAl/uIroYAlGLDEX3wlFLAEA5Y4ZDS7NyYUsAQDlnj3xoTylWC+kv45kd0bE8pXgvlK/MVXQvlKTh4XHTGa3RwT5okR85U4ZDS7OSYUsAQDljhkNLtJJRSwBAOWrL2LTflKMF/20ltvWsqXxXxZ4a03LeXLYr6s9NablvJlMV9WeetNSwGzGDCrvfWmpYBZDJg13nrTUsAsBsz2gLFJ21LA7ElPogeMTdqWaUtgwGwPGJu0LQXMYsBsX36xSdtSwiwmbOWY0WzSXlHCVpiwVV9/sUloRQlbYcJWjhnDJpIVJWz8U9/j+543Xb59P/T6NpsoHb5HOr5veIz+NTYCLw99xcfoMrp6fHqa2n5Xj0+g8+d+c/H6hvGPorsbX7dOrnQy+bI2zBk64zq5UnJypZIZrk7OqQKPCngMFfdQZff9xykZOF4w+RRgwiJU5UOV9SeG+hdoky8pJl9SBvk6nosFswSTHKzUKszX4dAFg4gCjKhAaQd341GVFhwMAo7N5NiEOgbvAcGVABdisBR2+Fce1mH814x/T8b/T9Zhcfu3Gfd19q0eX3gA7i3gPmy5x5e/4PID4KUO89G/R8jdK5Ds+KIEqFoBVWGThCdJwNqC6YnVuIqB05zOhAB/QJhYj/7mCDyeDgCTXU8+V2F5bPDV9q9h3M2YVu7NXY7cGkD+SgS5PX66ADAHdKqwazseSQB8gDtbmhFdE+6LAKcBcElY7iIn34G3yZke77ewK+o9igeWD2RZo4K99u/wwQICiTIZ7GwYKfyxeiAQzn6GS5JoJciHcsxTNgw7eMYT3GrAoRipMcEOj8cMwQ0BLsVgqOe6OxzBn5yC6zteGB22GaBDDWAZgUM5Jiw7wyNzWgE4B9lQjtnLBlN5OBsA/IFsqC5Hf2FJgh45AG5BQlTjlmjD8gV30BTADuZvgnWeHgUF/sD8TZhA+PHE5AhuwiNFYZdl+ApicgQEjfDosHkePmaYXIGLMKKiw6ZYwKNaIM2CXSAJK2LBpw0gL4B9TYzQ6bBNgFT9AkxSjr6SsCzoyim+slbAqQkUxtdmIGPZMEdceQF28CQMhv5zDrBKsEId70cddgW5bzGAY7CPCzk6DqvO2tI9iHDTBXgkYYkTfDkJkhAgY6wKwvAfTrF0J7IMWMNVmCzuSUuD7ToJW6nhAxSw6OA+FOODhA4reA6nhMEqgdWWIxpJ2K7q/wgFaAXTFWNxZgLuz5s42hW7vCyqPLra3Dw9/Q85lTHJ"; \ No newline at end of file diff --git a/docs/ng-vitest/assets/style.css b/docs/ng-vitest/assets/style.css new file mode 100644 index 00000000..5ba5a2a9 --- /dev/null +++ b/docs/ng-vitest/assets/style.css @@ -0,0 +1,1633 @@ +@layer typedoc { + :root { + --dim-toolbar-contents-height: 2.5rem; + --dim-toolbar-border-bottom-width: 1px; + --dim-header-height: calc( + var(--dim-toolbar-border-bottom-width) + + var(--dim-toolbar-contents-height) + ); + + /* 0rem For mobile; unit is required for calculation in `calc` */ + --dim-container-main-margin-y: 0rem; + + --dim-footer-height: 3.5rem; + + --modal-animation-duration: 0.2s; + } + + :root { + /* Light */ + --light-color-background: #f2f4f8; + --light-color-background-secondary: #eff0f1; + /* Not to be confused with [:active](https://developer.mozilla.org/en-US/docs/Web/CSS/:active) */ + --light-color-background-active: #d6d8da; + --light-color-background-warning: #e6e600; + --light-color-warning-text: #222; + --light-color-accent: #c5c7c9; + --light-color-active-menu-item: var(--light-color-background-active); + --light-color-text: #222; + --light-color-contrast-text: #000; + --light-color-text-aside: #5e5e5e; + + --light-color-icon-background: var(--light-color-background); + --light-color-icon-text: var(--light-color-text); + + --light-color-comment-tag-text: var(--light-color-text); + --light-color-comment-tag: var(--light-color-background); + + --light-color-link: #1f70c2; + --light-color-focus-outline: #3584e4; + + --light-color-ts-keyword: #056bd6; + --light-color-ts-project: #b111c9; + --light-color-ts-module: var(--light-color-ts-project); + --light-color-ts-namespace: var(--light-color-ts-project); + --light-color-ts-enum: #7e6f15; + --light-color-ts-enum-member: var(--light-color-ts-enum); + --light-color-ts-variable: #4760ec; + --light-color-ts-function: #572be7; + --light-color-ts-class: #1f70c2; + --light-color-ts-interface: #108024; + --light-color-ts-constructor: var(--light-color-ts-class); + --light-color-ts-property: #9f5f30; + --light-color-ts-method: #be3989; + --light-color-ts-reference: #ff4d82; + --light-color-ts-call-signature: var(--light-color-ts-method); + --light-color-ts-index-signature: var(--light-color-ts-property); + --light-color-ts-constructor-signature: var( + --light-color-ts-constructor + ); + --light-color-ts-parameter: var(--light-color-ts-variable); + /* type literal not included as links will never be generated to it */ + --light-color-ts-type-parameter: #a55c0e; + --light-color-ts-accessor: #c73c3c; + --light-color-ts-get-signature: var(--light-color-ts-accessor); + --light-color-ts-set-signature: var(--light-color-ts-accessor); + --light-color-ts-type-alias: #d51270; + /* reference not included as links will be colored with the kind that it points to */ + --light-color-document: #000000; + + --light-color-alert-note: #0969d9; + --light-color-alert-tip: #1a7f37; + --light-color-alert-important: #8250df; + --light-color-alert-warning: #9a6700; + --light-color-alert-caution: #cf222e; + + --light-external-icon: url("data:image/svg+xml;utf8,"); + --light-color-scheme: light; + } + + :root { + /* Dark */ + --dark-color-background: #2b2e33; + --dark-color-background-secondary: #1e2024; + /* Not to be confused with [:active](https://developer.mozilla.org/en-US/docs/Web/CSS/:active) */ + --dark-color-background-active: #5d5d6a; + --dark-color-background-warning: #bebe00; + --dark-color-warning-text: #222; + --dark-color-accent: #9096a2; + --dark-color-active-menu-item: var(--dark-color-background-active); + --dark-color-text: #f5f5f5; + --dark-color-contrast-text: #ffffff; + --dark-color-text-aside: #dddddd; + + --dark-color-icon-background: var(--dark-color-background-secondary); + --dark-color-icon-text: var(--dark-color-text); + + --dark-color-comment-tag-text: var(--dark-color-text); + --dark-color-comment-tag: var(--dark-color-background); + + --dark-color-link: #00aff4; + --dark-color-focus-outline: #4c97f2; + + --dark-color-ts-keyword: #3399ff; + --dark-color-ts-project: #e358ff; + --dark-color-ts-module: var(--dark-color-ts-project); + --dark-color-ts-namespace: var(--dark-color-ts-project); + --dark-color-ts-enum: #f4d93e; + --dark-color-ts-enum-member: var(--dark-color-ts-enum); + --dark-color-ts-variable: #798dff; + --dark-color-ts-function: #a280ff; + --dark-color-ts-class: #8ac4ff; + --dark-color-ts-interface: #6cff87; + --dark-color-ts-constructor: var(--dark-color-ts-class); + --dark-color-ts-property: #ff984d; + --dark-color-ts-method: #ff4db8; + --dark-color-ts-reference: #ff4d82; + --dark-color-ts-call-signature: var(--dark-color-ts-method); + --dark-color-ts-index-signature: var(--dark-color-ts-property); + --dark-color-ts-constructor-signature: var(--dark-color-ts-constructor); + --dark-color-ts-parameter: var(--dark-color-ts-variable); + /* type literal not included as links will never be generated to it */ + --dark-color-ts-type-parameter: #e07d13; + --dark-color-ts-accessor: #ff6060; + --dark-color-ts-get-signature: var(--dark-color-ts-accessor); + --dark-color-ts-set-signature: var(--dark-color-ts-accessor); + --dark-color-ts-type-alias: #ff6492; + /* reference not included as links will be colored with the kind that it points to */ + --dark-color-document: #ffffff; + + --dark-color-alert-note: #0969d9; + --dark-color-alert-tip: #1a7f37; + --dark-color-alert-important: #8250df; + --dark-color-alert-warning: #9a6700; + --dark-color-alert-caution: #cf222e; + + --dark-external-icon: url("data:image/svg+xml;utf8,"); + --dark-color-scheme: dark; + } + + @media (prefers-color-scheme: light) { + :root { + --color-background: var(--light-color-background); + --color-background-secondary: var( + --light-color-background-secondary + ); + --color-background-active: var(--light-color-background-active); + --color-background-warning: var(--light-color-background-warning); + --color-warning-text: var(--light-color-warning-text); + --color-accent: var(--light-color-accent); + --color-active-menu-item: var(--light-color-active-menu-item); + --color-text: var(--light-color-text); + --color-contrast-text: var(--light-color-contrast-text); + --color-text-aside: var(--light-color-text-aside); + + --color-icon-background: var(--light-color-icon-background); + --color-icon-text: var(--light-color-icon-text); + + --color-comment-tag-text: var(--light-color-text); + --color-comment-tag: var(--light-color-background); + + --color-link: var(--light-color-link); + --color-focus-outline: var(--light-color-focus-outline); + + --color-ts-keyword: var(--light-color-ts-keyword); + --color-ts-project: var(--light-color-ts-project); + --color-ts-module: var(--light-color-ts-module); + --color-ts-namespace: var(--light-color-ts-namespace); + --color-ts-enum: var(--light-color-ts-enum); + --color-ts-enum-member: var(--light-color-ts-enum-member); + --color-ts-variable: var(--light-color-ts-variable); + --color-ts-function: var(--light-color-ts-function); + --color-ts-class: var(--light-color-ts-class); + --color-ts-interface: var(--light-color-ts-interface); + --color-ts-constructor: var(--light-color-ts-constructor); + --color-ts-property: var(--light-color-ts-property); + --color-ts-method: var(--light-color-ts-method); + --color-ts-reference: var(--light-color-ts-reference); + --color-ts-call-signature: var(--light-color-ts-call-signature); + --color-ts-index-signature: var(--light-color-ts-index-signature); + --color-ts-constructor-signature: var( + --light-color-ts-constructor-signature + ); + --color-ts-parameter: var(--light-color-ts-parameter); + --color-ts-type-parameter: var(--light-color-ts-type-parameter); + --color-ts-accessor: var(--light-color-ts-accessor); + --color-ts-get-signature: var(--light-color-ts-get-signature); + --color-ts-set-signature: var(--light-color-ts-set-signature); + --color-ts-type-alias: var(--light-color-ts-type-alias); + --color-document: var(--light-color-document); + + --color-alert-note: var(--light-color-alert-note); + --color-alert-tip: var(--light-color-alert-tip); + --color-alert-important: var(--light-color-alert-important); + --color-alert-warning: var(--light-color-alert-warning); + --color-alert-caution: var(--light-color-alert-caution); + + --external-icon: var(--light-external-icon); + --color-scheme: var(--light-color-scheme); + } + } + + @media (prefers-color-scheme: dark) { + :root { + --color-background: var(--dark-color-background); + --color-background-secondary: var( + --dark-color-background-secondary + ); + --color-background-active: var(--dark-color-background-active); + --color-background-warning: var(--dark-color-background-warning); + --color-warning-text: var(--dark-color-warning-text); + --color-accent: var(--dark-color-accent); + --color-active-menu-item: var(--dark-color-active-menu-item); + --color-text: var(--dark-color-text); + --color-contrast-text: var(--dark-color-contrast-text); + --color-text-aside: var(--dark-color-text-aside); + + --color-icon-background: var(--dark-color-icon-background); + --color-icon-text: var(--dark-color-icon-text); + + --color-comment-tag-text: var(--dark-color-text); + --color-comment-tag: var(--dark-color-background); + + --color-link: var(--dark-color-link); + --color-focus-outline: var(--dark-color-focus-outline); + + --color-ts-keyword: var(--dark-color-ts-keyword); + --color-ts-project: var(--dark-color-ts-project); + --color-ts-module: var(--dark-color-ts-module); + --color-ts-namespace: var(--dark-color-ts-namespace); + --color-ts-enum: var(--dark-color-ts-enum); + --color-ts-enum-member: var(--dark-color-ts-enum-member); + --color-ts-variable: var(--dark-color-ts-variable); + --color-ts-function: var(--dark-color-ts-function); + --color-ts-class: var(--dark-color-ts-class); + --color-ts-interface: var(--dark-color-ts-interface); + --color-ts-constructor: var(--dark-color-ts-constructor); + --color-ts-property: var(--dark-color-ts-property); + --color-ts-method: var(--dark-color-ts-method); + --color-ts-reference: var(--dark-color-ts-reference); + --color-ts-call-signature: var(--dark-color-ts-call-signature); + --color-ts-index-signature: var(--dark-color-ts-index-signature); + --color-ts-constructor-signature: var( + --dark-color-ts-constructor-signature + ); + --color-ts-parameter: var(--dark-color-ts-parameter); + --color-ts-type-parameter: var(--dark-color-ts-type-parameter); + --color-ts-accessor: var(--dark-color-ts-accessor); + --color-ts-get-signature: var(--dark-color-ts-get-signature); + --color-ts-set-signature: var(--dark-color-ts-set-signature); + --color-ts-type-alias: var(--dark-color-ts-type-alias); + --color-document: var(--dark-color-document); + + --color-alert-note: var(--dark-color-alert-note); + --color-alert-tip: var(--dark-color-alert-tip); + --color-alert-important: var(--dark-color-alert-important); + --color-alert-warning: var(--dark-color-alert-warning); + --color-alert-caution: var(--dark-color-alert-caution); + + --external-icon: var(--dark-external-icon); + --color-scheme: var(--dark-color-scheme); + } + } + + :root[data-theme="light"] { + --color-background: var(--light-color-background); + --color-background-secondary: var(--light-color-background-secondary); + --color-background-active: var(--light-color-background-active); + --color-background-warning: var(--light-color-background-warning); + --color-warning-text: var(--light-color-warning-text); + --color-icon-background: var(--light-color-icon-background); + --color-accent: var(--light-color-accent); + --color-active-menu-item: var(--light-color-active-menu-item); + --color-text: var(--light-color-text); + --color-contrast-text: var(--light-color-contrast-text); + --color-text-aside: var(--light-color-text-aside); + --color-icon-text: var(--light-color-icon-text); + + --color-comment-tag-text: var(--light-color-text); + --color-comment-tag: var(--light-color-background); + + --color-link: var(--light-color-link); + --color-focus-outline: var(--light-color-focus-outline); + + --color-ts-keyword: var(--light-color-ts-keyword); + --color-ts-project: var(--light-color-ts-project); + --color-ts-module: var(--light-color-ts-module); + --color-ts-namespace: var(--light-color-ts-namespace); + --color-ts-enum: var(--light-color-ts-enum); + --color-ts-enum-member: var(--light-color-ts-enum-member); + --color-ts-variable: var(--light-color-ts-variable); + --color-ts-function: var(--light-color-ts-function); + --color-ts-class: var(--light-color-ts-class); + --color-ts-interface: var(--light-color-ts-interface); + --color-ts-constructor: var(--light-color-ts-constructor); + --color-ts-property: var(--light-color-ts-property); + --color-ts-method: var(--light-color-ts-method); + --color-ts-reference: var(--light-color-ts-reference); + --color-ts-call-signature: var(--light-color-ts-call-signature); + --color-ts-index-signature: var(--light-color-ts-index-signature); + --color-ts-constructor-signature: var( + --light-color-ts-constructor-signature + ); + --color-ts-parameter: var(--light-color-ts-parameter); + --color-ts-type-parameter: var(--light-color-ts-type-parameter); + --color-ts-accessor: var(--light-color-ts-accessor); + --color-ts-get-signature: var(--light-color-ts-get-signature); + --color-ts-set-signature: var(--light-color-ts-set-signature); + --color-ts-type-alias: var(--light-color-ts-type-alias); + --color-document: var(--light-color-document); + + --color-note: var(--light-color-note); + --color-tip: var(--light-color-tip); + --color-important: var(--light-color-important); + --color-warning: var(--light-color-warning); + --color-caution: var(--light-color-caution); + + --external-icon: var(--light-external-icon); + --color-scheme: var(--light-color-scheme); + } + + :root[data-theme="dark"] { + --color-background: var(--dark-color-background); + --color-background-secondary: var(--dark-color-background-secondary); + --color-background-active: var(--dark-color-background-active); + --color-background-warning: var(--dark-color-background-warning); + --color-warning-text: var(--dark-color-warning-text); + --color-icon-background: var(--dark-color-icon-background); + --color-accent: var(--dark-color-accent); + --color-active-menu-item: var(--dark-color-active-menu-item); + --color-text: var(--dark-color-text); + --color-contrast-text: var(--dark-color-contrast-text); + --color-text-aside: var(--dark-color-text-aside); + --color-icon-text: var(--dark-color-icon-text); + + --color-comment-tag-text: var(--dark-color-text); + --color-comment-tag: var(--dark-color-background); + + --color-link: var(--dark-color-link); + --color-focus-outline: var(--dark-color-focus-outline); + + --color-ts-keyword: var(--dark-color-ts-keyword); + --color-ts-project: var(--dark-color-ts-project); + --color-ts-module: var(--dark-color-ts-module); + --color-ts-namespace: var(--dark-color-ts-namespace); + --color-ts-enum: var(--dark-color-ts-enum); + --color-ts-enum-member: var(--dark-color-ts-enum-member); + --color-ts-variable: var(--dark-color-ts-variable); + --color-ts-function: var(--dark-color-ts-function); + --color-ts-class: var(--dark-color-ts-class); + --color-ts-interface: var(--dark-color-ts-interface); + --color-ts-constructor: var(--dark-color-ts-constructor); + --color-ts-property: var(--dark-color-ts-property); + --color-ts-method: var(--dark-color-ts-method); + --color-ts-reference: var(--dark-color-ts-reference); + --color-ts-call-signature: var(--dark-color-ts-call-signature); + --color-ts-index-signature: var(--dark-color-ts-index-signature); + --color-ts-constructor-signature: var( + --dark-color-ts-constructor-signature + ); + --color-ts-parameter: var(--dark-color-ts-parameter); + --color-ts-type-parameter: var(--dark-color-ts-type-parameter); + --color-ts-accessor: var(--dark-color-ts-accessor); + --color-ts-get-signature: var(--dark-color-ts-get-signature); + --color-ts-set-signature: var(--dark-color-ts-set-signature); + --color-ts-type-alias: var(--dark-color-ts-type-alias); + --color-document: var(--dark-color-document); + + --color-note: var(--dark-color-note); + --color-tip: var(--dark-color-tip); + --color-important: var(--dark-color-important); + --color-warning: var(--dark-color-warning); + --color-caution: var(--dark-color-caution); + + --external-icon: var(--dark-external-icon); + --color-scheme: var(--dark-color-scheme); + } + + html { + color-scheme: var(--color-scheme); + @media (prefers-reduced-motion: no-preference) { + scroll-behavior: smooth; + } + } + + *:focus-visible, + .tsd-accordion-summary:focus-visible svg { + outline: 2px solid var(--color-focus-outline); + } + + .always-visible, + .always-visible .tsd-signatures { + display: inherit !important; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.2; + } + + h1 { + font-size: 1.875rem; + margin: 0.67rem 0; + } + + h2 { + font-size: 1.5rem; + margin: 0.83rem 0; + } + + h3 { + font-size: 1.25rem; + margin: 1rem 0; + } + + h4 { + font-size: 1.05rem; + margin: 1.33rem 0; + } + + h5 { + font-size: 1rem; + margin: 1.5rem 0; + } + + h6 { + font-size: 0.875rem; + margin: 2.33rem 0; + } + + dl, + menu, + ol, + ul { + margin: 1em 0; + } + + dd { + margin: 0 0 0 34px; + } + + .container { + max-width: 1700px; + padding: 0 2rem; + } + + /* Footer */ + footer { + border-top: 1px solid var(--color-accent); + padding-top: 1rem; + padding-bottom: 1rem; + max-height: var(--dim-footer-height); + } + footer > p { + margin: 0 1em; + } + + .container-main { + margin: var(--dim-container-main-margin-y) auto; + /* toolbar, footer, margin */ + min-height: calc( + 100svh - var(--dim-header-height) - var(--dim-footer-height) - + 2 * var(--dim-container-main-margin-y) + ); + } + + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes fade-out { + from { + opacity: 1; + visibility: visible; + } + to { + opacity: 0; + } + } + @keyframes pop-in-from-right { + from { + transform: translate(100%, 0); + } + to { + transform: translate(0, 0); + } + } + @keyframes pop-out-to-right { + from { + transform: translate(0, 0); + visibility: visible; + } + to { + transform: translate(100%, 0); + } + } + body { + background: var(--color-background); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", + Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-size: 16px; + color: var(--color-text); + margin: 0; + } + + a { + color: var(--color-link); + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + a.external[target="_blank"] { + background-image: var(--external-icon); + background-position: top 3px right; + background-repeat: no-repeat; + padding-right: 13px; + } + a.tsd-anchor-link { + color: var(--color-text); + } + :target { + scroll-margin-block: calc(var(--dim-header-height) + 0.5rem); + } + + code, + pre { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + padding: 0.2em; + margin: 0; + font-size: 0.875rem; + border-radius: 0.8em; + } + + pre { + position: relative; + white-space: pre-wrap; + word-wrap: break-word; + padding: 10px; + border: 1px solid var(--color-accent); + margin-bottom: 8px; + } + pre code { + padding: 0; + font-size: 100%; + } + pre > button { + position: absolute; + top: 10px; + right: 10px; + opacity: 0; + transition: opacity 0.1s; + box-sizing: border-box; + } + pre:hover > button, + pre > button.visible, + pre > button:focus-visible { + opacity: 1; + } + + blockquote { + margin: 1em 0; + padding-left: 1em; + border-left: 4px solid gray; + } + + img { + max-width: 100%; + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--color-accent) var(--color-icon-background); + } + + *::-webkit-scrollbar { + width: 0.75rem; + } + + *::-webkit-scrollbar-track { + background: var(--color-icon-background); + } + + *::-webkit-scrollbar-thumb { + background-color: var(--color-accent); + border-radius: 999rem; + border: 0.25rem solid var(--color-icon-background); + } + + dialog { + border: none; + outline: none; + padding: 0; + background-color: var(--color-background); + } + dialog::backdrop { + display: none; + } + #tsd-overlay { + background-color: rgba(0, 0, 0, 0.5); + position: fixed; + z-index: 9999; + top: 0; + left: 0; + right: 0; + bottom: 0; + animation: fade-in var(--modal-animation-duration) forwards; + } + #tsd-overlay.closing { + animation-name: fade-out; + } + + .tsd-typography { + line-height: 1.333em; + } + .tsd-typography ul { + list-style: square; + padding: 0 0 0 20px; + margin: 0; + } + .tsd-typography .tsd-index-panel h3, + .tsd-index-panel .tsd-typography h3, + .tsd-typography h4, + .tsd-typography h5, + .tsd-typography h6 { + font-size: 1em; + } + .tsd-typography h5, + .tsd-typography h6 { + font-weight: normal; + } + .tsd-typography p, + .tsd-typography ul, + .tsd-typography ol { + margin: 1em 0; + } + .tsd-typography table { + border-collapse: collapse; + border: none; + } + .tsd-typography td, + .tsd-typography th { + padding: 6px 13px; + border: 1px solid var(--color-accent); + } + .tsd-typography thead, + .tsd-typography tr:nth-child(even) { + background-color: var(--color-background-secondary); + } + + .tsd-alert { + padding: 8px 16px; + margin-bottom: 16px; + border-left: 0.25em solid var(--alert-color); + } + .tsd-alert blockquote > :last-child, + .tsd-alert > :last-child { + margin-bottom: 0; + } + .tsd-alert-title { + color: var(--alert-color); + display: inline-flex; + align-items: center; + } + .tsd-alert-title span { + margin-left: 4px; + } + + .tsd-alert-note { + --alert-color: var(--color-alert-note); + } + .tsd-alert-tip { + --alert-color: var(--color-alert-tip); + } + .tsd-alert-important { + --alert-color: var(--color-alert-important); + } + .tsd-alert-warning { + --alert-color: var(--color-alert-warning); + } + .tsd-alert-caution { + --alert-color: var(--color-alert-caution); + } + + .tsd-breadcrumb { + margin: 0; + margin-top: 1rem; + padding: 0; + color: var(--color-text-aside); + } + .tsd-breadcrumb a { + color: var(--color-text-aside); + text-decoration: none; + } + .tsd-breadcrumb a:hover { + text-decoration: underline; + } + .tsd-breadcrumb li { + display: inline; + } + .tsd-breadcrumb li:after { + content: " / "; + } + + .tsd-comment-tags { + display: flex; + flex-direction: column; + } + dl.tsd-comment-tag-group { + display: flex; + align-items: center; + overflow: hidden; + margin: 0.5em 0; + } + dl.tsd-comment-tag-group dt { + display: flex; + margin-right: 0.5em; + font-size: 0.875em; + font-weight: normal; + } + dl.tsd-comment-tag-group dd { + margin: 0; + } + code.tsd-tag { + padding: 0.25em 0.4em; + border: 0.1em solid var(--color-accent); + margin-right: 0.25em; + font-size: 70%; + } + h1 code.tsd-tag:first-of-type { + margin-left: 0.25em; + } + + dl.tsd-comment-tag-group dd:before, + dl.tsd-comment-tag-group dd:after { + content: " "; + } + dl.tsd-comment-tag-group dd pre, + dl.tsd-comment-tag-group dd:after { + clear: both; + } + dl.tsd-comment-tag-group p { + margin: 0; + } + + .tsd-panel.tsd-comment .lead { + font-size: 1.1em; + line-height: 1.333em; + margin-bottom: 2em; + } + .tsd-panel.tsd-comment .lead:last-child { + margin-bottom: 0; + } + + .tsd-filter-visibility h4 { + font-size: 1rem; + padding-top: 0.75rem; + padding-bottom: 0.5rem; + margin: 0; + } + .tsd-filter-item:not(:last-child) { + margin-bottom: 0.5rem; + } + .tsd-filter-input { + display: flex; + width: -moz-fit-content; + width: fit-content; + align-items: center; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; + } + .tsd-filter-input input[type="checkbox"] { + cursor: pointer; + position: absolute; + width: 1.5em; + height: 1.5em; + opacity: 0; + } + .tsd-filter-input input[type="checkbox"]:disabled { + pointer-events: none; + } + .tsd-filter-input svg { + cursor: pointer; + width: 1.5em; + height: 1.5em; + margin-right: 0.5em; + border-radius: 0.33em; + /* Leaving this at full opacity breaks event listeners on Firefox. + Don't remove unless you know what you're doing. */ + opacity: 0.99; + } + .tsd-filter-input input[type="checkbox"]:focus-visible + svg { + outline: 2px solid var(--color-focus-outline); + } + .tsd-checkbox-background { + fill: var(--color-accent); + } + input[type="checkbox"]:checked ~ svg .tsd-checkbox-checkmark { + stroke: var(--color-text); + } + .tsd-filter-input input:disabled ~ svg > .tsd-checkbox-background { + fill: var(--color-background); + stroke: var(--color-accent); + stroke-width: 0.25rem; + } + .tsd-filter-input input:disabled ~ svg > .tsd-checkbox-checkmark { + stroke: var(--color-accent); + } + + .settings-label { + font-weight: bold; + text-transform: uppercase; + display: inline-block; + } + + .tsd-filter-visibility .settings-label { + margin: 0.75rem 0 0.5rem 0; + } + + .tsd-theme-toggle .settings-label { + margin: 0.75rem 0.75rem 0 0; + } + + .tsd-hierarchy h4 label:hover span { + text-decoration: underline; + } + + .tsd-hierarchy { + list-style: square; + margin: 0; + } + .tsd-hierarchy-target { + font-weight: bold; + } + .tsd-hierarchy-toggle { + color: var(--color-link); + cursor: pointer; + } + + .tsd-full-hierarchy:not(:last-child) { + margin-bottom: 1em; + padding-bottom: 1em; + border-bottom: 1px solid var(--color-accent); + } + .tsd-full-hierarchy, + .tsd-full-hierarchy ul { + list-style: none; + margin: 0; + padding: 0; + } + .tsd-full-hierarchy ul { + padding-left: 1.5rem; + } + .tsd-full-hierarchy a { + padding: 0.25rem 0 !important; + font-size: 1rem; + display: inline-flex; + align-items: center; + color: var(--color-text); + } + .tsd-full-hierarchy svg[data-dropdown] { + cursor: pointer; + } + .tsd-full-hierarchy svg[data-dropdown="false"] { + transform: rotate(-90deg); + } + .tsd-full-hierarchy svg[data-dropdown="false"] ~ ul { + display: none; + } + + .tsd-panel-group.tsd-index-group { + margin-bottom: 0; + } + .tsd-index-panel .tsd-index-list { + list-style: none; + line-height: 1.333em; + margin: 0; + padding: 0.25rem 0 0 0; + overflow: hidden; + display: grid; + grid-template-columns: repeat(3, 1fr); + column-gap: 1rem; + grid-template-rows: auto; + } + @media (max-width: 1024px) { + .tsd-index-panel .tsd-index-list { + grid-template-columns: repeat(2, 1fr); + } + } + @media (max-width: 768px) { + .tsd-index-panel .tsd-index-list { + grid-template-columns: repeat(1, 1fr); + } + } + .tsd-index-panel .tsd-index-list li { + -webkit-page-break-inside: avoid; + -moz-page-break-inside: avoid; + -ms-page-break-inside: avoid; + -o-page-break-inside: avoid; + page-break-inside: avoid; + } + + .tsd-flag { + display: inline-block; + padding: 0.25em 0.4em; + border-radius: 4px; + color: var(--color-comment-tag-text); + background-color: var(--color-comment-tag); + text-indent: 0; + font-size: 75%; + line-height: 1; + font-weight: normal; + } + + .tsd-anchor { + position: relative; + top: -100px; + } + + .tsd-member { + position: relative; + } + .tsd-member .tsd-anchor + h3 { + display: flex; + align-items: center; + margin-top: 0; + margin-bottom: 0; + border-bottom: none; + } + + .tsd-navigation.settings { + margin: 0; + margin-bottom: 1rem; + } + .tsd-navigation > a, + .tsd-navigation .tsd-accordion-summary { + width: calc(100% - 0.25rem); + display: flex; + align-items: center; + } + .tsd-navigation a, + .tsd-navigation summary > span, + .tsd-page-navigation a { + display: flex; + width: calc(100% - 0.25rem); + align-items: center; + padding: 0.25rem; + color: var(--color-text); + text-decoration: none; + box-sizing: border-box; + } + .tsd-navigation a.current, + .tsd-page-navigation a.current { + background: var(--color-active-menu-item); + color: var(--color-contrast-text); + } + .tsd-navigation a:hover, + .tsd-page-navigation a:hover { + text-decoration: underline; + } + .tsd-navigation ul, + .tsd-page-navigation ul { + margin-top: 0; + margin-bottom: 0; + padding: 0; + list-style: none; + } + .tsd-navigation li, + .tsd-page-navigation li { + padding: 0; + max-width: 100%; + } + .tsd-navigation .tsd-nav-link { + display: none; + } + .tsd-nested-navigation { + margin-left: 3rem; + } + .tsd-nested-navigation > li > details { + margin-left: -1.5rem; + } + .tsd-small-nested-navigation { + margin-left: 1.5rem; + } + .tsd-small-nested-navigation > li > details { + margin-left: -1.5rem; + } + + .tsd-page-navigation-section > summary { + padding: 0.25rem; + } + .tsd-page-navigation-section > summary > svg { + margin-right: 0.25rem; + } + .tsd-page-navigation-section > div { + margin-left: 30px; + } + .tsd-page-navigation ul { + padding-left: 1.75rem; + } + + #tsd-sidebar-links a { + margin-top: 0; + margin-bottom: 0.5rem; + line-height: 1.25rem; + } + #tsd-sidebar-links a:last-of-type { + margin-bottom: 0; + } + + a.tsd-index-link { + padding: 0.25rem 0 !important; + font-size: 1rem; + line-height: 1.25rem; + display: inline-flex; + align-items: center; + color: var(--color-text); + } + .tsd-accordion-summary { + list-style-type: none; /* hide marker on non-safari */ + outline: none; /* broken on safari, so just hide it */ + display: flex; + align-items: center; + gap: 0.25rem; + box-sizing: border-box; + } + .tsd-accordion-summary::-webkit-details-marker { + display: none; /* hide marker on safari */ + } + .tsd-accordion-summary, + .tsd-accordion-summary a { + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + + cursor: pointer; + } + .tsd-accordion-summary a { + width: calc(100% - 1.5rem); + } + .tsd-accordion-summary > * { + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + } + /* + * We need to be careful to target the arrow indicating whether the accordion + * is open, but not any other SVGs included in the details element. + */ + .tsd-accordion:not([open]) > .tsd-accordion-summary > svg:first-child { + transform: rotate(-90deg); + } + .tsd-index-content > :not(:first-child) { + margin-top: 0.75rem; + } + .tsd-index-summary { + margin-top: 1.5rem; + margin-bottom: 0.75rem; + display: flex; + align-content: center; + } + + .tsd-no-select { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + .tsd-kind-icon { + margin-right: 0.5rem; + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + } + .tsd-signature > .tsd-kind-icon { + margin-right: 0.8rem; + } + + .tsd-panel { + margin-bottom: 2.5rem; + } + .tsd-panel.tsd-member { + margin-bottom: 4rem; + } + .tsd-panel:empty { + display: none; + } + .tsd-panel > h1, + .tsd-panel > h2, + .tsd-panel > h3 { + margin: 1.5rem -1.5rem 0.75rem -1.5rem; + padding: 0 1.5rem 0.75rem 1.5rem; + } + .tsd-panel > h1.tsd-before-signature, + .tsd-panel > h2.tsd-before-signature, + .tsd-panel > h3.tsd-before-signature { + margin-bottom: 0; + border-bottom: none; + } + + .tsd-panel-group { + margin: 2rem 0; + } + .tsd-panel-group.tsd-index-group { + margin: 2rem 0; + } + .tsd-panel-group.tsd-index-group details { + margin: 2rem 0; + } + .tsd-panel-group > .tsd-accordion-summary { + margin-bottom: 1rem; + } + + #tsd-search[open] { + animation: fade-in var(--modal-animation-duration) ease-out forwards; + } + #tsd-search[open].closing { + animation-name: fade-out; + } + + /* Avoid setting `display` on closed dialog */ + #tsd-search[open] { + display: flex; + flex-direction: column; + padding: 1rem; + width: 32rem; + max-width: 90vw; + max-height: calc(100vh - env(keyboard-inset-height, 0px) - 25vh); + /* Anchor dialog to top */ + margin-top: 10vh; + border-radius: 6px; + will-change: max-height; + } + #tsd-search-input { + box-sizing: border-box; + width: 100%; + padding: 0 0.625rem; /* 10px */ + outline: 0; + border: 2px solid var(--color-accent); + background-color: transparent; + color: var(--color-text); + border-radius: 4px; + height: 2.5rem; + flex: 0 0 auto; + font-size: 0.875rem; + transition: border-color 0.2s, background-color 0.2s; + } + #tsd-search-input:focus-visible { + background-color: var(--color-background-active); + border-color: transparent; + color: var(--color-contrast-text); + } + #tsd-search-input::placeholder { + color: inherit; + opacity: 0.8; + } + #tsd-search-results { + margin: 0; + padding: 0; + list-style: none; + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow-y: auto; + } + #tsd-search-results:not(:empty) { + margin-top: 0.5rem; + } + #tsd-search-results > li { + background-color: var(--color-background); + line-height: 1.5; + box-sizing: border-box; + border-radius: 4px; + } + #tsd-search-results > li:nth-child(even) { + background-color: var(--color-background-secondary); + } + #tsd-search-results > li:is(:hover, [aria-selected="true"]) { + background-color: var(--color-background-active); + color: var(--color-contrast-text); + } + /* It's important that this takes full size of parent `li`, to capture a click on `li` */ + #tsd-search-results > li > a { + display: flex; + align-items: center; + padding: 0.5rem 0.25rem; + box-sizing: border-box; + width: 100%; + } + #tsd-search-results > li > a > .text { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; + } + #tsd-search-results > li > a .parent { + color: var(--color-text-aside); + } + #tsd-search-results > li > a mark { + color: inherit; + background-color: inherit; + font-weight: bold; + } + #tsd-search-status { + flex: 1; + display: grid; + place-content: center; + text-align: center; + overflow-wrap: anywhere; + } + #tsd-search-status:not(:empty) { + min-height: 6rem; + } + + .tsd-signature { + margin: 0 0 1rem 0; + padding: 1rem 0.5rem; + border: 1px solid var(--color-accent); + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + font-size: 14px; + overflow-x: auto; + } + + .tsd-signature-keyword { + color: var(--color-ts-keyword); + font-weight: normal; + } + + .tsd-signature-symbol { + color: var(--color-text-aside); + font-weight: normal; + } + + .tsd-signature-type { + font-style: italic; + font-weight: normal; + } + + .tsd-signatures { + padding: 0; + margin: 0 0 1em 0; + list-style-type: none; + } + .tsd-signatures .tsd-signature { + margin: 0; + border-color: var(--color-accent); + border-width: 1px 0; + transition: background-color 0.1s; + } + .tsd-signatures .tsd-index-signature:not(:last-child) { + margin-bottom: 1em; + } + .tsd-signatures .tsd-index-signature .tsd-signature { + border-width: 1px; + } + .tsd-description .tsd-signatures .tsd-signature { + border-width: 1px; + } + + ul.tsd-parameter-list, + ul.tsd-type-parameter-list { + list-style: square; + margin: 0; + padding-left: 20px; + } + ul.tsd-parameter-list > li.tsd-parameter-signature, + ul.tsd-type-parameter-list > li.tsd-parameter-signature { + list-style: none; + margin-left: -20px; + } + ul.tsd-parameter-list h5, + ul.tsd-type-parameter-list h5 { + font-size: 16px; + margin: 1em 0 0.5em 0; + } + .tsd-sources { + margin-top: 1rem; + font-size: 0.875em; + } + .tsd-sources a { + color: var(--color-text-aside); + text-decoration: underline; + } + .tsd-sources ul { + list-style: none; + padding: 0; + } + + .tsd-page-toolbar { + position: sticky; + z-index: 1; + top: 0; + left: 0; + width: 100%; + color: var(--color-text); + background: var(--color-background-secondary); + border-bottom: var(--dim-toolbar-border-bottom-width) + var(--color-accent) solid; + transition: transform 0.3s ease-in-out; + } + .tsd-page-toolbar a { + color: var(--color-text); + } + .tsd-toolbar-contents { + display: flex; + align-items: center; + height: var(--dim-toolbar-contents-height); + margin: 0 auto; + } + .tsd-toolbar-contents > .title { + font-weight: bold; + margin-right: auto; + } + #tsd-toolbar-links { + display: flex; + align-items: center; + gap: 1.5rem; + margin-right: 1rem; + } + + .tsd-widget { + box-sizing: border-box; + display: inline-block; + opacity: 0.8; + height: 2.5rem; + width: 2.5rem; + transition: opacity 0.1s, background-color 0.1s; + text-align: center; + cursor: pointer; + border: none; + background-color: transparent; + } + .tsd-widget:hover { + opacity: 0.9; + } + .tsd-widget:active { + opacity: 1; + background-color: var(--color-accent); + } + #tsd-toolbar-menu-trigger { + display: none; + } + + .tsd-member-summary-name { + display: inline-flex; + align-items: center; + padding: 0.25rem; + text-decoration: none; + } + + .tsd-anchor-icon { + display: inline-flex; + align-items: center; + margin-left: 0.5rem; + color: var(--color-text); + vertical-align: middle; + } + + .tsd-anchor-icon svg { + width: 1em; + height: 1em; + visibility: hidden; + } + + .tsd-member-summary-name:hover > .tsd-anchor-icon svg, + .tsd-anchor-link:hover > .tsd-anchor-icon svg, + .tsd-anchor-icon:focus-visible svg { + visibility: visible; + } + + .deprecated { + text-decoration: line-through !important; + } + + .warning { + padding: 1rem; + color: var(--color-warning-text); + background: var(--color-background-warning); + } + + .tsd-kind-project { + color: var(--color-ts-project); + } + .tsd-kind-module { + color: var(--color-ts-module); + } + .tsd-kind-namespace { + color: var(--color-ts-namespace); + } + .tsd-kind-enum { + color: var(--color-ts-enum); + } + .tsd-kind-enum-member { + color: var(--color-ts-enum-member); + } + .tsd-kind-variable { + color: var(--color-ts-variable); + } + .tsd-kind-function { + color: var(--color-ts-function); + } + .tsd-kind-class { + color: var(--color-ts-class); + } + .tsd-kind-interface { + color: var(--color-ts-interface); + } + .tsd-kind-constructor { + color: var(--color-ts-constructor); + } + .tsd-kind-property { + color: var(--color-ts-property); + } + .tsd-kind-method { + color: var(--color-ts-method); + } + .tsd-kind-reference { + color: var(--color-ts-reference); + } + .tsd-kind-call-signature { + color: var(--color-ts-call-signature); + } + .tsd-kind-index-signature { + color: var(--color-ts-index-signature); + } + .tsd-kind-constructor-signature { + color: var(--color-ts-constructor-signature); + } + .tsd-kind-parameter { + color: var(--color-ts-parameter); + } + .tsd-kind-type-parameter { + color: var(--color-ts-type-parameter); + } + .tsd-kind-accessor { + color: var(--color-ts-accessor); + } + .tsd-kind-get-signature { + color: var(--color-ts-get-signature); + } + .tsd-kind-set-signature { + color: var(--color-ts-set-signature); + } + .tsd-kind-type-alias { + color: var(--color-ts-type-alias); + } + + /* if we have a kind icon, don't color the text by kind */ + .tsd-kind-icon ~ span { + color: var(--color-text); + } + + /* mobile */ + @media (max-width: 769px) { + #tsd-toolbar-menu-trigger { + display: inline-block; + /* temporary fix to vertically align, for compatibility */ + line-height: 2.5; + } + #tsd-toolbar-links { + display: none; + } + + .container-main { + display: flex; + } + .col-content { + float: none; + max-width: 100%; + width: 100%; + } + .col-sidebar { + position: fixed !important; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + z-index: 1024; + top: 0 !important; + bottom: 0 !important; + left: auto !important; + right: 0 !important; + padding: 1.5rem 1.5rem 0 0; + width: 75vw; + visibility: hidden; + background-color: var(--color-background); + transform: translate(100%, 0); + } + .col-sidebar > *:last-child { + padding-bottom: 20px; + } + .overlay { + content: ""; + display: block; + position: fixed; + z-index: 1023; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.75); + visibility: hidden; + } + + .to-has-menu .overlay { + animation: fade-in 0.4s; + } + + .to-has-menu .col-sidebar { + animation: pop-in-from-right 0.4s; + } + + .from-has-menu .overlay { + animation: fade-out 0.4s; + } + + .from-has-menu .col-sidebar { + animation: pop-out-to-right 0.4s; + } + + .has-menu body { + overflow: hidden; + } + .has-menu .overlay { + visibility: visible; + } + .has-menu .col-sidebar { + visibility: visible; + transform: translate(0, 0); + display: flex; + flex-direction: column; + gap: 1.5rem; + max-height: 100vh; + padding: 1rem 2rem; + } + .has-menu .tsd-navigation { + max-height: 100%; + } + .tsd-navigation .tsd-nav-link { + display: flex; + } + } + + /* one sidebar */ + @media (min-width: 770px) { + .container-main { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + grid-template-areas: "sidebar content"; + --dim-container-main-margin-y: 2rem; + } + + .tsd-breadcrumb { + margin-top: 0; + } + + .col-sidebar { + grid-area: sidebar; + } + .col-content { + grid-area: content; + padding: 0 1rem; + } + } + @media (min-width: 770px) and (max-width: 1399px) { + .col-sidebar { + max-height: calc( + 100vh - var(--dim-header-height) - var(--dim-footer-height) - + 2 * var(--dim-container-main-margin-y) + ); + overflow: auto; + position: sticky; + top: calc( + var(--dim-header-height) + var(--dim-container-main-margin-y) + ); + } + .site-menu { + margin-top: 1rem; + } + } + + /* two sidebars */ + @media (min-width: 1200px) { + .container-main { + grid-template-columns: + minmax(0, 1fr) minmax(0, 2.5fr) minmax( + 0, + 20rem + ); + grid-template-areas: "sidebar content toc"; + } + + .col-sidebar { + display: contents; + } + + .page-menu { + grid-area: toc; + padding-left: 1rem; + } + .site-menu { + grid-area: sidebar; + } + + .site-menu { + margin-top: 0rem; + } + + .page-menu, + .site-menu { + max-height: calc( + 100vh - var(--dim-header-height) - var(--dim-footer-height) - + 2 * var(--dim-container-main-margin-y) + ); + overflow: auto; + position: sticky; + top: calc( + var(--dim-header-height) + var(--dim-container-main-margin-y) + ); + } + } +} diff --git a/docs/ng-vitest/classes/AngularContext.html b/docs/ng-vitest/classes/AngularContext.html new file mode 100644 index 00000000..934621ef --- /dev/null +++ b/docs/ng-vitest/classes/AngularContext.html @@ -0,0 +1,54 @@ +AngularContext | @s-libs/ng-vitest
@s-libs/ng-vitest
    Preparing search index...

    Class AngularContext

    Provides the foundation for an opinionated testing pattern.

    +
      +
    • All tests are run with vi.useFakeTimers. This gives you full control over the timing of everything by default.
    • +
    • Variables that are initialized for each test exist in a context that is thrown away, so they cannot leak between tests.
    • +
    • Clearly separates initialization code from the test itself.
    • +
    • Gives control over the simulated date and time with a single line of code.
    • +
    • Automatically includes provideHttpClientTesting() to stub network requests without additional setup.
    • +
    • Always verifies that no unexpected http requests were made.
    • +
    • Always verifies that no unmatched errors were thrown (using MockErrorHandler).
    • +
    • Disables Material animations so that you don't need to wait for them in your tests.
    • +
    +

    This example tests a simple service that uses HttpClient and is tested by using AngularContext directly. More often, AngularContext will be used as a super class. See ComponentContext for more common use cases.

    +
    // This is the class we will test.
    @Injectable({ providedIn: 'root' })
    class MemoriesService {
    #httpClient = inject(HttpClient);

    getLastYearToday(): Observable<any> {
    const datetime = new Date();
    datetime.setFullYear(datetime.getFullYear() - 1);
    const date = datetime.toISOString().split('T')[0];
    return this.#httpClient.get(`http://example.com/post-from/${date}`);
    }
    }

    describe('MemoriesService', () => {
    // Tests should have exactly 1 variable outside an "it": `ctx`.
    let ctx: AngularContext;
    beforeEach(() => {
    ctx = new AngularContext({ providers: [provideHttpClient()] });
    });

    it('requests a post from 1 year ago', async () => {
    // Before calling `run`, set up any context variables this test needs.
    ctx.startTime = new Date('2004-02-16T10:15:00.000Z');

    // Pass the test itself as a callback to `run()`.
    await ctx.run(() => {
    const httpBackend = ctx.inject(HttpTestingController);
    const myService = ctx.inject(MemoriesService);

    myService.getLastYearToday().subscribe();

    httpBackend.expectOne('http://example.com/post-from/2003-02-16');
    });
    });
    }); +
    + +

    Hierarchy (View Summary)

    Index

    Constructors

    Properties

    startTime: Date = ...

    Set this before calling run() to mock the time at which the test starts.

    +

    Methods

    • This is a hook for subclasses to override. It is called as the last step during run(), even if a previous step errored. This implementation does nothing, but if you override this it is still recommended to call super.cleanUp() in case this implementation does something in the future.

      +

      Returns Promise<void>

    • This is a hook for subclasses to override. It is called during run(), before the test() callback. This implementation does nothing, but if you override this it is still recommended to call super.init() in case this implementation does something in the future.

      +

      Returns Promise<void>

    • Gets a service or other injectable from the root injector.

      +

      This implementation is a simple pass-through to TestBed.inject(), but subclasses may provide their own implementation. It is recommended to use this in your tests instead of using TestBed directly.

      +

      Type Parameters

      • T

      Parameters

      • token: AbstractType<T> | InjectionToken<T> | Type<T>

      Returns T

    • Runs test with fake timers enabled. It can use async/await, but be sure anything you await is already due to execute (e.g. if a timeout is due in 3 seconds, call .tick(3000) before awaiting its result).

      +

      Also runs the following in this order:

      +
        +
      1. this.init()
      2. +
      3. test()
      4. +
      5. this.verifyPostTestConditions()
      6. +
      7. this.cleanUp()
      8. +
      +

      Parameters

      • test: () => void | Promise<void>

      Returns Promise<void>

    diff --git a/docs/ng-vitest/classes/AsyncMethodController.html b/docs/ng-vitest/classes/AsyncMethodController.html new file mode 100644 index 00000000..5ed5f519 --- /dev/null +++ b/docs/ng-vitest/classes/AsyncMethodController.html @@ -0,0 +1,18 @@ +AsyncMethodController | @s-libs/ng-vitest
    @s-libs/ng-vitest
      Preparing search index...

      Class AsyncMethodController<WrappingObject, MethodName>

      Controller to be used in tests, that allows for mocking and flushing any asynchronous method. If you are using an AngularContext, it automatically calls AngularContext#tick after each .flush() and .error() to trigger promise handlers and change detection. This is the normal production behavior of asynchronous browser APIs.

      +

      For example, to mock the browser's paste functionality:

      +
       it('can paste', async () => {
      const { clipboard } = navigator;
      const ctx = new AngularContext();

      // mock the browser API for pasting
      const controller = new AsyncMethodController(clipboard, 'readText');
      await ctx.run(async () => {
      // BEGIN production code that copies to the clipboard
      let pastedText: string;
      clipboard.readText().then((text) => {
      pastedText = text;
      });
      // END production code that copies to the clipboard

      await controller.expectOne([]).flush('mock clipboard contents');

      // BEGIN expect the correct results after a successful copy
      expect(pastedText!).toBe('mock clipboard contents');
      // END expect the correct results after a successful copy
      });
      }); +
      + +

      Type Parameters

      • WrappingObject extends object
      • MethodName extends AsyncMethodKeys<WrappingObject>

      Hierarchy

      Index

      Constructors

      Methods

      Constructors

      Methods

      • Verify that no unmatched calls are outstanding.

        +

        If any calls are outstanding, fail with an error message indicating which calls were not handled.

        +

        Returns void

      diff --git a/docs/ng-vitest/classes/AsyncTestCall.html b/docs/ng-vitest/classes/AsyncTestCall.html new file mode 100644 index 00000000..155164e1 --- /dev/null +++ b/docs/ng-vitest/classes/AsyncTestCall.html @@ -0,0 +1,19 @@ +AsyncTestCall | @s-libs/ng-vitest
      @s-libs/ng-vitest
        Preparing search index...

        Class AsyncTestCall<F>

        A mock method call that was made and is ready to be answered. This interface allows resolving or rejecting the asynchronous call's result.

        +

        Type Parameters

        • F extends Func

        Hierarchy (View Summary)

        Index

        Constructors

        Methods

        diff --git a/docs/ng-vitest/classes/ComponentContext.html b/docs/ng-vitest/classes/ComponentContext.html new file mode 100644 index 00000000..74c3dd8c --- /dev/null +++ b/docs/ng-vitest/classes/ComponentContext.html @@ -0,0 +1,68 @@ +ComponentContext | @s-libs/ng-vitest
        @s-libs/ng-vitest
          Preparing search index...

          Class ComponentContext<T>

          Provides the foundation for an opinionated pattern for component tests.

          +
            +
          • Includes all features from AngularContext
          • +
          • Automatically creates your component at the beginning of run().
          • +
          • Sets up Angular change detection and lifecycle hooks like it would in production. This covers cases you would normally have to trigger manually if you use the standard TestBed.createComponent() directly.
          • +
          • Wraps your component in a parent that you can easily style however you like.
          • +
          • Lets you use component harnesses with Vitest's fake timers, which is normally a challenge.
          • +
          • Causes async APP_INITIALIZERs to complete before instantiating the component. A caveat, they must not include a setTimeout delay, or the test will hang.
          • +
          +

          A very simple example:

          +
          @Component({ template: 'Hello, {{name()}}!' })
          class GreeterComponent {
          readonly name = input.required<string>();
          }

          it('greets you by name', async () => {
          const ctx = new ComponentContext(GreeterComponent);
          await ctx.assignInputs({ name: 'World' });
          await ctx.run(() => {
          expect(ctx.fixture.nativeElement.textContent).toBe('Hello, World!');
          });
          }); +
          + +

          A full example, with routing and a component harness. This is the full code for a tiny Angular app:

          +
           /////////////////
          // app-context.ts

          // To re-use your context setup, make a subclass of ComponentContext to import into any spec
          class AppContext extends ComponentContext<AppComponent> {
          constructor() {
          // Import `appConfig` from `app.config.ts`
          super(AppComponent, appConfig);
          }
          }

          ////////////////////////
          // app.component.spec.ts

          describe('AppComponent', () => {
          let ctx: AppContext;
          beforeEach(() => {
          ctx = new AppContext();
          });

          it('can navigate to the first page', async () => {
          await ctx.run(async () => {
          const app = await ctx.getHarness(AppComponentHarness);
          await app.navigateToFirstPage();
          expect(ctx.fixture.nativeElement.textContent).toContain(
          'First works!',
          );
          });
          });
          });

          ///////////////////////////
          // app.component.harness.ts

          // A simple component harness to demonstrate its integration with component contexts
          class AppComponentHarness extends ComponentHarness {
          static hostSelector = 'app-root';

          #getFirstPageLink = this.locatorFor('a');

          async navigateToFirstPage(): Promise<void> {
          const link = await this.#getFirstPageLink();
          await link.click();
          }
          }

          /////////////////////
          // first.component.ts

          // A minimal component for demonstration purposes
          @Component({ template: '<p>First works!</p>' })
          class FirstComponent {}

          ///////////////////
          // app.component.ts

          // A minimal app component with routing for demonstration purposes
          @Component({
          selector: 'app-root',
          imports: [RouterOutlet, RouterLink],
          template: `
          <a routerLink="/first-page">First Page</a>
          <router-outlet />
          `,
          })
          class AppComponent {}

          ////////////////////////
          // app.routes.ts

          const routes: Routes = [{ path: 'first-page', component: FirstComponent }];

          ////////////////
          // app.config.ts

          const appConfig: ApplicationConfig = { providers: [provideRouter(routes)] }; +
          + +

          Type Parameters

          • T

          Hierarchy (View Summary)

          Index

          Constructors

          Properties

          fixture: ComponentFixture<unknown>

          The ComponentFixture for a synthetic wrapper around your component. Available within the callback to run().

          +
          startTime: Date = ...

          Set this before calling run() to mock the time at which the test starts.

          +

          Methods

          • Assign inputs to your component. Can be called before run() to set the initial inputs, or within run() to update them and trigger all the appropriate change detection and lifecycle hooks.

            +

            Parameters

            • inputs: Inputs<T>

            Returns Promise<void>

          • Assign CSS styles to the div wrapping your component. Can be called before or during run(). Accepts an object with the same structure as the ngStyle directive.

            +
            ctx.assignWrapperStyles({
            width: '400px',
            height: '600px',
            margin: '20px auto',
            border: '1px solid',
            }); +
            + +

            Parameters

            • styles: Record<string, unknown>

            Returns Promise<void>

          • Runs test with fake timers enabled. It can use async/await, but be sure anything you await is already due to execute (e.g. if a timeout is due in 3 seconds, call .tick(3000) before awaiting its result).

            +

            Also runs the following in this order:

            +
              +
            1. this.init()
            2. +
            3. test()
            4. +
            5. this.verifyPostTestConditions()
            6. +
            7. this.cleanUp()
            8. +
            +

            Parameters

            • test: () => void | Promise<void>

            Returns Promise<void>

          diff --git a/docs/ng-vitest/classes/ComponentHarnessSuperclass.html b/docs/ng-vitest/classes/ComponentHarnessSuperclass.html new file mode 100644 index 00000000..8183a482 --- /dev/null +++ b/docs/ng-vitest/classes/ComponentHarnessSuperclass.html @@ -0,0 +1,140 @@ +ComponentHarnessSuperclass | @s-libs/ng-vitest
          @s-libs/ng-vitest
            Preparing search index...

            Class ComponentHarnessSuperclass

            Provides some shorthand utilities that component harnesses may want.

            +

            Hierarchy

            • ContentContainerComponentHarness
              • ComponentHarnessSuperclass
            Index

            Constructors

            • Parameters

              • locatorFactory: LocatorFactory

              Returns ComponentHarnessSuperclass

            Properties

            locatorFactory: LocatorFactory

            Methods

            • Returns the number of matching harnesses for the given query within the current harness's +content.

              +

              Type Parameters

              • T extends ComponentHarness

              Parameters

              • query: HarnessQuery<T>

                The harness query to search for.

                +

              Returns Promise<number>

              The number of matching harnesses for the given query.

              +
            • Gets a LocatorFactory for the document root element. This factory can be used to create +locators for elements that a component creates outside of its own root element. (e.g. by +appending to document.body).

              +

              Returns LocatorFactory

            • Flushes change detection and async tasks in the Angular zone. +In most cases it should not be necessary to call this manually. However, there may be some edge +cases where it is needed to fully flush animation events.

              +

              Returns Promise<void>

            • Gets a list of HarnessLoader for each element matching the given selector under the current +harness's cotnent that searches for harnesses under that element.

              +

              Parameters

              • selector: string

                The selector for elements in the component's content.

                +

              Returns Promise<HarnessLoader[]>

              A list of HarnessLoader for each element matching the given selector.

              +
            • Gets all matching harnesses for the given query within the current harness's content.

              +

              Type Parameters

              • T extends ComponentHarness

              Parameters

              • query: HarnessQuery<T>

                The harness query to search for.

                +

              Returns Promise<T[]>

              The list of harness matching the given query.

              +
            • Searches for all instances of the component corresponding to the given harness type under the document root element, and returns a list ComponentHarness for each instance.

              +

              Type Parameters

              • T extends ComponentHarness

              Parameters

              • predicate: HarnessQuery<T>

              Returns Promise<T[]>

            • Gets a HarnessLoader that searches for harnesses under the first element matching the given +selector within the current harness's content.

              +

              Parameters

              • selector: string

                The selector for an element in the component's content.

                +

              Returns Promise<HarnessLoader>

              A HarnessLoader that searches for harnesses under the given selector.

              +
            • Gets the first matching harness for the given query within the current harness's content.

              +

              Type Parameters

              • T extends ComponentHarness

              Parameters

              • query: HarnessQuery<T>

                The harness query to search for.

                +

              Returns Promise<T>

              The first harness matching the given query.

              +

              If no matching harness is found.

              +
            • Gets a matching harness for the given query and index within the current harness's content.

              +

              Type Parameters

              • T extends ComponentHarness

              Parameters

              • query: HarnessQuery<T>

                The harness query to search for.

                +
              • index: number

                The zero-indexed offset of the component to find.

                +

              Returns Promise<T>

              The first harness matching the given query.

              +

              If no matching harness is found.

              +
            • Gets the first matching harness for the given query within the current harness's content.

              +

              Type Parameters

              • T extends ComponentHarness

              Parameters

              • query: HarnessQuery<T>

                The harness query to search for.

                +

              Returns Promise<T | null>

              The first harness matching the given query, or null if none is found.

              +
            • Gets the root harness loader from which to start +searching for content contained by this harness.

              +

              Returns Promise<HarnessLoader>

            • Searches for an instance of the component corresponding to the given harness type under the document root element, and returns a ComponentHarness for that instance. If multiple matching components are found, a harness for the first one is returned. If no matching component is found, an error is thrown.

              +

              Type Parameters

              • T extends ComponentHarness

              Parameters

              • predicate: HarnessQuery<T>

              Returns Promise<T>

            • Checks whether there is a matching harnesses for the given query within the current harness's +content.

              +

              Type Parameters

              • T extends ComponentHarness

              Parameters

              • query: HarnessQuery<T>

                The harness query to search for.

                +

              Returns Promise<boolean>

              Whether there is matching harnesses for the given query.

              +
            • Gets a Promise for the TestElement representing the host element of the component.

              +

              Returns Promise<TestElement>

            • Creates an asynchronous locator function that can be used to find a ComponentHarness instance +or element under the host element of this ComponentHarness.

              +

              For example, given the following DOM and assuming DivHarness.hostSelector is 'div'

              +
              <div id="d1"></div><div id="d2"></div>
              +
              + +

              then we expect:

              +
              await ch.locatorFor(DivHarness, 'div')() // Gets a `DivHarness` instance for #d1
              await ch.locatorFor('div', DivHarness)() // Gets a `TestElement` instance for #d1
              await ch.locatorFor('span')() // Throws because the `Promise` rejects +
              + +

              Type Parameters

              • T extends (string | HarnessQuery<any>)[]

              Parameters

              • ...queries: T

                A list of queries specifying which harnesses and elements to search for:

                +
                  +
                • A string searches for elements matching the CSS selector specified by the string.
                • +
                • A ComponentHarness constructor searches for ComponentHarness instances matching the +given class.
                • +
                • A HarnessPredicate searches for ComponentHarness instances matching the given +predicate.
                • +
                +

              Returns () => Promise<LocatorFnResult<T>>

              An asynchronous locator function that searches for and returns a Promise for the +first element or harness matching the given search criteria. Matches are ordered first by +order in the DOM, and second by order in the queries list. If no matches are found, the +Promise rejects. The type that the Promise resolves to is a union of all result types for +each query.

              +
            • Creates an asynchronous locator function that can be used to find ComponentHarness instances +or elements under the host element of this ComponentHarness.

              +

              For example, given the following DOM and assuming DivHarness.hostSelector is 'div' and +IdIsD1Harness.hostSelector is '#d1'

              +
              <div id="d1"></div><div id="d2"></div>
              +
              + +

              then we expect:

              +
              // Gets [DivHarness for #d1, TestElement for #d1, DivHarness for #d2, TestElement for #d2]
              await ch.locatorForAll(DivHarness, 'div')()
              // Gets [TestElement for #d1, TestElement for #d2]
              await ch.locatorForAll('div', '#d1')()
              // Gets [DivHarness for #d1, IdIsD1Harness for #d1, DivHarness for #d2]
              await ch.locatorForAll(DivHarness, IdIsD1Harness)()
              // Gets []
              await ch.locatorForAll('span')() +
              + +

              Type Parameters

              • T extends (string | HarnessQuery<any>)[]

              Parameters

              • ...queries: T

                A list of queries specifying which harnesses and elements to search for:

                +
                  +
                • A string searches for elements matching the CSS selector specified by the string.
                • +
                • A ComponentHarness constructor searches for ComponentHarness instances matching the +given class.
                • +
                • A HarnessPredicate searches for ComponentHarness instances matching the given +predicate.
                • +
                +

              Returns () => Promise<LocatorFnResult<T>[]>

              An asynchronous locator function that searches for and returns a Promise for all +elements and harnesses matching the given search criteria. Matches are ordered first by +order in the DOM, and second by order in the queries list. If an element matches more than +one ComponentHarness class, the locator gets an instance of each for the same element. If +an element matches multiple string selectors, only one TestElement instance is returned +for that element. The type that the Promise resolves to is an array where each element is +the union of all result types for each query.

              +
            • Creates an asynchronous locator function that can be used to find a ComponentHarness instance +or element under the host element of this ComponentHarness.

              +

              For example, given the following DOM and assuming DivHarness.hostSelector is 'div'

              +
              <div id="d1"></div><div id="d2"></div>
              +
              + +

              then we expect:

              +
              await ch.locatorForOptional(DivHarness, 'div')() // Gets a `DivHarness` instance for #d1
              await ch.locatorForOptional('div', DivHarness)() // Gets a `TestElement` instance for #d1
              await ch.locatorForOptional('span')() // Gets `null` +
              + +

              Type Parameters

              • T extends (string | HarnessQuery<any>)[]

              Parameters

              • ...queries: T

                A list of queries specifying which harnesses and elements to search for:

                +
                  +
                • A string searches for elements matching the CSS selector specified by the string.
                • +
                • A ComponentHarness constructor searches for ComponentHarness instances matching the +given class.
                • +
                • A HarnessPredicate searches for ComponentHarness instances matching the given +predicate.
                • +
                +

              Returns () => Promise<LocatorFnResult<T> | null>

              An asynchronous locator function that searches for and returns a Promise for the +first element or harness matching the given search criteria. Matches are ordered first by +order in the DOM, and second by order in the queries list. If no matches are found, the +Promise is resolved with null. The type that the Promise resolves to is a union of all +result types for each query or null.

              +
            • Waits for all scheduled or running async tasks to complete. This allows harness +authors to wait for async tasks outside of the Angular zone.

              +

              Returns Promise<void>

            diff --git a/docs/ng-vitest/classes/MockController.html b/docs/ng-vitest/classes/MockController.html new file mode 100644 index 00000000..233549c6 --- /dev/null +++ b/docs/ng-vitest/classes/MockController.html @@ -0,0 +1,15 @@ +MockController | @s-libs/ng-vitest
            @s-libs/ng-vitest
              Preparing search index...

              Class MockController<F>

              Provides expectations and matching around a vitest mock that will be familiar to users of Angular's HttpTestingController.

              +

              Type Parameters

              • F extends Func

              Hierarchy

              Index

              Constructors

              Methods

              Constructors

              Methods

              • Expect that no requests were made that match the given condition.

                +

                If a matching call was made, fail with an error message including the given request description, if any.

                +

                Parameters

                • matcher: CallMatcher<TestCall<F>>
                • Optionaldescription: string

                Returns void

              • Expect that a single call was made that matches the given condition, and return its TestCall.

                +

                If no such call was made, or more than one such call was made, fail with an error message including the given request description, if any.

                +

                Parameters

                • matcher: CallMatcher<TestCall<F>>
                • Optionaldescription: string

                Returns TestCall

              • Verify that no unmatched calls are outstanding.

                +

                If any calls are outstanding, fail with an error message indicating which calls were not handled.

                +

                Returns void

              diff --git a/docs/ng-vitest/classes/SlTestRequest.html b/docs/ng-vitest/classes/SlTestRequest.html new file mode 100644 index 00000000..45398192 --- /dev/null +++ b/docs/ng-vitest/classes/SlTestRequest.html @@ -0,0 +1,15 @@ +SlTestRequest | @s-libs/ng-vitest
              @s-libs/ng-vitest
                Preparing search index...

                Class SlTestRequest<Body>

                A class very similar to Angular's TestRequest for use with an AngularContext. If you are using an AngularContext, this will trigger change detection automatically after you flush a response, like production behavior.

                +

                Though it is possible to construct yourself, normally an instance of this class is obtained from ().

                +
                const ctx = new AngularContext({ providers: [provideHttpClient()] });
                await ctx.run(async () => {
                ctx
                .inject(HttpClient)
                .get('http://example.com', { params: { key: 'value' } })
                .subscribe();
                const request = expectRequest<string>('GET', 'http://example.com', {
                params: { key: 'value' },
                });
                await request.flush('my response body');
                }); +
                + +

                Type Parameters

                • Body extends HttpBody
                Index

                Constructors

                Properties

                Methods

                Constructors

                • Type Parameters

                  • Body extends
                        | string
                        | number
                        | boolean
                        | Object
                        | ArrayBuffer
                        | Blob
                        | (string | number | boolean | Object | null)[]
                        | null

                  Parameters

                  • req: TestRequest

                  Returns SlTestRequest<Body>

                Properties

                request: HttpRequest<any>

                The underlying TestRequest object from Angular.

                +

                Methods

                • Resolve the request with the given body and options, like TestRequest.flush().

                  +

                  Parameters

                  • body: Body
                  • Optionalopts: {
                        headers?: HttpHeaders | { [name: string]: string | string[] };
                        status?: number;
                        statusText?: string;
                    }

                  Returns Promise<void>

                • Convenience method to flush an error response.

                  +

                  Parameters

                  • status: number = 500
                  • __namedParameters: {
                        body?:
                            | string
                            | number
                            | boolean
                            | Object
                            | ArrayBuffer
                            | Blob
                            | (string | number | boolean | Object | null)[]
                            | null;
                        statusText?: string;
                    } = {}

                  Returns Promise<void>

                diff --git a/docs/ng-vitest/classes/TestCall.html b/docs/ng-vitest/classes/TestCall.html new file mode 100644 index 00000000..dcaede55 --- /dev/null +++ b/docs/ng-vitest/classes/TestCall.html @@ -0,0 +1,15 @@ +TestCall | @s-libs/ng-vitest
                @s-libs/ng-vitest
                  Preparing search index...

                  Class TestCall<F>

                  Collects all the information about a single call to a vitest mock into a single object.

                  +

                  Type Parameters

                  • F extends Func

                  Hierarchy (View Summary)

                  Index

                  Constructors

                  Methods

                  diff --git a/docs/ng-vitest/functions/arrayWithMatch.html b/docs/ng-vitest/functions/arrayWithMatch.html new file mode 100644 index 00000000..eec28709 --- /dev/null +++ b/docs/ng-vitest/functions/arrayWithMatch.html @@ -0,0 +1 @@ +arrayWithMatch | @s-libs/ng-vitest
                  @s-libs/ng-vitest
                    Preparing search index...

                    Function arrayWithMatch

                    diff --git a/docs/ng-vitest/functions/createMockObject.html b/docs/ng-vitest/functions/createMockObject.html new file mode 100644 index 00000000..2de35859 --- /dev/null +++ b/docs/ng-vitest/functions/createMockObject.html @@ -0,0 +1,5 @@ +createMockObject | @s-libs/ng-vitest
                    @s-libs/ng-vitest
                      Preparing search index...

                      Function createMockObject

                      • Creates a new object with Vitest mocks for each method in type. Each comes with a MockController you can use for targeted expectations.

                        +
                        class Greeter {
                        greet(name: string): string {
                        return `Hello, ${name}!`;
                        }
                        }

                        const mockObject = createMockObject(Greeter);
                        mockObject.greet.mockReturnValue('Hello, stub!');
                        expect(mockObject.greet('Eric')).toBe('Hello, stub!');
                        expectSingleCallAndReset(mockObject.greet, 'Eric'); +
                        + +

                        Type Parameters

                        • T

                        Parameters

                        • type: Type<T>

                        Returns MockObject<T>

                      diff --git a/docs/ng-vitest/functions/expectExactContents.html b/docs/ng-vitest/functions/expectExactContents.html new file mode 100644 index 00000000..9a0b53fe --- /dev/null +++ b/docs/ng-vitest/functions/expectExactContents.html @@ -0,0 +1 @@ +expectExactContents | @s-libs/ng-vitest
                      @s-libs/ng-vitest
                        Preparing search index...

                        Function expectExactContents

                        diff --git a/docs/ng-vitest/functions/expectRequest.html b/docs/ng-vitest/functions/expectRequest.html new file mode 100644 index 00000000..9b9d74ab --- /dev/null +++ b/docs/ng-vitest/functions/expectRequest.html @@ -0,0 +1,7 @@ +expectRequest | @s-libs/ng-vitest
                        @s-libs/ng-vitest
                          Preparing search index...

                          Function expectRequest

                          • This convenience function is similar to HttpTestingController.expectOne(), with extra features. The returned request object will automatically trigger change detection when you flush a response, just like in production.

                            +

                            This function is opinionated in that you must specify all aspects of the request to match. E.g. if the request specifies headers, you must also specify them in the arguments to this method.

                            +

                            This function only works when you are using an AngularContext.

                            +
                            const ctx = new AngularContext({ providers: [provideHttpClient()] });
                            ctx.run(() => {
                            inject(HttpClient)
                            .get('http://example.com', { params: { key: 'value' } })
                            .subscribe();
                            const request = expectRequest<string>('GET', 'http://example.com', {
                            params: { key: 'value' },
                            });
                            request.flush('my response body');
                            }); +
                            + +

                            Type Parameters

                            • ResponseBody extends
                                  | string
                                  | number
                                  | boolean
                                  | Object
                                  | ArrayBuffer
                                  | Blob
                                  | (string | number | boolean | Object | null)[]
                                  | null

                            Parameters

                            • method: HttpMethod
                            • url: string
                            • options: RequestOptions & {
                                  body?:
                                      | string
                                      | number
                                      | boolean
                                      | Object
                                      | ArrayBuffer
                                      | Blob
                                      | (string | number | boolean | Object | null)[]
                                      | null;
                              } = {}

                            Returns SlTestRequest<ResponseBody>

                          diff --git a/docs/ng-vitest/functions/expectSingleCallAndReset.html b/docs/ng-vitest/functions/expectSingleCallAndReset.html new file mode 100644 index 00000000..4390add6 --- /dev/null +++ b/docs/ng-vitest/functions/expectSingleCallAndReset.html @@ -0,0 +1,5 @@ +expectSingleCallAndReset | @s-libs/ng-vitest
                          @s-libs/ng-vitest
                            Preparing search index...

                            Function expectSingleCallAndReset

                            • Expects exactly one call to have been made to a vitest mock, for it to have received the given arguments, then clears the mock.

                              +
                              const mock = vi.fn();

                              mock(1, 2);
                              expectSingleCallAndReset(mock, 1, 2); // pass
                              expectSingleCallAndReset(mock, 1, 2); // fail

                              mock(3);
                              mock(4);
                              expectSingleCallAndReset(mock, 3); // fail +
                              + +

                              Parameters

                              • mock: Mock
                              • ...args: unknown[]

                              Returns void

                            diff --git a/docs/ng-vitest/functions/staticTest.html b/docs/ng-vitest/functions/staticTest.html new file mode 100644 index 00000000..2c630458 --- /dev/null +++ b/docs/ng-vitest/functions/staticTest.html @@ -0,0 +1,5 @@ +staticTest | @s-libs/ng-vitest
                            @s-libs/ng-vitest
                              Preparing search index...

                              Function staticTest

                              • Use this when you want to write test code that doesn't actually run, instead relying only on your static tools like TypeScript or a linter to raise errors.

                                +
                                function reject<T>(array: T[], predicate: (value: T) => boolean): T[] {
                                return array.filter((value) => !predicate(value));
                                }

                                it('requires the predicate type to match the array type', () => {
                                staticTest(() => {
                                // @ts-expect-error -- mismatch of number array w/ string function
                                reject([1, 2, 3], (value: string) => value === '2');
                                });
                                }); +
                                + +

                                Parameters

                                • _: () => void | Promise<void>

                                Returns void

                              diff --git a/docs/ng-vitest/hierarchy.html b/docs/ng-vitest/hierarchy.html new file mode 100644 index 00000000..56f76cd5 --- /dev/null +++ b/docs/ng-vitest/hierarchy.html @@ -0,0 +1 @@ +@s-libs/ng-vitest
                              @s-libs/ng-vitest
                                Preparing search index...

                                @s-libs/ng-vitest

                                Hierarchy Summary

                                diff --git a/docs/ng-vitest/index.html b/docs/ng-vitest/index.html new file mode 100644 index 00000000..80f43bd0 --- /dev/null +++ b/docs/ng-vitest/index.html @@ -0,0 +1,5 @@ +@s-libs/ng-vitest
                                @s-libs/ng-vitest
                                  Preparing search index...

                                  @s-libs/ng-vitest

                                  To quickly see what is available, see the api documentation.

                                  +
                                  npm install @s-libs/ng-core @s-libs/js-core @s-libs/micro-dash
                                  npm install --save-dev @s-libs/ng-vitest @s-libs/ng-dev +
                                  + +
                                  diff --git a/docs/ng-vitest/modules.html b/docs/ng-vitest/modules.html new file mode 100644 index 00000000..59568f08 --- /dev/null +++ b/docs/ng-vitest/modules.html @@ -0,0 +1 @@ +@s-libs/ng-vitest
                                  @s-libs/ng-vitest
                                    Preparing search index...
                                    diff --git a/docs/ng-vitest/types/MockObject.html b/docs/ng-vitest/types/MockObject.html new file mode 100644 index 00000000..c9277766 --- /dev/null +++ b/docs/ng-vitest/types/MockObject.html @@ -0,0 +1,2 @@ +MockObject | @s-libs/ng-vitest
                                    @s-libs/ng-vitest
                                      Preparing search index...

                                      Type Alias MockObject<T>

                                      MockObject: {
                                          [K in keyof T]: T[K] extends (...args: any[]) => any
                                              ? Mock<T[K]> & { controller: MockController<T[K]> }
                                              : never
                                      }

                                      Return type of createMockObject.

                                      +

                                      Type Parameters

                                      • T
                                      diff --git a/projects/ng-vitest/README.md b/projects/ng-vitest/README.md index fcbd42d1..d5ae5063 100644 --- a/projects/ng-vitest/README.md +++ b/projects/ng-vitest/README.md @@ -1,64 +1,10 @@ -# NgVitest +## API Documentation -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.0. +To quickly see what is available, see the [api documentation](https://simontonsoftware.github.io/s-libs/ng-vitest). -## Code scaffolding +## Installation -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: - -```bash -ng generate component component-name -``` - -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: - -```bash -ng generate --help -``` - -## Building - -To build the library, run: - -```bash -ng build ng-vitest -``` - -This command will compile your project, and the build artifacts will be placed in the `dist/` directory. - -### Publishing the Library - -Once the project is built, you can publish your library by following these steps: - -1. Navigate to the `dist` directory: - - ```bash - cd dist/ng-vitest - ``` - -2. Run the `npm publish` command to publish your library to the npm registry: - ```bash - npm publish - ``` - -## Running unit tests - -To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: - -```bash -ng test ``` - -## Running end-to-end tests - -For end-to-end (e2e) testing, run: - -```bash -ng e2e +npm install @s-libs/ng-core @s-libs/js-core @s-libs/micro-dash +npm install --save-dev @s-libs/ng-vitest @s-libs/ng-dev ``` - -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. - -## Additional Resources - -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/projects/ng-vitest/src/lib/angular-context/angular-context.ts b/projects/ng-vitest/src/lib/angular-context/angular-context.ts index 8b3cc16b..7fb50d9a 100644 --- a/projects/ng-vitest/src/lib/angular-context/angular-context.ts +++ b/projects/ng-vitest/src/lib/angular-context/angular-context.ts @@ -43,12 +43,12 @@ export function extendMetadata( * - Variables that are initialized for each test exist in a context that is thrown away, so they cannot leak between tests. * - Clearly separates initialization code from the test itself. * - Gives control over the simulated date and time with a single line of code. - * - Automatically includes {@link https://angular.dev/api/common/http/testing/provideHttpClientTesting | provideHttpClientTesting()} to stub network requests without additional setup. + * - Automatically includes {@linkcode https://angular.dev/api/common/http/testing/provideHttpClientTesting | provideHttpClientTesting()} to stub network requests without additional setup. * - Always verifies that no unexpected http requests were made. - * - Always verifies that no unmatched errors were thrown (using {@link MockErrorHandler}). + * - Always verifies that no unmatched errors were thrown (using {@linkcode MockErrorHandler}). * - Disables Material animations so that you don't need to wait for them in your tests. * - * This example tests a simple service that uses `HttpClient` and is tested by using `AngularContext` directly. More often, `AngularContext` will be used as a super class. See {@link ComponentContext} for more common use cases. + * This example tests a simple service that uses `HttpClient` and is tested by using `AngularContext` directly. More often, `AngularContext` will be used as a super class. See {@linkcode ComponentContext} for more common use cases. * * ```ts * // This is the class we will test. @@ -100,7 +100,7 @@ export class AngularContext { #loader = FakeTimerHarnessEnvironment.documentRootLoader(this); /** - * @param moduleMetadata passed along to {@linkcode https://angular.dev/api/core/testing/TestBedStatic#configureTestingModule | TestBed.configureTestingModule()}. Automatically includes {@link provideHttpClientTesting}, {@link MockErrorHandler}, and {@link MATERIAL_ANIMATIONS} with `animationsDisabled: true`. + * @param moduleMetadata passed along to {@linkcode https://angular.dev/api/core/testing/TestBedStatic#configureTestingModule | TestBed.configureTestingModule()}. Automatically includes {@linkcode provideHttpClientTesting}, {@linkcode MockErrorHandler}, and {@linkcode MATERIAL_ANIMATIONS} with `animationsDisabled: true`. */ constructor(moduleMetadata: TestModuleMetadata = {}) { assert( @@ -162,7 +162,7 @@ export class AngularContext { } /** - * Returns whether this context is currently executing the {@linkcode #run} callback. + * Returns whether this context is currently executing the {@linkcode AngularContext#run} callback. */ isRunning(): boolean { return this.#isRunning; diff --git a/projects/ng-vitest/src/lib/component-context/component-context.ts b/projects/ng-vitest/src/lib/component-context/component-context.ts index d766edcb..9a43c924 100644 --- a/projects/ng-vitest/src/lib/component-context/component-context.ts +++ b/projects/ng-vitest/src/lib/component-context/component-context.ts @@ -44,12 +44,12 @@ export class WrapperComponent { /** * Provides the foundation for an opinionated pattern for component tests. * - * - Includes all features from {@link AngularContext} + * - Includes all features from {@linkcode AngularContext} * - Automatically creates your component at the beginning of `run()`. * - Sets up Angular change detection and lifecycle hooks like it would in production. This covers cases you would normally have to trigger manually if you use the standard `TestBed.createComponent()` directly. * - Wraps your component in a parent that you can easily style however you like. * - Lets you use {@link https://material.angular.dev/cdk/testing/overview | component harnesses} with Vitest's fake timers, which is normally a challenge. - * - Causes async {@link https://angular.dev/api/core/APP_INITIALIZER | APP_INITIALIZER}s to complete before instantiating the component. A caveat, they must not include a `setTimeout` delay, or the test will hang. + * - Causes async {@linkcode https://angular.dev/api/core/APP_INITIALIZER | APP_INITIALIZER}s to complete before instantiating the component. A caveat, they must not include a `setTimeout` delay, or the test will hang. * * A very simple example: * ```ts @@ -149,7 +149,7 @@ export class WrapperComponent { */ export class ComponentContext extends AngularContext { /** - * The {@link ComponentFixture} for a synthetic wrapper around your component. Available within the callback to `run()`. + * The {@linkcode ComponentFixture} for a synthetic wrapper around your component. Available within the callback to `run()`. */ fixture!: ComponentFixture; @@ -160,7 +160,7 @@ export class ComponentContext extends AngularContext { /** * @param componentType `run()` will create a component of this type before running the rest of your test. - * @param moduleMetadata passed along to {@linkcode https://angular.dev/api/core/testing/TestBedStatic#configureTestingModule | TestBed.configureTestingModule()}. Automatically includes everything provided by {@link AngularContext}. + * @param moduleMetadata passed along to {@linkcode https://angular.dev/api/core/testing/TestBedStatic#configureTestingModule | TestBed.configureTestingModule()}. Automatically includes everything provided by {@linkcode AngularContext}. */ constructor(componentType: Type, moduleMetadata: TestModuleMetadata = {}) { const mirror = reflectComponentType(componentType); @@ -251,7 +251,7 @@ export class ComponentContext extends AngularContext { } /** - * Performs any cleanup needed at the end of each test. This implementation destroys {@link fixture} and calls the super implementation. + * Performs any cleanup needed at the end of each test. This implementation destroys {@linkcode fixture} and calls the super implementation. */ protected override async cleanUp(): Promise { this.fixture.destroy(); diff --git a/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.ts b/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.ts index f70d5409..b9ed94cd 100644 --- a/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.ts +++ b/projects/ng-vitest/src/lib/component-harness/component-harness-superclass.ts @@ -10,7 +10,7 @@ import { */ export class ComponentHarnessSuperclass extends ContentContainerComponentHarness { /** - * Searches for an instance of the component corresponding to the given harness type under the document root element, and returns a {@link ComponentHarness} for that instance. If multiple matching components are found, a harness for the first one is returned. If no matching component is found, an error is thrown. + * Searches for an instance of the component corresponding to the given harness type under the document root element, and returns a {@linkcode ComponentHarness} for that instance. If multiple matching components are found, a harness for the first one is returned. If no matching component is found, an error is thrown. */ protected async getTopLevelHarness( predicate: HarnessQuery, @@ -20,7 +20,7 @@ export class ComponentHarnessSuperclass extends ContentContainerComponentHarness } /** - * Searches for all instances of the component corresponding to the given harness type under the document root element, and returns a list {@link ComponentHarness} for each instance. + * Searches for all instances of the component corresponding to the given harness type under the document root element, and returns a list {@linkcode ComponentHarness} for each instance. */ protected async getAllTopLevelHarnesses( predicate: HarnessQuery, @@ -30,7 +30,7 @@ export class ComponentHarnessSuperclass extends ContentContainerComponentHarness } /** - * Gets a {@link HarnessLoader} for the document root element. This loader can be used for elements that a component creates outside its own root element (e.g. by appending to `document.body`). + * Gets a {@linkcode HarnessLoader} for the document root element. This loader can be used for elements that a component creates outside its own root element (e.g. by appending to `document.body`). */ protected async getTopLevelLoader(): Promise { return this.documentRootLocatorFactory().rootHarnessLoader(); diff --git a/projects/ng-vitest/src/lib/mocks/async-method-controller.ts b/projects/ng-vitest/src/lib/mocks/async-method-controller.ts index 0226aa35..a3e12839 100644 --- a/projects/ng-vitest/src/lib/mocks/async-method-controller.ts +++ b/projects/ng-vitest/src/lib/mocks/async-method-controller.ts @@ -17,7 +17,7 @@ type AsyncMethod< : never; /** - * Controller to be used in tests, that allows for mocking and flushing any asynchronous method. If you are using an {@link AngularContext}, it automatically calls {@link AngularContext.tick} after each `.flush()` and `.error()` to trigger promise handlers and change detection. This is the normal production behavior of asynchronous browser APIs. + * Controller to be used in tests, that allows for mocking and flushing any asynchronous method. If you are using an {@linkcode AngularContext}, it automatically calls {@linkcode AngularContext#tick} after each `.flush()` and `.error()` to trigger promise handlers and change detection. This is the normal production behavior of asynchronous browser APIs. * * For example, to mock the browser's paste functionality: * diff --git a/projects/ng-vitest/src/lib/mocks/call-tracker.ts b/projects/ng-vitest/src/lib/mocks/call-tracker.ts index 293d5859..3c2c7ad7 100644 --- a/projects/ng-vitest/src/lib/mocks/call-tracker.ts +++ b/projects/ng-vitest/src/lib/mocks/call-tracker.ts @@ -20,7 +20,7 @@ export abstract class CallTracker> { } /** - * Expect that a single call was made that matches the given condition, and return its {@link TestCall}. + * Expect that a single call was made that matches the given condition, and return its {@linkcode TestCall}. * * If no such call was made, or more than one such call was made, fail with an error message including the given request description, if any. */ diff --git a/projects/ng-vitest/src/lib/mocks/create-mock-object.ts b/projects/ng-vitest/src/lib/mocks/create-mock-object.ts index d2b49598..db411250 100644 --- a/projects/ng-vitest/src/lib/mocks/create-mock-object.ts +++ b/projects/ng-vitest/src/lib/mocks/create-mock-object.ts @@ -6,7 +6,7 @@ import { MockController } from './mock-controller'; // adapted from https://github.com/ngneat/spectator/blob/e13c9554778bdb179dfc7235aedb4b3b90302850/projects/spectator/src/lib/mock.ts /** - * Return type of {@link createMockObject}. + * Return type of {@linkcode createMockObject}. */ export type MockObject = { [K in keyof T]: T[K] extends (...args: any[]) => any @@ -15,7 +15,7 @@ export type MockObject = { }; /** - * Creates a new object with Vitest mocks for each method in `type`. Each comes with a {@link MockController} you can use for targeted expectations. + * Creates a new object with Vitest mocks for each method in `type`. Each comes with a {@linkcode MockController} you can use for targeted expectations. * * ```ts * class Greeter { diff --git a/projects/ng-vitest/src/lib/mocks/index.ts b/projects/ng-vitest/src/lib/mocks/index.ts index 11415921..c6d600b6 100644 --- a/projects/ng-vitest/src/lib/mocks/index.ts +++ b/projects/ng-vitest/src/lib/mocks/index.ts @@ -1,6 +1,6 @@ export { AsyncMethodController } from './async-method-controller'; export { AsyncTestCall } from './async-test-call'; -export { createMockObject } from './create-mock-object'; +export { createMockObject, type MockObject } from './create-mock-object'; export { expectSingleCallAndReset } from './expect-single-call-and-reset'; export { MockController } from './mock-controller'; export { TestCall } from './test-call'; diff --git a/projects/ng-vitest/src/lib/mocks/test-call.ts b/projects/ng-vitest/src/lib/mocks/test-call.ts index de1c0d40..3b9508dc 100644 --- a/projects/ng-vitest/src/lib/mocks/test-call.ts +++ b/projects/ng-vitest/src/lib/mocks/test-call.ts @@ -18,42 +18,42 @@ export class TestCall { ) {} /** - * See {@link Mock.mock.calls} + * See {@linkcode Mock.mock.calls} */ getArgs(): MockParameters { return this.mock.mock.calls[this.index]; } /** - * See {@link Mock.mock.instances} + * See {@linkcode Mock.mock.instances} */ getInstance(): MockProcedureContext { return this.mock.mock.instances[this.index]; } /** - * See {@link Mock.mock.contexts} + * See {@linkcode Mock.mock.contexts} */ getContext(): MockProcedureContext { return this.mock.mock.contexts[this.index]; } /** - * See {@link Mock.mock.invocationCallOrder} + * See {@linkcode Mock.mock.invocationCallOrder} */ getInvocationCallOrder(): number { return this.mock.mock.invocationCallOrder[this.index]; } /** - * See {@link Mock.mock.results} + * See {@linkcode Mock.mock.results} */ getResult(): MockResult> { return this.mock.mock.results[this.index]; } /** - * See {@link Mock.mock.settledResults} + * See {@linkcode Mock.mock.settledResults} */ getSettledResult(): MockSettledResult>> { return this.mock.mock.settledResults[this.index]; diff --git a/scripts/shared.ts b/scripts/shared.ts index 0a0bdb0a..8b665776 100644 --- a/scripts/shared.ts +++ b/scripts/shared.ts @@ -1,3 +1,4 @@ +import { execSync } from 'node:child_process'; import { createInterface } from 'readline'; // in dependency order @@ -19,7 +20,7 @@ export const libraries = [...buildableLibraries, 'eslint-config-ng']; export function runCommand(command: string): void { console.log('Running command:', command); - // execSync(command, { stdio: 'inherit' }); + execSync(command, { stdio: 'inherit' }); } export async function getInput(text: string): Promise {