diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 0a6635a3..18b867b8 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -1,20 +1,20 @@ runs: using: 'composite' steps: - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 22 - - uses: actions/cache@v4 + node-version: 24 + - uses: actions/cache@v5 with: path: node_modules key: node-modules-v1-${{ hashFiles('package-lock.json') }} - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: .angular/cache key: build-artifacts-v1-${{ github.sha }} restore-keys: build-artifacts-v1- - - run: npm i + - run: npm ci shell: bash - run: npm run build-libs shell: bash diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32e0ab90..53d28a31 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,15 +4,16 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: ./.github/actions/build + - run: npx playwright install --with-deps # - run: npm run test -- --no-watch --no-progress --browsers=ChromeHeadless - run: npx tsx ./scripts/test-all.ts lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: ./.github/actions/build - run: npm run lint -- --quiet 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>

    • Gets a component harness. Returns null if no matching component is found.

      +

      Type Parameters

      • H extends ComponentHarness

      Parameters

      • query: HarnessQuery<H>

      Returns Promise<H | null>

    • 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/eslint.config.js b/eslint.config.js index 4f0b2d42..43896e32 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,7 +1,8 @@ // @ts-check -const tseslint = require('typescript-eslint'); -const libraryConfig = require('./projects/eslint-config-ng/strict.js'); +const { defineConfig } = require('eslint/config'); +const slibs = require('./projects/eslint-config-ng/strict.js'); -module.exports = tseslint.config(...libraryConfig, { - ignores: ['**/*.dts-spec.ts'], -}); +module.exports = defineConfig([ + ...slibs, + { ignores: ['**/*.dts-spec.ts', '**/vitest-base.config.ts'] }, +]); diff --git a/package-lock.json b/package-lock.json index 52580dcd..d4148094 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", @@ -27,12 +27,14 @@ "@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", "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", @@ -40,16 +42,24 @@ "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", "standard-version": "^9.5.0", - "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 +921,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", @@ -1204,6 +1269,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", @@ -1214,6 +1286,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 +2115,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", @@ -3645,6 +3875,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", @@ -4484,11 +4721,10 @@ "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==", + "license": "MIT" }, "node_modules/@szmarczak/http-timer": { "version": "1.1.2", @@ -4577,6 +4813,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 +4834,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,102 +5618,331 @@ "vite": "^6.0.0 || ^7.0.0" } }, - "node_modules/@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/a-sync-waterfall": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", - "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", - "dev": true, - "license": "MIT" - }, - "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "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": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "@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" }, - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.8" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "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", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@vitest/browser": "4.1.8", + "@vitest/mocker": "4.1.8", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "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", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/add-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", - "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "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": ">= 14" + "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/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "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": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "@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": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://opencollective.com/vitest" } }, - "node_modules/ajv-formats": { + "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", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/add-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", + "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", @@ -5730,6 +6213,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 +6298,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 +6800,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 +7777,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 +7804,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 +7850,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 +7949,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 +8669,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 +9118,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": { @@ -9368,19 +9959,6 @@ "node": ">=6" } }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/git-raw-commits": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.11.tgz", @@ -9942,9 +10520,22 @@ "node": "20 || >=22" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "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", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" @@ -10510,6 +11101,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 +11426,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 +12928,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 +14573,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 +15098,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", @@ -14509,6 +15175,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", @@ -15215,16 +15938,6 @@ "node": ">= 0.10" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/responselike": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", @@ -16030,6 +16743,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 +16983,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", @@ -16288,6 +17021,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", @@ -16741,6 +17489,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 +17746,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 +17926,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 +18170,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 +18204,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 +18348,42 @@ "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", + "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", @@ -17561,564 +18413,60 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" }, "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "prelude-ls": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": ">= 0.8.0" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], + "node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/tuf-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", - "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "4.1.0", - "debug": "^4.4.3", - "make-fetch-happen": "^15.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" + "node": ">= 0.6" } }, "node_modules/typedarray": { @@ -19460,6 +19808,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 +19918,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 +19953,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 +20003,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 +20220,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..bc0fb030 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,14 @@ "watch": "ng build --watch --configuration development", "test": "ng test", "lint": "ng lint", - "build-libs": "tsx scripts/build-libs", + "build-libs": "node scripts/build-libs.ts", "dtslint": "dtslint --expectOnly --localTs node_modules/typescript/lib", "prettier-lint": "prettier --check \"**/*.{html,js,json,md,scss,ts,yml}\"", "prettier-all": "prettier --write \"**/*.{html,js,json,md,scss,ts,yml}\"", - "docs": "tsx scripts/docs-libs", + "docs": "node scripts/docs-libs.ts", "git-publish": "standard-version --commit-all", - "npm-publish": "tsx scripts/publish-libs", - "calc-micro-dash-sizes": "cd projects/micro-dash-sizes && tsx calc-sizes" + "npm-publish": "node scripts/publish-libs.ts", + "calc-micro-dash-sizes": "cd projects/micro-dash-sizes && node calc-sizes.ts" }, "prettier": { "singleQuote": true, @@ -49,12 +49,14 @@ "@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", "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", @@ -62,14 +64,15 @@ "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", "standard-version": "^9.5.0", - "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": [ @@ -114,12 +117,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/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..e8776744 --- /dev/null +++ b/projects/integration/src/app/api-tests/ng-vitest.spec.ts @@ -0,0 +1,74 @@ +import { + AngularContext, + arrayWithMatch, + AsyncMethodController, + AsyncTestCall, + ComponentContext, + ComponentHarnessSuperclass, + createMockObject, + expectExactContents, + expectRequest, + expectSingleCallAndReset, + MockController, + 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 AsyncTestCall', () => { + expect(AsyncTestCall).toBeDefined(); + }); + + it('has ComponentContext', () => { + expect(ComponentContext).toBeDefined(); + }); + + it('has ComponentHarnessSuperclass', () => { + expect(ComponentHarnessSuperclass).toBeDefined(); + }); + + it('has MockController', () => { + expect(MockController).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/README.md b/projects/ng-vitest/README.md new file mode 100644 index 00000000..d5ae5063 --- /dev/null +++ b/projects/ng-vitest/README.md @@ -0,0 +1,10 @@ +## API Documentation + +To quickly see what is available, see the [api documentation](https://simontonsoftware.github.io/s-libs/ng-vitest). + +## Installation + +``` +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/projects/ng-vitest/eslint.config.js b/projects/ng-vitest/eslint.config.js new file mode 100644 index 00000000..834cbb8e --- /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: ['app', 'sl'], + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: ['app', 'sl'], + 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..316c0491 --- /dev/null +++ b/projects/ng-vitest/package.json @@ -0,0 +1,25 @@ +{ + "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.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" + }, + "sideEffects": false +} 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..655e01be --- /dev/null +++ b/projects/ng-vitest/src/lib/angular-context/angular-context.spec.ts @@ -0,0 +1,539 @@ +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 async cleanUp(): Promise { + this.inject(OverlayContainer).ngOnDestroy(); + await 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 async cleanUp(): Promise { + 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 + 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('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..7fb50d9a --- /dev/null +++ b/projects/ng-vitest/src/lib/angular-context/angular-context.ts @@ -0,0 +1,257 @@ +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 {@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 {@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 {@linkcode 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', 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'); + * }); + * }); + * }); + * ``` + */ +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 {@linkcode provideHttpClientTesting}, {@linkcode MockErrorHandler}, and {@linkcode 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` 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 `await`ing its result). + * + * Also runs the following in this order: + * + * 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 { + await this.cleanUp(); + } finally { + vi.useRealTimers(); + AngularContext.#current = undefined; + this.#isRunning = false; + } + } + } + + /** + * Returns whether this context is currently executing the {@linkcode AngularContext#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. Throws an error if no matching component is found. + */ + async getHarness( + query: HarnessQuery, + ): Promise { + return this.#loader.getHarness(query); + } + + /** + * Gets a component harness. 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. + */ + 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}. 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(); + } + + /** + * 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. + */ + // eslint-disable-next-line @typescript-eslint/no-empty-function + protected async cleanUp(): Promise {} +} 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..9a43c924 --- /dev/null +++ b/projects/ng-vitest/src/lib/component-context/component-context.ts @@ -0,0 +1,264 @@ +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 {@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 {@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 + * @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!'); + * }); + * }); + * ``` + * + * 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', 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)] }; + * ``` + */ +export class ComponentContext extends AngularContext { + /** + * The {@linkcode ComponentFixture} for a synthetic wrapper around your component. Available within 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 everything provided by {@linkcode AngularContext}. + */ + 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, + ]); + } + + /** + * 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 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. + */ + 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. + */ + 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. + */ + 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 {@linkcode fixture} and calls the super implementation. + */ + protected override async cleanUp(): Promise { + this.fixture.destroy(); + await 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..b9ed94cd --- /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 {@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, + ): 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 {@linkcode ComponentHarness} for each instance. + */ + protected async getAllTopLevelHarnesses( + predicate: HarnessQuery, + ): Promise { + const loader = await this.getTopLevelLoader(); + return loader.getAllHarnesses(predicate); + } + + /** + * 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/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..29979359 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/async-method-controller.spec.ts @@ -0,0 +1,57 @@ +import { AngularContext } from '../angular-context'; +import { AsyncMethodController } from './async-method-controller'; + +describe('AsyncMethodController', () => { + function triggerRead(): void { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + navigator.clipboard.readText(); + } + + describe('constructor', () => { + it('sets up call tracking', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + triggerRead(); + triggerRead(); + expect(controller.match([])).toHaveLength(2); + + triggerRead(); + expect(controller.match([])).toHaveLength(1); + }); + + 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('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..a3e12839 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/async-method-controller.ts @@ -0,0 +1,64 @@ +import { Deferred } from '@s-libs/js-core'; +import { once } from '@s-libs/micro-dash'; +import { AsyncTestCall } from './async-test-call'; +import { CallTracker } from './call-tracker'; + +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 AsyncFunc + ? WrappingObject[FunctionName] + : never; + +/** + * 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: + * + * ```ts + * 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 + * }); + * }); + * ``` + */ +export class AsyncMethodController< + WrappingObject extends object, + MethodName extends AsyncMethodKeys, +> extends CallTracker>> { + constructor(obj: WrappingObject, methodName: MethodName) { + const mock: any = vi.spyOn(obj, methodName); + super( + once((track) => { + mock.mockImplementation(async () => { + const deferred = new Deferred(); + track(new AsyncTestCall(mock, mock.mock.calls.length - 1, deferred)); + return deferred.promise; + }); + }), + ); + } +} diff --git a/projects/ng-vitest/src/lib/mocks/async-test-call.spec.ts b/projects/ng-vitest/src/lib/mocks/async-test-call.spec.ts new file mode 100644 index 00000000..57b2af92 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/async-test-call.spec.ts @@ -0,0 +1,146 @@ +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('AsyncTestCall', () => { + 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('.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', 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); + }); + }); + + 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(); + }); + }); + + 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', 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); + }); + }); + + it('gracefully handles being run outside an AngularContext', async () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + triggerRead(); + await expect( + controller.expectOne([]).error('permission denied'), + ).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.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/async-test-call.ts b/projects/ng-vitest/src/lib/mocks/async-test-call.ts new file mode 100644 index 00000000..c9f8ee2d --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/async-test-call.ts @@ -0,0 +1,34 @@ +import { Deferred } from '@s-libs/js-core'; +import { Mock } from '@vitest/spy'; +import { AngularContext } from '../angular-context'; +import { Func, ResolveType } from '../interfaces'; +import { TestCall } from './test-call'; + +/** + * A mock method call that was made and is ready to be answered. This interface allows resolving or rejecting the asynchronous call's result. + */ +export class AsyncTestCall extends TestCall { + constructor( + mock: Mock, + index: number, + private deferred: Deferred>, + ) { + super(mock, index); + } + + /** + * Resolve the call with the given value. + */ + async flush(value: ResolveType): Promise { + this.deferred.resolve(value); + await AngularContext.getCurrent()?.tick(); + } + + /** + * Reject the call with the given reason. + */ + async error(reason: unknown): Promise { + this.deferred.reject(reason); + await AngularContext.getCurrent()?.tick(); + } +} diff --git a/projects/ng-vitest/src/lib/mocks/call-tracker.spec.ts b/projects/ng-vitest/src/lib/mocks/call-tracker.spec.ts new file mode 100644 index 00000000..bb050c25 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/call-tracker.spec.ts @@ -0,0 +1,212 @@ +import { expectExactContents } from '../expectations'; +import { staticTest } from '../static-test/static-test'; +import { MockController } from './mock-controller'; +import { TestCall } from './test-call'; + +describe('CallTracker', () => { + describe('.expectOne()', () => { + it('finds a matching method call', () => { + const fn = vi.fn(); + const controller = new MockController(fn); + fn('value 1'); + fn('value 2'); + + const match = controller.expectOne(['value 2']); + + expect(match.getArgs()[0]).toEqual('value 2'); + }); + + it('throws when there is no match', () => { + const fn = vi.fn(); + const controller = new MockController(fn); + fn(); + + 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 MockController(vi.fn()); + + 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 fn = vi.fn(); + const controller = new MockController(fn); + fn(); + fn(); + + expect(() => { + controller.expectOne(() => true); + }).toThrow( + 'Expected one matching call(s) for criterion "Match by function: ", found 2', + ); + }); + + it('has fancy typing', () => { + staticTest(() => { + type FnType = (arg: string) => Date; + const fn = vi.fn(); + const controller = new MockController(fn); + expectTypeOf(controller.expectOne) + .parameter(0) + .toEqualTypeOf<[string] | ((call: TestCall) => boolean)>(); + }); + }); + }); + + describe('.expectNone()', () => { + it('throws if any call matches', () => { + const fn = vi.fn(); + const controller = new MockController(fn); + fn('value 1'); + fn('value 2'); + + expect(() => { + controller.expectNone(['value 2']); + }).toThrow( + 'Expected zero matching call(s) for criterion "Match by arguments: ["value 2"]", found 1', + ); + }); + + it('does not throw when no call matches', () => { + const controller = new MockController(vi.fn()); + + expect(() => { + controller.expectNone(() => false); + }).not.toThrow(); + }); + }); + + describe('.match()', () => { + it('finds matching method calls', () => { + const fn = vi.fn<(arg: string) => void>(); + const controller = new MockController(fn); + fn('value 1'); + fn('value 2'); + fn('value 3'); + + const matches = controller.match( + (call) => call.getArgs()[0] !== 'value 2', + ); + + expectExactContents( + matches.map((match) => match.getArgs()[0]), + ['value 1', 'value 3'], + ); + }); + + it('accepts an array of arguments to match against', () => { + const fn = vi.fn<(arg: string) => void>(); + const controller = new MockController(fn); + fn('value 1'); + fn('value 2'); + fn('value 1'); + + const matches = controller.match(['value 1']); + + expectExactContents( + matches.map((call) => call.getArgs()[0]), + ['value 1', 'value 1'], + ); + }); + + it('uses deep equality matching for the arguments shorthand', () => { + const fn = vi.fn<(arg: { method: string }) => void>(); + const controller = new MockController(fn); + fn({ method: 'GET' }); + fn({ method: 'POST' }); + fn({ method: 'GET' }); + + const matches = controller.match([{ method: 'GET' }]); + + expectExactContents( + matches.map((call) => call.getArgs()[0]), + [{ method: 'GET' }, { method: 'GET' }], + ); + }); + + it('removes the matching calls from future matching', () => { + const fn = vi.fn(); + const controller = new MockController(fn); + fn('value 1'); + fn('value 2'); + + controller.match(['value 2']); + + expect(controller.match(() => true).length).toBe(1); + }); + + it('returns an empty array when there have been no calls', () => { + const controller = new MockController(vi.fn()); + const matches = controller.match(() => false); + expect(matches).toEqual([]); + }); + + it('gracefully handles when no calls match', () => { + const fn = vi.fn(); + const controller = new MockController(fn); + fn(); + + const matches = controller.match(() => false); + + expect(matches).toEqual([]); + }); + }); + + describe('.verify()', () => { + it('does not throw when all calls have been expected', () => { + const fn = vi.fn(); + const controller = new MockController(fn); + + // 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 + fn(); + controller.expectOne([]); + expect(() => { + controller.verify(); + }).not.toThrow(); + }); + + it('throws if there is an outstanding call, including the number of them', () => { + const fn = vi.fn(); + const controller = new MockController(fn); + + // when multiple calls have not been expected + fn('call 1'); + fn('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 fn = vi.fn(); + const controller = new MockController(fn); + fn('call 1'); + fn('call 2'); + + expect(() => { + controller.verify(); + }).toThrow(/\n {2}\["call 1"\]\n {2}\["call 2"\]/u); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/mocks/call-tracker.ts b/projects/ng-vitest/src/lib/mocks/call-tracker.ts new file mode 100644 index 00000000..3c2c7ad7 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/call-tracker.ts @@ -0,0 +1,110 @@ +import { isDefined } from '@s-libs/js-core'; +import { isEqual, isUndefined, remove } from '@s-libs/micro-dash'; +import { Func } from '../interfaces'; +import { TestCall } from './test-call'; + +type FType> = T extends TestCall ? F : never; +export type CallMatcher> = + | Parameters> + | ((call: T) => boolean); + +export abstract class CallTracker> { + #calls: T[] = []; + #sync: () => void; + + constructor(sync: (track: (call: T) => void) => void) { + this.#sync = sync.bind(this, (call) => { + this.#calls.push(call); + }); + this.#sync(); + } + + /** + * 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. + */ + expectOne(matcher: CallMatcher, description?: string): T { + const matches = this.match(matcher); + if (matches.length !== 1) { + const userInput = stringifyUserInput(matcher, description); + throw new Error(buildErrorMessage('one matching', matches, userInput)); + } + return matches[0]; + } + + /** + * 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. + */ + expectNone(matcher: CallMatcher, description?: string): void { + const matches = this.match(matcher); + if (matches.length > 0) { + const userInput = stringifyUserInput(matcher, description); + throw new Error(buildErrorMessage('zero matching', matches, userInput)); + } + } + + /** + * Search for calls that match the given condition, without any expectations. + */ + match(matcher: CallMatcher): T[] { + this.#sync(); + let filterFn: (call: T) => boolean; + if (Array.isArray(matcher)) { + filterFn = (call): boolean => isEqual(call.getArgs(), matcher); + } else { + filterFn = matcher; + } + return remove(this.#calls, filterFn); + } + + /** + * Verify that no unmatched calls are outstanding. + * + * If any calls are outstanding, fail with an error message indicating which calls were not handled. + */ + verify(): void { + this.#sync(); + if (this.#calls.length) { + let message = buildErrorMessage('no open', this.#calls); + message += ':'; + for (const call of this.#calls) { + message += `\n ${stringifyArgs(call.getArgs())}`; + } + throw new Error(message); + } + } +} + +function buildErrorMessage( + matchType: string, + matches: unknown[], + stringifiedUserInput?: string, +): string { + let message = `Expected ${matchType} call(s)`; + if (isDefined(stringifiedUserInput)) { + message += ` for criterion "${stringifiedUserInput}"`; + } + message += `, found ${matches.length}`; + return message; +} + +function 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/mocks/create-mock-object.spec.ts b/projects/ng-vitest/src/lib/mocks/create-mock-object.spec.ts new file mode 100644 index 00000000..a4a576f7 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/create-mock-object.spec.ts @@ -0,0 +1,63 @@ +import { expectTypeOf } from 'expect-type'; +import { Mock } from 'vitest'; +import { staticTest } from '../static-test/static-test'; +import { createMockObject } from './create-mock-object'; +import { expectSingleCallAndReset } from './expect-single-call-and-reset'; +import { MockController } from './mock-controller'; + +class Superclass { + a(): string { + return 'return a'; + } + + b(): string { + return 'return b'; + } +} + +class Subclass extends Superclass { + override b(): string { + return 'override b'; + } + + c(arg: string): string { + return `received ${arg}`; + } +} + +describe('createMockObject()', () => { + it('mocks methods up the class hierarchy', () => { + const obj = createMockObject(Subclass); + obj.a.mockReturnValue('stubbed a'); + obj.b.mockReturnValue('stubbed b'); + obj.c('my arg'); + + expect(obj.a()).toBe('stubbed a'); + expect(obj.b()).toBe('stubbed b'); + expectSingleCallAndReset(obj.c, 'my arg'); + }); + + it('works for the example in the docs', () => { + 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'); + }); + + it('has fancy typing', () => { + staticTest(() => { + const mockDate = createMockObject(Date); + expectTypeOf(mockDate.getUTCDate).toExtend(); + expectTypeOf(mockDate.getUTCDate).toExtend>(); + expectTypeOf(mockDate.getUTCDate.controller).toEqualTypeOf< + MockController + >(); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/mocks/create-mock-object.ts b/projects/ng-vitest/src/lib/mocks/create-mock-object.ts new file mode 100644 index 00000000..db411250 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/create-mock-object.ts @@ -0,0 +1,47 @@ +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 + +/** + * Return type of {@linkcode createMockObject}. + */ +export type MockObject = { + [K in keyof T]: T[K] extends (...args: any[]) => any + ? Mock & { controller: MockController } + : never; +}; + +/** + * 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 { + * 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'); + * ``` + */ +export function createMockObject(type: Type): MockObject { + 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]); + } + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return mock; +} 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..f94b06df --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/expect-single-call-and-reset.spec.ts @@ -0,0 +1,38 @@ +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(); + }); + + it('requires exactly one call', () => { + const mock = vi.fn(); + expect(() => { + expectSingleCallAndReset(mock); + }).toThrow(); + + mock(); + mock(); + expect(() => { + expectSingleCallAndReset(mock); + }).toThrow(); + }); +}); 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..e6156b9a --- /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 vitest mock, for it to have received the given arguments, then clears the mock. + * + * ```ts + * 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 + * ``` + */ +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/index.ts b/projects/ng-vitest/src/lib/mocks/index.ts new file mode 100644 index 00000000..c6d600b6 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/index.ts @@ -0,0 +1,6 @@ +export { AsyncMethodController } from './async-method-controller'; +export { AsyncTestCall } from './async-test-call'; +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/mock-controller.spec.ts b/projects/ng-vitest/src/lib/mocks/mock-controller.spec.ts new file mode 100644 index 00000000..7e13f077 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/mock-controller.spec.ts @@ -0,0 +1,43 @@ +import { staticTest } from '../static-test/static-test'; +import { MockController } from './mock-controller'; +import { TestCall } from './test-call'; + +describe('MockController', () => { + it('correctly initializes TestCall objects even after others have been matched', () => { + const mock = vi.fn(); + const controller = new MockController(mock); + controller.verify(); + + // make call1, causing: + // testCalls: [call1] + // mock.calls: [call1] + mock('call1'); + + // match call1, causing: + // testCalls: [] + // mock.calls: [call1] + controller.expectOne(() => true); + + // make call2, causing: + // testCalls: [call2] + // mock.calls: [call1, call2] + mock('call2'); + + // try matching call2 + const testCall = controller.expectOne(() => true); + expect(testCall.getArgs()[0]).toBe('call2'); + }); + + it('has fancy typing', () => { + staticTest(() => { + const datePred = new MockController(vi.fn<(arg: Date) => boolean>()); + datePred.expectOne([new Date()]); + // @ts-expect-error -- require exact tuple [Date] + datePred.expectOne([]); + datePred.expectOne((call) => { + expectTypeOf(call).toEqualTypeOf boolean>>(); + return true; + }); + }); + }); +}); diff --git a/projects/ng-vitest/src/lib/mocks/mock-controller.ts b/projects/ng-vitest/src/lib/mocks/mock-controller.ts new file mode 100644 index 00000000..edb0d14a --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/mock-controller.ts @@ -0,0 +1,21 @@ +import { Mock } from '@vitest/spy'; +import { Func } from '../interfaces'; +import { CallTracker } from './call-tracker'; +import { TestCall } from './test-call'; + +/** + * Provides expectations and matching around a vitest mock that will be familiar to users of Angular's {@linkcode https://angular.dev/api/common/http/testing/HttpTestingController | HttpTestingController}. + */ +export class MockController extends CallTracker> { + /** + * **Warning:** Do not clear the history of the passed-in mock. If you do, this controller will misbehave. + */ + constructor(mock: Mock) { + let numTracked = 0; + super((track) => { + for (; numTracked < mock.mock.calls.length; ++numTracked) { + track(new TestCall(mock, numTracked)); + } + }); + } +} 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..d7bf7a00 --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/test-call.spec.ts @@ -0,0 +1,23 @@ +import { expectTypeOf } from 'expect-type'; +import { MockResult, MockSettledResult } from 'vitest'; +import { staticTest } from '../static-test/static-test'; +import { MockController } from './mock-controller'; + +describe('TestCall', () => { + it('has fancy typing', () => { + staticTest(() => { + const controller = new MockController( + vi.fn<(this: URL, arg: string) => Date>(), + ); + const testCall = controller.expectOne(['']); + + expectTypeOf(testCall.getArgs()).toEqualTypeOf<[string]>(); + expectTypeOf(testCall.getInstance()).toEqualTypeOf(); + expectTypeOf(testCall.getContext()).toEqualTypeOf(); + expectTypeOf(testCall.getResult()).toEqualTypeOf>(); + expectTypeOf(testCall.getSettledResult()).toEqualTypeOf< + MockSettledResult + >(); + }); + }); +}); 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..3b9508dc --- /dev/null +++ b/projects/ng-vitest/src/lib/mocks/test-call.ts @@ -0,0 +1,61 @@ +import { + Mock, + MockParameters, + MockProcedureContext, + MockResult, + MockReturnType, + MockSettledResult, +} from '@vitest/spy'; +import { Func } from '../interfaces'; + +/** + * Collects all the information about a single call to a vitest mock into a single object. + */ +export class TestCall { + constructor( + private mock: Mock, + private index: number, + ) {} + + /** + * See {@linkcode Mock.mock.calls} + */ + getArgs(): MockParameters { + return this.mock.mock.calls[this.index]; + } + + /** + * See {@linkcode Mock.mock.instances} + */ + getInstance(): MockProcedureContext { + return this.mock.mock.instances[this.index]; + } + + /** + * See {@linkcode Mock.mock.contexts} + */ + getContext(): MockProcedureContext { + return this.mock.mock.contexts[this.index]; + } + + /** + * See {@linkcode Mock.mock.invocationCallOrder} + */ + getInvocationCallOrder(): number { + return this.mock.mock.invocationCallOrder[this.index]; + } + + /** + * See {@linkcode Mock.mock.results} + */ + getResult(): MockResult> { + return this.mock.mock.results[this.index]; + } + + /** + * See {@linkcode Mock.mock.settledResults} + */ + getSettledResult(): MockSettledResult>> { + return this.mock.mock.settledResults[this.index]; + } +} 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..7236f46f --- /dev/null +++ b/projects/ng-vitest/src/lib/static-test/static-test.ts @@ -0,0 +1,19 @@ +/** + * 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..e5cda318 --- /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/multiple 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/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/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..841bce07 --- /dev/null +++ b/projects/ng-vitest/src/lib/test-request/sl-test-request.ts @@ -0,0 +1,70 @@ +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()] }); + * 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'); + * }); + * ``` + */ +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'; diff --git a/projects/ng-vitest/src/public-api.ts b/projects/ng-vitest/src/public-api.ts new file mode 100644 index 00000000..622d7bc4 --- /dev/null +++ b/projects/ng-vitest/src/public-api.ts @@ -0,0 +1,11 @@ +/* + * Public API Surface of 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'; diff --git a/projects/ng-vitest/tsconfig.lib.json b/projects/ng-vitest/tsconfig.lib.json new file mode 100644 index 00000000..dddad6ba --- /dev/null +++ b/projects/ng-vitest/tsconfig.lib.json @@ -0,0 +1,16 @@ +/* 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": [ + // 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.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..48fcc2fd --- /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": ["vitest/globals"] + }, + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] +} diff --git a/projects/ng-vitest/vitest-base.config.ts b/projects/ng-vitest/vitest-base.config.ts new file mode 100644 index 00000000..65e8c297 --- /dev/null +++ b/projects/ng-vitest/vitest-base.config.ts @@ -0,0 +1,9 @@ +// Learn more about Vitest configuration options at https://vitest.dev/config/ + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + restoreMocks: true, + }, +}); diff --git a/scripts/build-libs.ts b/scripts/build-libs.ts index f3352f77..cdf58da7 100644 --- a/scripts/build-libs.ts +++ b/scripts/build-libs.ts @@ -1,4 +1,4 @@ -import { buildableLibraries, runCommand } from './shared'; +import { buildableLibraries, runCommand } from './shared.ts'; for (const project of buildableLibraries) { runCommand(`npm run build -- ${project}`); diff --git a/scripts/bump-peer-dependencies.ts b/scripts/bump-peer-dependencies.ts index 9af63d30..43533117 100644 --- a/scripts/bump-peer-dependencies.ts +++ b/scripts/bump-peer-dependencies.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from 'fs'; import { format } from 'prettier'; -import { libraries } from './shared'; +import { libraries } from './shared.ts'; run(); diff --git a/scripts/docs-libs.ts b/scripts/docs-libs.ts index 142254a2..46b0c87b 100644 --- a/scripts/docs-libs.ts +++ b/scripts/docs-libs.ts @@ -1,4 +1,4 @@ -import { buildableLibraries, runCommand } from './shared'; +import { buildableLibraries, runCommand } from './shared.ts'; let projects = process.argv.slice(2); if (projects.length === 0) { diff --git a/scripts/dtslint-all.ts b/scripts/dtslint-all.ts index ec5c6a85..77e28cfe 100644 --- a/scripts/dtslint-all.ts +++ b/scripts/dtslint-all.ts @@ -1,5 +1,5 @@ import { glob } from 'glob'; -import { runCommand } from './shared'; +import { runCommand } from './shared.ts'; for (const path of glob.sync('projects/*/src/typing-tests/')) { runCommand(`npm run dtslint -- ${path}`); diff --git a/scripts/publish-libs.ts b/scripts/publish-libs.ts index 1ac5c09e..948fb3ce 100644 --- a/scripts/publish-libs.ts +++ b/scripts/publish-libs.ts @@ -1,6 +1,6 @@ -import { copyEslintConfig } from './copy-eslint-config'; -import { getInput, libraries, runCommand } from './shared'; import { join } from 'path'; +import { copyEslintConfig } from './copy-eslint-config.ts'; +import { getInput, libraries, runCommand } from './shared.ts'; async function run(): Promise { copyEslintConfig(); diff --git a/scripts/shared.ts b/scripts/shared.ts index 22ef5fdc..8b665776 100644 --- a/scripts/shared.ts +++ b/scripts/shared.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execSync } from 'node:child_process'; import { createInterface } from 'readline'; // in dependency order @@ -13,6 +13,7 @@ export const buildableLibraries = [ 'signal-store', 'ng-dev', 'ng-jasmine', + 'ng-vitest', ]; export const libraries = [...buildableLibraries, 'eslint-config-ng']; diff --git a/scripts/test-all.ts b/scripts/test-all.ts index cdc1bfb1..33f692ec 100644 --- a/scripts/test-all.ts +++ b/scripts/test-all.ts @@ -5,11 +5,17 @@ // ``` // So this script is a workaround. It runs the tests on one project at a time. -import { buildableLibraries, runCommand } from './shared'; +import { buildableLibraries, runCommand } from './shared.ts'; + +const vitestLibs = new Set(['ng-vitest']); const testableProjects = [...buildableLibraries, 'integration']; for (const project of testableProjects) { - runCommand( - `npm run test -- ${project} --no-watch --no-progress --browsers=ChromeHeadless`, - ); + if (vitestLibs.has(project)) { + runCommand(`npm run test -- ${project}`); + } else { + runCommand( + `npm run test -- ${project} --no-watch --no-progress --browsers=ChromeHeadless`, + ); + } } diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 00000000..603a2d85 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index bfef5721..5848f998 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,6 +37,7 @@ }, "files": [], "references": [ + { "path": "./scripts/tsconfig.json" }, { "path": "./projects/app-state/tsconfig.lib.json" }, { "path": "./projects/app-state/tsconfig.spec.json" }, { "path": "./projects/integration/tsconfig.app.json" }, @@ -56,6 +58,8 @@ { "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/ng-vitest/tsconfig.lib.json" }, + { "path": "./projects/ng-vitest/tsconfig.spec.json" }, { "path": "./projects/rxjs-core/tsconfig.lib.json" }, { "path": "./projects/rxjs-core/tsconfig.spec.json" }, { "path": "./projects/signal-store/tsconfig.lib.json" },