Compare commits

...

8 Commits

14 changed files with 2452 additions and 40 deletions

View File

@@ -1,5 +1,37 @@
# Changelog # Changelog
## 2025-10-10 - 2.4.2 - fix(deno)
Enable additional Deno permissions for runtime adapters and add local dev settings
- Add --allow-sys, --allow-import and --node-modules-dir to the default Deno permission set used by the Deno runtime adapter
- Include the new permission flags in the fallback permissions array when constructing Deno command args
- Add .claude/settings.local.json to capture local development permissions and helper commands
## 2025-10-10 - 2.4.1 - fix(runtime/deno)
Enable Deno runtime tests by adding required permissions and local settings
- ts/tstest.classes.runtime.deno.ts: expanded default Deno permissions to include --allow-net, --allow-write and --sloppy-imports to allow network access, file writes and permissive JS/TS imports
- ts/tstest.classes.runtime.deno.ts: updated fallback permissions used when building the Deno command to match the new default set
- Added .claude/settings.local.json with a set of allowed local commands/permissions used for local development/CI tooling
## 2025-10-10 - 2.4.0 - feat(runtime)
Add runtime adapters, filename runtime parser and migration tool; integrate runtime selection into TsTest and add tests
- Introduce RuntimeAdapter abstraction and RuntimeAdapterRegistry to manage multiple runtimes
- Add runtime adapters: NodeRuntimeAdapter, ChromiumRuntimeAdapter, DenoRuntimeAdapter and BunRuntimeAdapter
- Add filename runtime parser utilities: parseTestFilename, isLegacyFilename and getLegacyMigrationTarget
- Add Migration class to detect and (dry-run) migrate legacy test filenames to the new naming convention
- Integrate runtime registry into TsTest and choose execution adapters based on parsed runtimes; show deprecation warnings for legacy naming
- Add tests covering runtime parsing and migration: test/test.runtime.parser.node.ts and test/test.migration.node.ts
## 2025-09-12 - 2.3.8 - fix(tstest)
Improve free port selection for Chrome runner and bump smartnetwork dependency
- Use randomized port selection when finding free HTTP and WebSocket ports to reduce collision probability in concurrent runs
- Ensure WebSocket port search excludes the chosen HTTP port so the two ports will not conflict
- Simplify failure handling: throw early if a free WebSocket port cannot be found instead of retrying with a less robust fallback
- Bump @push.rocks/smartnetwork dependency from ^4.2.0 to ^4.4.0 to pick up new findFreePort options
## 2025-09-12 - 2.3.7 - fix(tests) ## 2025-09-12 - 2.3.7 - fix(tests)
Remove flaky dynamic-ports browser test and add local dev tool settings Remove flaky dynamic-ports browser test and add local dev tool settings

View File

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tstest", "name": "@git.zone/tstest",
"version": "2.3.7", "version": "2.4.2",
"private": false, "private": false,
"description": "a test utility to run tests that match test/**/*.ts", "description": "a test utility to run tests that match test/**/*.ts",
"exports": { "exports": {
@@ -43,7 +43,7 @@
"@push.rocks/smartjson": "^5.0.20", "@push.rocks/smartjson": "^5.0.20",
"@push.rocks/smartlog": "^3.1.9", "@push.rocks/smartlog": "^3.1.9",
"@push.rocks/smartmongo": "^2.0.12", "@push.rocks/smartmongo": "^2.0.12",
"@push.rocks/smartnetwork": "^4.2.0", "@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^4.3.1", "@push.rocks/smartrequest": "^4.3.1",

302
pnpm-lock.yaml generated
View File

@@ -54,8 +54,8 @@ importers:
specifier: ^2.0.12 specifier: ^2.0.12
version: 2.0.12(socks@2.8.7) version: 2.0.12(socks@2.8.7)
'@push.rocks/smartnetwork': '@push.rocks/smartnetwork':
specifier: ^4.2.0 specifier: ^4.4.0
version: 4.2.0 version: 4.4.0
'@push.rocks/smartpath': '@push.rocks/smartpath':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
@@ -617,6 +617,9 @@ packages:
resolution: {integrity: sha512-dOOXgzqaDoHu5qqMEPLKEgLz5CeIA7q8+1W62mCvFVCOqeC71UoTGJ4u1xUSOpIl2J1x2pqrNULkFteUeZW3/A==} resolution: {integrity: sha512-dOOXgzqaDoHu5qqMEPLKEgLz5CeIA7q8+1W62mCvFVCOqeC71UoTGJ4u1xUSOpIl2J1x2pqrNULkFteUeZW3/A==}
engines: {node: '>= 8.0.0'} engines: {node: '>= 8.0.0'}
'@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
'@lit-labs/ssr-dom-shim@1.4.0': '@lit-labs/ssr-dom-shim@1.4.0':
resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==} resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==}
@@ -679,6 +682,39 @@ packages:
'@pdf-lib/upng@1.0.1': '@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@peculiar/asn1-cms@2.5.0':
resolution: {integrity: sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A==}
'@peculiar/asn1-csr@2.5.0':
resolution: {integrity: sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ==}
'@peculiar/asn1-ecc@2.5.0':
resolution: {integrity: sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg==}
'@peculiar/asn1-pfx@2.5.0':
resolution: {integrity: sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug==}
'@peculiar/asn1-pkcs8@2.5.0':
resolution: {integrity: sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw==}
'@peculiar/asn1-pkcs9@2.5.0':
resolution: {integrity: sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A==}
'@peculiar/asn1-rsa@2.5.0':
resolution: {integrity: sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q==}
'@peculiar/asn1-schema@2.5.0':
resolution: {integrity: sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==}
'@peculiar/asn1-x509-attr@2.5.0':
resolution: {integrity: sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A==}
'@peculiar/asn1-x509@2.5.0':
resolution: {integrity: sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ==}
'@peculiar/x509@1.14.0':
resolution: {integrity: sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==}
'@pnpm/config.env-replace@1.1.0': '@pnpm/config.env-replace@1.1.0':
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
@@ -759,6 +795,9 @@ packages:
'@push.rocks/smartdelay@3.0.5': '@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==} resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
'@push.rocks/smartdns@7.6.1':
resolution: {integrity: sha512-nnP5+A2GOt0WsHrYhtKERmjdEHUchc+QbCCBEqlyeQTn+mNfx2WZvKVI1DFRJt8lamvzxP6Hr/BSe3WHdh4Snw==}
'@push.rocks/smartenv@5.0.13': '@push.rocks/smartenv@5.0.13':
resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==} resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==}
@@ -816,8 +855,8 @@ packages:
'@push.rocks/smartmongo@2.0.12': '@push.rocks/smartmongo@2.0.12':
resolution: {integrity: sha512-NglYiO14BikxnlvW6JF18FtopBtaWQEGAtPxHmmSCbyhU8Mi0aEFO7VgCasE9Kguba/wcR597qhcDEdcpBg1eQ==} resolution: {integrity: sha512-NglYiO14BikxnlvW6JF18FtopBtaWQEGAtPxHmmSCbyhU8Mi0aEFO7VgCasE9Kguba/wcR597qhcDEdcpBg1eQ==}
'@push.rocks/smartnetwork@4.2.0': '@push.rocks/smartnetwork@4.4.0':
resolution: {integrity: sha512-XkRE2hQFCxUKzeD54MhbWlZAEVGyOhjU68A0zP9r3wsVjsVRqz38PwoM2GrhMrW4gtPa1j1t6cEUv1WNTwfo/Q==} resolution: {integrity: sha512-OvFtz41cvQ7lcXwaIOhghNUUlNoMxvwKDctbDvMyuZyEH08SpLjhyv2FuKbKL/mgwA/WxakTbohoC8SW7t+kiw==}
'@push.rocks/smartnpm@2.0.6': '@push.rocks/smartnpm@2.0.6':
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==} resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
@@ -1390,6 +1429,9 @@ packages:
'@tybys/wasm-util@0.10.0': '@tybys/wasm-util@0.10.0':
resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==}
'@types/bn.js@5.2.0':
resolution: {integrity: sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -1408,6 +1450,12 @@ packages:
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/dns-packet@5.6.5':
resolution: {integrity: sha512-qXOC7XLOEe43ehtWJCMnQXvgcIpv6rPmQ1jXT98Ad8A3TB1Ue50jsCbSSSyuazScEuZ/Q026vHbrOTVkmwA+7Q==}
'@types/elliptic@6.4.18':
resolution: {integrity: sha512-UseG6H5vjRiNpQvrhy4VF/JXdA3V/Fp5amvveaL+fs28BZ6xIKJBPnUPRlEaZpysD9MbpfaLi8lbl7PGUAkpWw==}
'@types/express-serve-static-core@5.0.7': '@types/express-serve-static-core@5.0.7':
resolution: {integrity: sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==} resolution: {integrity: sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==}
@@ -1539,6 +1587,10 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
acme-client@5.4.0:
resolution: {integrity: sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==}
engines: {node: '>= 16'}
agent-base@7.1.4: agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -1581,6 +1633,10 @@ packages:
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
asn1js@3.0.6:
resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==}
engines: {node: '>=12.0.0'}
ast-types@0.13.4: ast-types@0.13.4:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1594,6 +1650,9 @@ packages:
asynckit@0.4.0: asynckit@0.4.0:
resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=} resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=}
axios@1.12.1:
resolution: {integrity: sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==}
b4a@1.6.7: b4a@1.6.7:
resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
@@ -1644,6 +1703,9 @@ packages:
resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
bn.js@4.12.2:
resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==}
body-parser@2.2.0: body-parser@2.2.0:
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1663,6 +1725,9 @@ packages:
broadcast-channel@7.1.0: broadcast-channel@7.1.0:
resolution: {integrity: sha512-InJljddsYWbEL8LBnopnCg+qMQp9KcowvYWOt4YWrjD5HmxzDYKdVbDS1w/ji5rFZdRD58V5UxJPtBdpEbEJYw==} resolution: {integrity: sha512-InJljddsYWbEL8LBnopnCg+qMQp9KcowvYWOt4YWrjD5HmxzDYKdVbDS1w/ji5rFZdRD58V5UxJPtBdpEbEJYw==}
brorand@1.1.0:
resolution: {integrity: sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=}
bson@6.10.4: bson@6.10.4:
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
engines: {node: '>=16.20.1'} engines: {node: '>=16.20.1'}
@@ -1953,6 +2018,10 @@ packages:
resolution: {integrity: sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==} resolution: {integrity: sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==}
engines: {node: '>=4.5.0'} engines: {node: '>=4.5.0'}
dns-packet@5.6.1:
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
engines: {node: '>=6'}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1963,6 +2032,9 @@ packages:
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
elliptic@6.6.1:
resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==}
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -2293,6 +2365,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
hasown@2.0.2: hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2310,6 +2385,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
hmac-drbg@1.0.1:
resolution: {integrity: sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=}
html-minifier@4.0.0: html-minifier@4.0.0:
resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==} resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2803,6 +2881,12 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'} engines: {node: '>=4'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=}
minimatch@10.0.3: minimatch@10.0.3:
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -3111,6 +3195,13 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
qs@6.14.0: qs@6.14.0:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@@ -3143,6 +3234,9 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
regenerator-runtime@0.14.1: regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
@@ -3484,6 +3578,10 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
tsyringe@4.10.0:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
turndown-plugin-gfm@1.0.2: turndown-plugin-gfm@1.0.2:
resolution: {integrity: sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==} resolution: {integrity: sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==}
@@ -4918,6 +5016,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@leichtgewicht/ip-codec@2.0.5': {}
'@lit-labs/ssr-dom-shim@1.4.0': {} '@lit-labs/ssr-dom-shim@1.4.0': {}
'@lit/reactive-element@2.1.1': '@lit/reactive-element@2.1.1':
@@ -4991,6 +5091,96 @@ snapshots:
dependencies: dependencies:
pako: 1.0.11 pako: 1.0.11
'@peculiar/asn1-cms@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
'@peculiar/asn1-x509-attr': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-csr@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-ecc@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-pfx@2.5.0':
dependencies:
'@peculiar/asn1-cms': 2.5.0
'@peculiar/asn1-pkcs8': 2.5.0
'@peculiar/asn1-rsa': 2.5.0
'@peculiar/asn1-schema': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.5.0':
dependencies:
'@peculiar/asn1-cms': 2.5.0
'@peculiar/asn1-pfx': 2.5.0
'@peculiar/asn1-pkcs8': 2.5.0
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
'@peculiar/asn1-x509-attr': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-rsa@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-schema@2.5.0':
dependencies:
asn1js: 3.0.6
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-x509@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
asn1js: 3.0.6
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/x509@1.14.0':
dependencies:
'@peculiar/asn1-cms': 2.5.0
'@peculiar/asn1-csr': 2.5.0
'@peculiar/asn1-ecc': 2.5.0
'@peculiar/asn1-pkcs9': 2.5.0
'@peculiar/asn1-rsa': 2.5.0
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.10.0
'@pnpm/config.env-replace@1.1.0': {} '@pnpm/config.env-replace@1.1.0': {}
'@pnpm/network.ca-file@1.0.2': '@pnpm/network.ca-file@1.0.2':
@@ -5227,6 +5417,22 @@ snapshots:
dependencies: dependencies:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartdns@7.6.1':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartenv': 5.0.13
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 2.1.0
'@tsclass/tsclass': 9.2.0
'@types/dns-packet': 5.6.5
'@types/elliptic': 6.4.18
acme-client: 5.4.0
dns-packet: 5.6.1
elliptic: 6.6.1
minimatch: 10.0.3
transitivePeerDependencies:
- supports-color
'@push.rocks/smartenv@5.0.13': '@push.rocks/smartenv@5.0.13':
dependencies: dependencies:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
@@ -5367,13 +5573,16 @@ snapshots:
- socks - socks
- supports-color - supports-color
'@push.rocks/smartnetwork@4.2.0': '@push.rocks/smartnetwork@4.4.0':
dependencies: dependencies:
'@push.rocks/smartdns': 7.6.1
'@push.rocks/smartping': 1.0.8 '@push.rocks/smartping': 1.0.8
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartstring': 4.0.15 '@push.rocks/smartstring': 4.0.15
isopen: 1.3.0 isopen: 1.3.0
systeminformation: 5.27.8 systeminformation: 5.27.8
transitivePeerDependencies:
- supports-color
'@push.rocks/smartnpm@2.0.6': '@push.rocks/smartnpm@2.0.6':
dependencies: dependencies:
@@ -5421,7 +5630,7 @@ snapshots:
'@push.rocks/smartbuffer': 3.0.5 '@push.rocks/smartbuffer': 3.0.5
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfile': 11.2.7 '@push.rocks/smartfile': 11.2.7
'@push.rocks/smartnetwork': 4.2.0 '@push.rocks/smartnetwork': 4.4.0
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartpuppeteer': 2.0.5(typescript@5.9.2) '@push.rocks/smartpuppeteer': 2.0.5(typescript@5.9.2)
@@ -6282,6 +6491,10 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/bn.js@5.2.0':
dependencies:
'@types/node': 22.17.2
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
dependencies: dependencies:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
@@ -6306,6 +6519,14 @@ snapshots:
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/dns-packet@5.6.5':
dependencies:
'@types/node': 22.17.2
'@types/elliptic@6.4.18':
dependencies:
'@types/bn.js': 5.2.0
'@types/express-serve-static-core@5.0.7': '@types/express-serve-static-core@5.0.7':
dependencies: dependencies:
'@types/node': 22.17.2 '@types/node': 22.17.2
@@ -6446,6 +6667,16 @@ snapshots:
mime-types: 3.0.1 mime-types: 3.0.1
negotiator: 1.0.0 negotiator: 1.0.0
acme-client@5.4.0:
dependencies:
'@peculiar/x509': 1.14.0
asn1js: 3.0.6
axios: 1.12.1(debug@4.4.1)
debug: 4.4.1
node-forge: 1.3.1
transitivePeerDependencies:
- supports-color
agent-base@7.1.4: {} agent-base@7.1.4: {}
agentkeepalive@4.6.0: agentkeepalive@4.6.0:
@@ -6476,6 +6707,12 @@ snapshots:
argparse@2.0.1: {} argparse@2.0.1: {}
asn1js@3.0.6:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.3
tslib: 2.8.1
ast-types@0.13.4: ast-types@0.13.4:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -6488,6 +6725,14 @@ snapshots:
asynckit@0.4.0: {} asynckit@0.4.0: {}
axios@1.12.1(debug@4.4.1):
dependencies:
follow-redirects: 1.15.11(debug@4.4.1)
form-data: 4.0.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
b4a@1.6.7: {} b4a@1.6.7: {}
bail@2.0.2: {} bail@2.0.2: {}
@@ -6525,6 +6770,8 @@ snapshots:
basic-ftp@5.0.5: {} basic-ftp@5.0.5: {}
bn.js@4.12.2: {}
body-parser@2.2.0: body-parser@2.2.0:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@@ -6559,6 +6806,8 @@ snapshots:
p-queue: 6.6.2 p-queue: 6.6.2
unload: 2.4.1 unload: 2.4.1
brorand@1.1.0: {}
bson@6.10.4: {} bson@6.10.4: {}
buffer-crc32@0.2.13: {} buffer-crc32@0.2.13: {}
@@ -6820,6 +7069,10 @@ snapshots:
dependencies: dependencies:
streamsearch: 0.1.2 streamsearch: 0.1.2
dns-packet@5.6.1:
dependencies:
'@leichtgewicht/ip-codec': 2.0.5
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -6830,6 +7083,16 @@ snapshots:
ee-first@1.1.1: {} ee-first@1.1.1: {}
elliptic@6.6.1:
dependencies:
bn.js: 4.12.2
brorand: 1.1.0
hash.js: 1.1.7
hmac-drbg: 1.0.1
inherits: 2.0.4
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}
@@ -7236,6 +7499,11 @@ snapshots:
dependencies: dependencies:
has-symbols: 1.1.0 has-symbols: 1.1.0
hash.js@1.1.7:
dependencies:
inherits: 2.0.4
minimalistic-assert: 1.0.1
hasown@2.0.2: hasown@2.0.2:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
@@ -7266,6 +7534,12 @@ snapshots:
he@1.2.0: {} he@1.2.0: {}
hmac-drbg@1.0.1:
dependencies:
hash.js: 1.1.7
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
html-minifier@4.0.0: html-minifier@4.0.0:
dependencies: dependencies:
camel-case: 3.0.0 camel-case: 3.0.0
@@ -7945,6 +8219,10 @@ snapshots:
min-indent@1.0.1: {} min-indent@1.0.1: {}
minimalistic-assert@1.0.1: {}
minimalistic-crypto-utils@1.0.1: {}
minimatch@10.0.3: minimatch@10.0.3:
dependencies: dependencies:
'@isaacs/brace-expansion': 5.0.0 '@isaacs/brace-expansion': 5.0.0
@@ -8257,6 +8535,12 @@ snapshots:
- typescript - typescript
- utf-8-validate - utf-8-validate
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.3: {}
qs@6.14.0: qs@6.14.0:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0
@@ -8293,6 +8577,8 @@ snapshots:
readdirp@4.1.2: {} readdirp@4.1.2: {}
reflect-metadata@0.2.2: {}
regenerator-runtime@0.14.1: {} regenerator-runtime@0.14.1: {}
registry-auth-token@5.1.0: registry-auth-token@5.1.0:
@@ -8745,6 +9031,10 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
tsyringe@4.10.0:
dependencies:
tslib: 1.14.1
turndown-plugin-gfm@1.0.2: {} turndown-plugin-gfm@1.0.2: {}
turndown@7.2.1: turndown@7.2.1:

111
test/test.migration.node.ts Normal file
View File

@@ -0,0 +1,111 @@
import { expect, tap } from '../ts_tapbundle/index.js';
import { Migration } from '../ts/tstest.classes.migration.js';
import * as plugins from '../ts/tstest.plugins.js';
import * as paths from '../ts/tstest.paths.js';
tap.test('Migration - can initialize', async () => {
const migration = new Migration({
baseDir: process.cwd(),
dryRun: true,
});
expect(migration).toBeInstanceOf(Migration);
});
tap.test('Migration - findLegacyFiles returns empty for no legacy files', async () => {
const migration = new Migration({
baseDir: process.cwd(),
pattern: 'test/test.migration.node.ts', // This file itself, not legacy
dryRun: true,
});
const legacyFiles = await migration.findLegacyFiles();
expect(legacyFiles).toEqual([]);
});
tap.test('Migration - generateReport works', async () => {
const migration = new Migration({
baseDir: process.cwd(),
dryRun: true,
});
const report = await migration.generateReport();
expect(report).toBeTypeOf('string');
expect(report).toContain('Test File Migration Report');
});
tap.test('Migration - detects legacy files when they exist', async () => {
// Create a temporary legacy test file
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration');
await plugins.smartfile.fs.ensureEmptyDir(tempDir);
const legacyFile = plugins.path.join(tempDir, 'test.browser.ts');
await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile);
const migration = new Migration({
baseDir: tempDir,
pattern: '**/*.ts',
dryRun: true,
});
const legacyFiles = await migration.findLegacyFiles();
expect(legacyFiles.length).toEqual(1);
expect(legacyFiles[0]).toContain('test.browser.ts');
// Clean up
await plugins.smartfile.fs.removeSync(tempDir);
});
tap.test('Migration - detects both legacy pattern', async () => {
// Create temporary legacy files
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration_both');
await plugins.smartfile.fs.ensureEmptyDir(tempDir);
const browserFile = plugins.path.join(tempDir, 'test.browser.ts');
const bothFile = plugins.path.join(tempDir, 'test.both.ts');
await plugins.smartfile.memory.toFs('// Browser test\nexport default Promise.resolve();', browserFile);
await plugins.smartfile.memory.toFs('// Both test\nexport default Promise.resolve();', bothFile);
const migration = new Migration({
baseDir: tempDir,
pattern: '**/*.ts',
dryRun: true,
});
const legacyFiles = await migration.findLegacyFiles();
expect(legacyFiles.length).toEqual(2);
// Clean up
await plugins.smartfile.fs.removeSync(tempDir);
});
tap.test('Migration - dry run does not modify files', async () => {
// Create a temporary legacy test file
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test_migration_dryrun');
await plugins.smartfile.fs.ensureEmptyDir(tempDir);
const legacyFile = plugins.path.join(tempDir, 'test.browser.ts');
await plugins.smartfile.memory.toFs('// Legacy test file\nexport default Promise.resolve();', legacyFile);
const migration = new Migration({
baseDir: tempDir,
pattern: '**/*.ts',
dryRun: true,
verbose: false,
});
const summary = await migration.run();
expect(summary.dryRun).toEqual(true);
expect(summary.totalLegacyFiles).toEqual(1);
expect(summary.migratedCount).toEqual(1); // Dry run still counts as "would migrate"
// Verify original file still exists
const fileExists = await plugins.smartfile.fs.fileExists(legacyFile);
expect(fileExists).toEqual(true);
// Clean up
await plugins.smartfile.fs.removeSync(tempDir);
});
export default tap.start();

View File

@@ -0,0 +1,167 @@
import { expect, tap } from '../ts_tapbundle/index.js';
import { parseTestFilename, isLegacyFilename, getLegacyMigrationTarget } from '../ts/tstest.classes.runtime.parser.js';
tap.test('parseTestFilename - single runtime', async () => {
const parsed = parseTestFilename('test.node.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - chromium runtime', async () => {
const parsed = parseTestFilename('test.chromium.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - multiple runtimes', async () => {
const parsed = parseTestFilename('test.node+chromium.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - deno+bun runtime', async () => {
const parsed = parseTestFilename('test.deno+bun.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['deno', 'bun']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - with nonci modifier', async () => {
const parsed = parseTestFilename('test.chromium.nonci.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual(['nonci']);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - multi-runtime with nonci', async () => {
const parsed = parseTestFilename('test.node+chromium.nonci.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium']);
expect(parsed.modifiers).toEqual(['nonci']);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - legacy browser', async () => {
const parsed = parseTestFilename('test.browser.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(true);
});
tap.test('parseTestFilename - legacy both', async () => {
const parsed = parseTestFilename('test.both.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(true);
});
tap.test('parseTestFilename - legacy browser with nonci', async () => {
const parsed = parseTestFilename('test.browser.nonci.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual(['nonci']);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(true);
});
tap.test('parseTestFilename - complex basename', async () => {
const parsed = parseTestFilename('test.some.feature.node.ts');
expect(parsed.baseName).toEqual('test.some.feature');
expect(parsed.runtimes).toEqual(['node']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - default to node when no runtime', async () => {
const parsed = parseTestFilename('test.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - tsx extension', async () => {
const parsed = parseTestFilename('test.chromium.tsx');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['chromium']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('tsx');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('parseTestFilename - deduplicates runtime tokens', async () => {
const parsed = parseTestFilename('test.node+node.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node']);
expect(parsed.modifiers).toEqual([]);
expect(parsed.extension).toEqual('ts');
expect(parsed.isLegacy).toEqual(false);
});
tap.test('isLegacyFilename - detects browser', async () => {
expect(isLegacyFilename('test.browser.ts')).toEqual(true);
});
tap.test('isLegacyFilename - detects both', async () => {
expect(isLegacyFilename('test.both.ts')).toEqual(true);
});
tap.test('isLegacyFilename - rejects new naming', async () => {
expect(isLegacyFilename('test.node.ts')).toEqual(false);
expect(isLegacyFilename('test.chromium.ts')).toEqual(false);
expect(isLegacyFilename('test.node+chromium.ts')).toEqual(false);
});
tap.test('getLegacyMigrationTarget - browser to chromium', async () => {
const target = getLegacyMigrationTarget('test.browser.ts');
expect(target).toEqual('test.chromium.ts');
});
tap.test('getLegacyMigrationTarget - both to node+chromium', async () => {
const target = getLegacyMigrationTarget('test.both.ts');
expect(target).toEqual('test.node+chromium.ts');
});
tap.test('getLegacyMigrationTarget - browser with nonci', async () => {
const target = getLegacyMigrationTarget('test.browser.nonci.ts');
expect(target).toEqual('test.chromium.nonci.ts');
});
tap.test('getLegacyMigrationTarget - both with nonci', async () => {
const target = getLegacyMigrationTarget('test.both.nonci.ts');
expect(target).toEqual('test.node+chromium.nonci.ts');
});
tap.test('getLegacyMigrationTarget - returns null for non-legacy', async () => {
const target = getLegacyMigrationTarget('test.node.ts');
expect(target).toEqual(null);
});
tap.test('parseTestFilename - handles full paths', async () => {
const parsed = parseTestFilename('/path/to/test.node+chromium.ts');
expect(parsed.baseName).toEqual('test');
expect(parsed.runtimes).toEqual(['node', 'chromium']);
expect(parsed.original).toEqual('test.node+chromium.ts');
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tstest', name: '@git.zone/tstest',
version: '2.3.7', version: '2.4.2',
description: 'a test utility to run tests that match test/**/*.ts' description: 'a test utility to run tests that match test/**/*.ts'
} }

View File

@@ -0,0 +1,316 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import { parseTestFilename, getLegacyMigrationTarget, isLegacyFilename } from './tstest.classes.runtime.parser.js';
/**
* Migration result for a single file
*/
export interface MigrationResult {
/**
* Original file path
*/
oldPath: string;
/**
* New file path after migration
*/
newPath: string;
/**
* Whether the migration was performed
*/
migrated: boolean;
/**
* Error message if migration failed
*/
error?: string;
}
/**
* Migration summary
*/
export interface MigrationSummary {
/**
* Total number of legacy files found
*/
totalLegacyFiles: number;
/**
* Number of files successfully migrated
*/
migratedCount: number;
/**
* Number of files that failed to migrate
*/
errorCount: number;
/**
* Individual migration results
*/
results: MigrationResult[];
/**
* Whether this was a dry run
*/
dryRun: boolean;
}
/**
* Migration options
*/
export interface MigrationOptions {
/**
* Base directory to search for test files
* Default: process.cwd()
*/
baseDir?: string;
/**
* Glob pattern for finding test files
* Default: '** /*test*.ts' (without space)
*/
pattern?: string;
/**
* Dry run mode - don't actually rename files
* Default: true
*/
dryRun?: boolean;
/**
* Verbose output
* Default: false
*/
verbose?: boolean;
}
/**
* Migration class for renaming legacy test files to new naming convention
*
* Migrations:
* - .browser.ts → .chromium.ts
* - .both.ts → .node+chromium.ts
* - .both.nonci.ts → .node+chromium.nonci.ts
* - .browser.nonci.ts → .chromium.nonci.ts
*/
export class Migration {
private options: Required<MigrationOptions>;
constructor(options: MigrationOptions = {}) {
this.options = {
baseDir: options.baseDir || process.cwd(),
pattern: options.pattern || '**/test*.ts',
dryRun: options.dryRun !== undefined ? options.dryRun : true,
verbose: options.verbose || false,
};
}
/**
* Find all legacy test files in the base directory
*/
async findLegacyFiles(): Promise<string[]> {
const files = await plugins.smartfile.fs.listFileTree(
this.options.baseDir,
this.options.pattern
);
const legacyFiles: string[] = [];
for (const file of files) {
const fileName = plugins.path.basename(file);
if (isLegacyFilename(fileName)) {
const absolutePath = plugins.path.isAbsolute(file)
? file
: plugins.path.join(this.options.baseDir, file);
legacyFiles.push(absolutePath);
}
}
return legacyFiles;
}
/**
* Migrate a single file
*/
private async migrateFile(filePath: string): Promise<MigrationResult> {
const fileName = plugins.path.basename(filePath);
const dirName = plugins.path.dirname(filePath);
try {
// Get the new filename
const newFileName = getLegacyMigrationTarget(fileName);
if (!newFileName) {
return {
oldPath: filePath,
newPath: filePath,
migrated: false,
error: 'File is not a legacy file',
};
}
const newPath = plugins.path.join(dirName, newFileName);
// Check if target file already exists
if (await plugins.smartfile.fs.fileExists(newPath)) {
return {
oldPath: filePath,
newPath,
migrated: false,
error: `Target file already exists: ${newPath}`,
};
}
if (!this.options.dryRun) {
// Check if we're in a git repository
const isGitRepo = await this.isGitRepository(this.options.baseDir);
if (isGitRepo) {
// Use git mv to preserve history
const smartshell = new plugins.smartshell.Smartshell({
executor: 'bash',
pathDirectories: [],
});
const gitCommand = `cd "${this.options.baseDir}" && git mv "${filePath}" "${newPath}"`;
const result = await smartshell.exec(gitCommand);
if (result.exitCode !== 0) {
throw new Error(`git mv failed: ${result.stderr}`);
}
} else {
// Not a git repository - cannot migrate without git
throw new Error('Migration requires a git repository. We have git!');
}
}
return {
oldPath: filePath,
newPath,
migrated: true,
};
} catch (error) {
return {
oldPath: filePath,
newPath: filePath,
migrated: false,
error: error.message,
};
}
}
/**
* Check if a directory is a git repository
*/
private async isGitRepository(dir: string): Promise<boolean> {
try {
const gitDir = plugins.path.join(dir, '.git');
return await plugins.smartfile.fs.isDirectory(gitDir);
} catch {
return false;
}
}
/**
* Run the migration
*/
async run(): Promise<MigrationSummary> {
const legacyFiles = await this.findLegacyFiles();
console.log('');
console.log(cs('='.repeat(60), 'blue'));
console.log(cs('Test File Migration Tool', 'blue'));
console.log(cs('='.repeat(60), 'blue'));
console.log('');
if (this.options.dryRun) {
console.log(cs('🔍 DRY RUN MODE - No files will be modified', 'orange'));
console.log('');
}
console.log(`Found ${legacyFiles.length} legacy test file(s)`);
console.log('');
const results: MigrationResult[] = [];
let migratedCount = 0;
let errorCount = 0;
for (const file of legacyFiles) {
const result = await this.migrateFile(file);
results.push(result);
if (result.migrated) {
migratedCount++;
const oldName = plugins.path.basename(result.oldPath);
const newName = plugins.path.basename(result.newPath);
if (this.options.dryRun) {
console.log(cs(` Would migrate:`, 'cyan'));
} else {
console.log(cs(` ✓ Migrated:`, 'green'));
}
console.log(` ${oldName}`);
console.log(cs(`${newName}`, 'green'));
console.log('');
} else if (result.error) {
errorCount++;
console.log(cs(` ✗ Failed: ${plugins.path.basename(result.oldPath)}`, 'red'));
console.log(cs(` ${result.error}`, 'red'));
console.log('');
}
}
console.log(cs('='.repeat(60), 'blue'));
console.log(`Summary:`);
console.log(` Total legacy files: ${legacyFiles.length}`);
console.log(` Successfully migrated: ${migratedCount}`);
console.log(` Errors: ${errorCount}`);
console.log(cs('='.repeat(60), 'blue'));
if (this.options.dryRun && legacyFiles.length > 0) {
console.log('');
console.log(cs('To apply these changes, run:', 'orange'));
console.log(cs(' tstest migrate --write', 'orange'));
}
console.log('');
return {
totalLegacyFiles: legacyFiles.length,
migratedCount,
errorCount,
results,
dryRun: this.options.dryRun,
};
}
/**
* Create a migration report without performing the migration
*/
async generateReport(): Promise<string> {
const legacyFiles = await this.findLegacyFiles();
let report = '';
report += 'Test File Migration Report\n';
report += '='.repeat(60) + '\n';
report += '\n';
report += `Found ${legacyFiles.length} legacy test file(s)\n`;
report += '\n';
for (const file of legacyFiles) {
const fileName = plugins.path.basename(file);
const newFileName = getLegacyMigrationTarget(fileName);
if (newFileName) {
report += `${fileName}\n`;
report += `${newFileName}\n`;
report += '\n';
}
}
report += '='.repeat(60) + '\n';
return report;
}
}

View File

@@ -0,0 +1,245 @@
import * as plugins from './tstest.plugins.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
import { TapParser } from './tstest.classes.tap.parser.js';
/**
* Runtime-specific configuration options
*/
export interface RuntimeOptions {
/**
* Environment variables to pass to the runtime
*/
env?: Record<string, string>;
/**
* Additional command-line arguments
*/
extraArgs?: string[];
/**
* Working directory for test execution
*/
cwd?: string;
/**
* Timeout in milliseconds (0 = no timeout)
*/
timeout?: number;
}
/**
* Deno-specific configuration options
*/
export interface DenoOptions extends RuntimeOptions {
/**
* Permissions to grant to Deno
* Default: ['--allow-read', '--allow-env']
*/
permissions?: string[];
/**
* Path to deno.json config file
*/
configPath?: string;
/**
* Path to import map file
*/
importMap?: string;
}
/**
* Chromium-specific configuration options
*/
export interface ChromiumOptions extends RuntimeOptions {
/**
* Chromium launch arguments
*/
launchArgs?: string[];
/**
* Headless mode (default: true)
*/
headless?: boolean;
/**
* Port range for HTTP server
*/
portRange?: { min: number; max: number };
}
/**
* Command configuration returned by createCommand()
*/
export interface RuntimeCommand {
/**
* The main command executable (e.g., 'node', 'deno', 'bun')
*/
command: string;
/**
* Command-line arguments
*/
args: string[];
/**
* Environment variables
*/
env?: Record<string, string>;
/**
* Working directory
*/
cwd?: string;
}
/**
* Runtime availability check result
*/
export interface RuntimeAvailability {
/**
* Whether the runtime is available
*/
available: boolean;
/**
* Version string if available
*/
version?: string;
/**
* Error message if not available
*/
error?: string;
}
/**
* Abstract base class for runtime adapters
* Each runtime (Node, Chromium, Deno, Bun) implements this interface
*/
export abstract class RuntimeAdapter {
/**
* Runtime identifier
*/
abstract readonly id: Runtime;
/**
* Human-readable display name
*/
abstract readonly displayName: string;
/**
* Check if this runtime is available on the system
* @returns Availability information including version
*/
abstract checkAvailable(): Promise<RuntimeAvailability>;
/**
* Create the command configuration for executing a test
* @param testFile - Absolute path to the test file
* @param options - Runtime-specific options
* @returns Command configuration
*/
abstract createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand;
/**
* Execute a test file and return a TAP parser
* @param testFile - Absolute path to the test file
* @param index - Test index (for display)
* @param total - Total number of tests (for display)
* @param options - Runtime-specific options
* @returns TAP parser with test results
*/
abstract run(
testFile: string,
index: number,
total: number,
options?: RuntimeOptions
): Promise<TapParser>;
/**
* Get the default options for this runtime
* Can be overridden by subclasses
*/
protected getDefaultOptions(): RuntimeOptions {
return {
timeout: 0,
extraArgs: [],
env: {},
};
}
/**
* Merge user options with defaults
*/
protected mergeOptions<T extends RuntimeOptions>(userOptions?: T): T {
const defaults = this.getDefaultOptions();
return {
...defaults,
...userOptions,
env: { ...defaults.env, ...userOptions?.env },
extraArgs: [...(defaults.extraArgs || []), ...(userOptions?.extraArgs || [])],
} as T;
}
}
/**
* Registry for runtime adapters
* Manages all available runtime implementations
*/
export class RuntimeAdapterRegistry {
private adapters: Map<Runtime, RuntimeAdapter> = new Map();
/**
* Register a runtime adapter
*/
register(adapter: RuntimeAdapter): void {
this.adapters.set(adapter.id, adapter);
}
/**
* Get an adapter by runtime ID
*/
get(runtime: Runtime): RuntimeAdapter | undefined {
return this.adapters.get(runtime);
}
/**
* Get all registered adapters
*/
getAll(): RuntimeAdapter[] {
return Array.from(this.adapters.values());
}
/**
* Check which runtimes are available on the system
*/
async checkAvailability(): Promise<Map<Runtime, RuntimeAvailability>> {
const results = new Map<Runtime, RuntimeAvailability>();
for (const [runtime, adapter] of this.adapters) {
const availability = await adapter.checkAvailable();
results.set(runtime, availability);
}
return results;
}
/**
* Get adapters for a list of runtimes, in order
* @param runtimes - Ordered list of runtimes
* @returns Adapters in the same order, skipping any that aren't registered
*/
getAdaptersForRuntimes(runtimes: Runtime[]): RuntimeAdapter[] {
const adapters: RuntimeAdapter[] = [];
for (const runtime of runtimes) {
const adapter = this.get(runtime);
if (adapter) {
adapters.push(adapter);
}
}
return adapters;
}
}

View File

@@ -0,0 +1,219 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type RuntimeOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Bun runtime adapter
* Executes tests using the Bun runtime with native TypeScript support
*/
export class BunRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'bun';
readonly displayName: string = 'Bun';
constructor(
private logger: TsTestLogger,
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
private timeoutSeconds: number | null,
private filterTags: string[]
) {
super();
}
/**
* Check if Bun is available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
const result = await this.smartshellInstance.exec('bun --version', {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return {
available: false,
error: 'Bun not found. Install from: https://bun.sh/',
};
}
// Bun version is just the version number
const version = result.stdout.trim();
return {
available: true,
version: `Bun ${version}`,
};
} catch (error) {
return {
available: false,
error: error.message,
};
}
}
/**
* Create command configuration for Bun test execution
*/
createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options);
const args: string[] = ['run'];
// Add extra args
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
args.push(...mergedOptions.extraArgs);
}
// Add test file
args.push(testFile);
// Set environment variables
const env = { ...mergedOptions.env };
if (this.filterTags.length > 0) {
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
return {
command: 'bun',
args,
env,
cwd: mergedOptions.cwd,
};
}
/**
* Execute a test file in Bun
*/
async run(
testFile: string,
index: number,
total: number,
options?: RuntimeOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
const tapParser = new TapParser(testFile + ':bun', this.logger);
const mergedOptions = this.mergeOptions(options);
// Build Bun command
const command = this.createCommand(testFile, mergedOptions);
const fullCommand = `${command.command} ${command.args.join(' ')}`;
// Set filter tags as environment variable
if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts');
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
let runCommand = fullCommand;
let loaderPath: string | null = null;
// If 00init.ts exists, create a loader file
if (initFileExists) {
const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(testFile);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
// Rebuild command with loader file
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
// If we created a loader file, clean it up after test execution
if (loaderPath) {
const cleanup = () => {
try {
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
plugins.smartfile.fs.removeSync(loaderPath);
}
} catch (e) {
// Ignore cleanup errors
}
};
execResultStreaming.childProcess.on('exit', cleanup);
execResultStreaming.childProcess.on('error', cleanup);
}
// Start warning timer if no timeout was specified
let warningTimer: NodeJS.Timeout | null = null;
if (this.timeoutSeconds === null) {
warningTimer = setTimeout(() => {
console.error('');
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
console.error(cs(` File: ${testFile}`, 'orange'));
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
console.error('');
}, 60000); // 1 minute
}
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(async () => {
// Use smartshell's terminate() to kill entire process tree
await execResultStreaming.terminate();
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
tapParser.handleTapProcess(execResultStreaming.childProcess),
timeoutPromise
]);
// Clear timeout if test completed successfully
clearTimeout(timeoutId);
} catch (error) {
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
// Ensure entire process tree is killed if still running
try {
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
} catch (killError) {
// Process tree might already be dead
}
await tapParser.evaluateFinalResult();
}
} else {
await tapParser.handleTapProcess(execResultStreaming.childProcess);
}
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
return tapParser;
}
}

View File

@@ -0,0 +1,293 @@
import * as plugins from './tstest.plugins.js';
import * as paths from './tstest.paths.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type ChromiumOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Chromium runtime adapter
* Executes tests in a headless Chromium browser
*/
export class ChromiumRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'chromium';
readonly displayName: string = 'Chromium';
constructor(
private logger: TsTestLogger,
private tsbundleInstance: any, // TsBundle instance from @push.rocks/tsbundle
private smartbrowserInstance: any, // SmartBrowser instance from @push.rocks/smartbrowser
private timeoutSeconds: number | null
) {
super();
}
/**
* Check if Chromium is available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
// Check if smartbrowser is available and can start
// The browser binary is usually handled by @push.rocks/smartbrowser
return {
available: true,
version: 'Chromium (via smartbrowser)',
};
} catch (error) {
return {
available: false,
error: error.message || 'Chromium not available',
};
}
}
/**
* Create command configuration for Chromium test execution
* Note: Chromium tests don't use a traditional command, but this satisfies the interface
*/
createCommand(testFile: string, options?: ChromiumOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options);
return {
command: 'chromium',
args: [],
env: mergedOptions.env,
cwd: mergedOptions.cwd,
};
}
/**
* Find free ports for HTTP server and WebSocket
*/
private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> {
const smartnetwork = new plugins.smartnetwork.SmartNetwork();
// Find random free HTTP port in range 30000-40000 to minimize collision chance
const httpPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true });
if (!httpPort) {
throw new Error('Could not find a free HTTP port in range 30000-40000');
}
// Find random free WebSocket port, excluding the HTTP port to ensure they're different
const wsPort = await smartnetwork.findFreePort(30000, 40000, {
randomize: true,
exclude: [httpPort]
});
if (!wsPort) {
throw new Error('Could not find a free WebSocket port in range 30000-40000');
}
// Log selected ports for debugging
if (!this.logger.options.quiet) {
console.log(`Selected ports - HTTP: ${httpPort}, WebSocket: ${wsPort}`);
}
return { httpPort, wsPort };
}
/**
* Execute a test file in Chromium browser
*/
async run(
testFile: string,
index: number,
total: number,
options?: ChromiumOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
// lets get all our paths sorted
const tsbundleCacheDirPath = plugins.path.join(paths.cwd, './.nogit/tstest_cache');
const bundleFileName = testFile.replace('/', '__') + '.js';
const bundleFilePath = plugins.path.join(tsbundleCacheDirPath, bundleFileName);
// lets bundle the test
await plugins.smartfile.fs.ensureEmptyDir(tsbundleCacheDirPath);
await this.tsbundleInstance.build(process.cwd(), testFile, bundleFilePath, {
bundler: 'esbuild',
});
// Find free ports for HTTP and WebSocket
const { httpPort, wsPort } = await this.findFreePorts();
// lets create a server
const server = new plugins.typedserver.servertools.Server({
cors: true,
port: httpPort,
});
server.addRoute(
'/test',
new plugins.typedserver.servertools.Handler('GET', async (_req, res) => {
res.type('.html');
res.write(`
<html>
<head>
<script>
globalThis.testdom = true;
globalThis.wsPort = ${wsPort};
</script>
</head>
<body></body>
</html>
`);
res.end();
})
);
server.addRoute('/*splat', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
await server.start();
// lets handle realtime comms
const tapParser = new TapParser(testFile + ':chrome', this.logger);
const wss = new plugins.ws.WebSocketServer({ port: wsPort });
wss.on('connection', (ws) => {
ws.on('message', (message) => {
const messageStr = message.toString();
if (messageStr.startsWith('console:')) {
const [, level, ...messageParts] = messageStr.split(':');
this.logger.browserConsole(messageParts.join(':'), level);
} else {
tapParser.handleTapLog(messageStr);
}
});
});
// lets do the browser bit with timeout handling
await this.smartbrowserInstance.start();
const evaluatePromise = this.smartbrowserInstance.evaluateOnPage(
`http://localhost:${httpPort}/test?bundleName=${bundleFileName}`,
async () => {
// lets enable real time comms
const ws = new WebSocket(`ws://localhost:${globalThis.wsPort}`);
await new Promise((resolve) => (ws.onopen = resolve));
// Ensure this function is declared with 'async'
const logStore = [];
const originalLog = console.log;
const originalError = console.error;
// Override console methods to capture the logs
console.log = (...args: any[]) => {
logStore.push(args.join(' '));
ws.send(args.join(' '));
originalLog(...args);
};
console.error = (...args: any[]) => {
logStore.push(args.join(' '));
ws.send(args.join(' '));
originalError(...args);
};
const bundleName = new URLSearchParams(window.location.search).get('bundleName');
originalLog(`::TSTEST IN CHROMIUM:: Relevant Script name is: ${bundleName}`);
try {
// Dynamically import the test module
const testModule = await import(`/${bundleName}`);
if (testModule && testModule.default && testModule.default instanceof Promise) {
// Execute the exported test function
await testModule.default;
} else if (testModule && testModule.default && typeof testModule.default.then === 'function') {
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
console.log('Test module default export is just promiselike: Something might be messing with your Promise implementation.');
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
await testModule.default;
} else if (globalThis.tapPromise && typeof globalThis.tapPromise.then === 'function') {
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
console.log('Using globalThis.tapPromise');
console.log('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
await testModule.default;
} else {
console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
console.error('Test module does not export a default promise.');
console.error('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
console.log(`We got: ${JSON.stringify(testModule)}`);
}
} catch (err) {
console.error(err);
}
return logStore.join('\n');
}
);
// Start warning timer if no timeout was specified
let warningTimer: NodeJS.Timeout | null = null;
if (this.timeoutSeconds === null) {
warningTimer = setTimeout(() => {
console.error('');
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
console.error(cs(` File: ${testFile}`, 'orange'));
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
console.error('');
}, 60000); // 1 minute
}
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
evaluatePromise,
timeoutPromise
]);
// Clear timeout if test completed successfully
clearTimeout(timeoutId);
} catch (error) {
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
}
} else {
await evaluatePromise;
}
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Always clean up resources, even on timeout
try {
await this.smartbrowserInstance.stop();
} catch (error) {
// Browser might already be stopped
}
try {
await server.stop();
} catch (error) {
// Server might already be stopped
}
try {
wss.close();
} catch (error) {
// WebSocket server might already be closed
}
console.log(
`${cs('=> ', 'blue')} Stopped ${cs(testFile, 'orange')} chromium instance and server.`
);
// Always evaluate final result (handleTimeout just sets up the test state)
await tapParser.evaluateFinalResult();
return tapParser;
}
}

View File

@@ -0,0 +1,262 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type DenoOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Deno runtime adapter
* Executes tests using the Deno runtime
*/
export class DenoRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'deno';
readonly displayName: string = 'Deno';
constructor(
private logger: TsTestLogger,
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
private timeoutSeconds: number | null,
private filterTags: string[]
) {
super();
}
/**
* Get default Deno options
*/
protected getDefaultOptions(): DenoOptions {
return {
...super.getDefaultOptions(),
permissions: [
'--allow-read',
'--allow-env',
'--allow-net',
'--allow-write',
'--allow-sys', // Allow system info access
'--allow-import', // Allow npm/node imports
'--node-modules-dir', // Enable Node.js compatibility mode
'--sloppy-imports', // Allow .js imports to resolve to .ts files
],
};
}
/**
* Check if Deno is available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
const result = await this.smartshellInstance.exec('deno --version', {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return {
available: false,
error: 'Deno not found. Install from: https://deno.land/',
};
}
// Parse Deno version from output (first line is "deno X.Y.Z")
const versionMatch = result.stdout.match(/deno (\d+\.\d+\.\d+)/);
const version = versionMatch ? versionMatch[1] : 'unknown';
return {
available: true,
version: `Deno ${version}`,
};
} catch (error) {
return {
available: false,
error: error.message,
};
}
}
/**
* Create command configuration for Deno test execution
*/
createCommand(testFile: string, options?: DenoOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options) as DenoOptions;
const args: string[] = ['run'];
// Add permissions
const permissions = mergedOptions.permissions || [
'--allow-read',
'--allow-env',
'--allow-net',
'--allow-write',
'--allow-sys',
'--allow-import',
'--node-modules-dir',
'--sloppy-imports',
];
args.push(...permissions);
// Add config file if specified
if (mergedOptions.configPath) {
args.push('--config', mergedOptions.configPath);
}
// Add import map if specified
if (mergedOptions.importMap) {
args.push('--import-map', mergedOptions.importMap);
}
// Add extra args
if (mergedOptions.extraArgs && mergedOptions.extraArgs.length > 0) {
args.push(...mergedOptions.extraArgs);
}
// Add test file
args.push(testFile);
// Set environment variables
const env = { ...mergedOptions.env };
if (this.filterTags.length > 0) {
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
return {
command: 'deno',
args,
env,
cwd: mergedOptions.cwd,
};
}
/**
* Execute a test file in Deno
*/
async run(
testFile: string,
index: number,
total: number,
options?: DenoOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
const tapParser = new TapParser(testFile + ':deno', this.logger);
const mergedOptions = this.mergeOptions(options) as DenoOptions;
// Build Deno command
const command = this.createCommand(testFile, mergedOptions);
const fullCommand = `${command.command} ${command.args.join(' ')}`;
// Set filter tags as environment variable
if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts');
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
let runCommand = fullCommand;
let loaderPath: string | null = null;
// If 00init.ts exists, create a loader file
if (initFileExists) {
const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(testFile);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
// Rebuild command with loader file
const loaderCommand = this.createCommand(loaderPath, mergedOptions);
runCommand = `${loaderCommand.command} ${loaderCommand.args.join(' ')}`;
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
// If we created a loader file, clean it up after test execution
if (loaderPath) {
const cleanup = () => {
try {
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
plugins.smartfile.fs.removeSync(loaderPath);
}
} catch (e) {
// Ignore cleanup errors
}
};
execResultStreaming.childProcess.on('exit', cleanup);
execResultStreaming.childProcess.on('error', cleanup);
}
// Start warning timer if no timeout was specified
let warningTimer: NodeJS.Timeout | null = null;
if (this.timeoutSeconds === null) {
warningTimer = setTimeout(() => {
console.error('');
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
console.error(cs(` File: ${testFile}`, 'orange'));
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
console.error('');
}, 60000); // 1 minute
}
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(async () => {
// Use smartshell's terminate() to kill entire process tree
await execResultStreaming.terminate();
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
tapParser.handleTapProcess(execResultStreaming.childProcess),
timeoutPromise
]);
// Clear timeout if test completed successfully
clearTimeout(timeoutId);
} catch (error) {
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
// Ensure entire process tree is killed if still running
try {
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
} catch (killError) {
// Process tree might already be dead
}
await tapParser.evaluateFinalResult();
}
} else {
await tapParser.handleTapProcess(execResultStreaming.childProcess);
}
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
return tapParser;
}
}

View File

@@ -0,0 +1,222 @@
import * as plugins from './tstest.plugins.js';
import { coloredString as cs } from '@push.rocks/consolecolor';
import {
RuntimeAdapter,
type RuntimeOptions,
type RuntimeCommand,
type RuntimeAvailability,
} from './tstest.classes.runtime.adapter.js';
import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Node.js runtime adapter
* Executes tests using tsrun (TypeScript runner for Node.js)
*/
export class NodeRuntimeAdapter extends RuntimeAdapter {
readonly id: Runtime = 'node';
readonly displayName: string = 'Node.js';
constructor(
private logger: TsTestLogger,
private smartshellInstance: any, // SmartShell instance from @push.rocks/smartshell
private timeoutSeconds: number | null,
private filterTags: string[]
) {
super();
}
/**
* Check if Node.js and tsrun are available
*/
async checkAvailable(): Promise<RuntimeAvailability> {
try {
// Check Node.js version
const nodeVersion = process.version;
// Check if tsrun is available
const result = await this.smartshellInstance.exec('tsrun --version', {
cwd: process.cwd(),
onError: () => {
// Ignore error
}
});
if (result.exitCode !== 0) {
return {
available: false,
error: 'tsrun not found. Install with: pnpm install --save-dev @git.zone/tsrun',
};
}
return {
available: true,
version: nodeVersion,
};
} catch (error) {
return {
available: false,
error: error.message,
};
}
}
/**
* Create command configuration for Node.js test execution
*/
createCommand(testFile: string, options?: RuntimeOptions): RuntimeCommand {
const mergedOptions = this.mergeOptions(options);
// Build tsrun options
const args: string[] = [];
if (process.argv.includes('--web')) {
args.push('--web');
}
// Add any extra args
if (mergedOptions.extraArgs) {
args.push(...mergedOptions.extraArgs);
}
// Set environment variables
const env = { ...mergedOptions.env };
if (this.filterTags.length > 0) {
env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
return {
command: 'tsrun',
args: [testFile, ...args],
env,
cwd: mergedOptions.cwd,
};
}
/**
* Execute a test file in Node.js
*/
async run(
testFile: string,
index: number,
total: number,
options?: RuntimeOptions
): Promise<TapParser> {
this.logger.testFileStart(testFile, this.displayName, index, total);
const tapParser = new TapParser(testFile + ':node', this.logger);
const mergedOptions = this.mergeOptions(options);
// Build tsrun command
let tsrunOptions = '';
if (process.argv.includes('--web')) {
tsrunOptions += ' --web';
}
// Set filter tags as environment variable
if (this.filterTags.length > 0) {
process.env.TSTEST_FILTER_TAGS = this.filterTags.join(',');
}
// Check for 00init.ts file in test directory
const testDir = plugins.path.dirname(testFile);
const initFile = plugins.path.join(testDir, '00init.ts');
let runCommand = `tsrun ${testFile}${tsrunOptions}`;
const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
// If 00init.ts exists, run it first
let loaderPath: string | null = null;
if (initFileExists) {
// Create a temporary loader file that imports both 00init.ts and the test file
const absoluteInitFile = plugins.path.resolve(initFile);
const absoluteTestFile = plugins.path.resolve(testFile);
const loaderContent = `
import '${absoluteInitFile.replace(/\\/g, '/')}';
import '${absoluteTestFile.replace(/\\/g, '/')}';
`;
loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
runCommand = `tsrun ${loaderPath}${tsrunOptions}`;
}
const execResultStreaming = await this.smartshellInstance.execStreamingSilent(runCommand);
// If we created a loader file, clean it up after test execution
if (loaderPath) {
const cleanup = () => {
try {
if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
plugins.smartfile.fs.removeSync(loaderPath);
}
} catch (e) {
// Ignore cleanup errors
}
};
execResultStreaming.childProcess.on('exit', cleanup);
execResultStreaming.childProcess.on('error', cleanup);
}
// Start warning timer if no timeout was specified
let warningTimer: NodeJS.Timeout | null = null;
if (this.timeoutSeconds === null) {
warningTimer = setTimeout(() => {
console.error('');
console.error(cs('⚠️ WARNING: Test file is running for more than 1 minute', 'orange'));
console.error(cs(` File: ${testFile}`, 'orange'));
console.error(cs(' Consider using --timeout option to set a timeout for test files.', 'orange'));
console.error(cs(' Example: tstest test --timeout=300 (for 5 minutes)', 'orange'));
console.error('');
}, 60000); // 1 minute
}
// Handle timeout if specified
if (this.timeoutSeconds !== null) {
const timeoutMs = this.timeoutSeconds * 1000;
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_resolve, reject) => {
timeoutId = setTimeout(async () => {
// Use smartshell's terminate() to kill entire process tree
await execResultStreaming.terminate();
reject(new Error(`Test file timed out after ${this.timeoutSeconds} seconds`));
}, timeoutMs);
});
try {
await Promise.race([
tapParser.handleTapProcess(execResultStreaming.childProcess),
timeoutPromise
]);
// Clear timeout if test completed successfully
clearTimeout(timeoutId);
} catch (error) {
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
// Handle timeout error
tapParser.handleTimeout(this.timeoutSeconds);
// Ensure entire process tree is killed if still running
try {
await execResultStreaming.kill(); // This kills the entire process tree with SIGKILL
} catch (killError) {
// Process tree might already be dead
}
await tapParser.evaluateFinalResult();
}
} else {
await tapParser.handleTapProcess(execResultStreaming.childProcess);
}
// Clear warning timer if it was set
if (warningTimer) {
clearTimeout(warningTimer);
}
return tapParser;
}
}

View File

@@ -0,0 +1,211 @@
/**
* Runtime parser for test file naming convention
* Supports: test.runtime1+runtime2.modifier.ts
* Examples:
* - test.node.ts
* - test.chromium.ts
* - test.node+chromium.ts
* - test.deno+bun.ts
* - test.chromium.nonci.ts
*/
export type Runtime = 'node' | 'chromium' | 'deno' | 'bun';
export type Modifier = 'nonci';
export interface ParsedFilename {
baseName: string;
runtimes: Runtime[];
modifiers: Modifier[];
extension: string;
isLegacy: boolean;
original: string;
}
export interface ParserConfig {
strictUnknownRuntime?: boolean; // default: true
defaultRuntimes?: Runtime[]; // default: ['node']
}
const KNOWN_RUNTIMES: Set<string> = new Set(['node', 'chromium', 'deno', 'bun']);
const KNOWN_MODIFIERS: Set<string> = new Set(['nonci']);
const VALID_EXTENSIONS: Set<string> = new Set(['ts', 'tsx', 'mts', 'cts']);
// Legacy mappings for backwards compatibility
const LEGACY_RUNTIME_MAP: Record<string, Runtime[]> = {
browser: ['chromium'],
both: ['node', 'chromium'],
};
/**
* Parse a test filename to extract runtimes, modifiers, and detect legacy patterns
* Algorithm: Right-to-left token analysis from the extension
*/
export function parseTestFilename(
filePath: string,
config: ParserConfig = {}
): ParsedFilename {
const strictUnknownRuntime = config.strictUnknownRuntime ?? true;
const defaultRuntimes = config.defaultRuntimes ?? ['node'];
// Extract just the filename from the path
const fileName = filePath.split('/').pop() || filePath;
const original = fileName;
// Step 1: Extract and validate extension
const lastDot = fileName.lastIndexOf('.');
if (lastDot === -1) {
throw new Error(`Invalid test file: no extension found in "${fileName}"`);
}
const extension = fileName.substring(lastDot + 1);
if (!VALID_EXTENSIONS.has(extension)) {
throw new Error(
`Invalid test file extension ".${extension}" in "${fileName}". ` +
`Valid extensions: ${Array.from(VALID_EXTENSIONS).join(', ')}`
);
}
// Step 2: Split remaining basename by dots
const withoutExtension = fileName.substring(0, lastDot);
const tokens = withoutExtension.split('.');
if (tokens.length === 0) {
throw new Error(`Invalid test file: empty basename in "${fileName}"`);
}
// Step 3: Parse from right to left
let isLegacy = false;
const modifiers: Modifier[] = [];
let runtimes: Runtime[] = [];
let runtimeTokenIndex = -1;
// Scan from right to left
for (let i = tokens.length - 1; i >= 0; i--) {
const token = tokens[i];
// Check if this is a known modifier
if (KNOWN_MODIFIERS.has(token)) {
modifiers.unshift(token as Modifier);
continue;
}
// Check if this is a legacy runtime token
if (LEGACY_RUNTIME_MAP[token]) {
isLegacy = true;
runtimes = LEGACY_RUNTIME_MAP[token];
runtimeTokenIndex = i;
break;
}
// Check if this is a runtime chain (may contain + separators)
if (token.includes('+')) {
const runtimeCandidates = token.split('+').map(r => r.trim()).filter(Boolean);
const validRuntimes: Runtime[] = [];
const invalidRuntimes: string[] = [];
for (const candidate of runtimeCandidates) {
if (KNOWN_RUNTIMES.has(candidate)) {
// Dedupe: only add if not already in list
if (!validRuntimes.includes(candidate as Runtime)) {
validRuntimes.push(candidate as Runtime);
}
} else {
invalidRuntimes.push(candidate);
}
}
if (invalidRuntimes.length > 0) {
if (strictUnknownRuntime) {
throw new Error(
`Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
`Valid runtimes: ${Array.from(KNOWN_RUNTIMES).join(', ')}`
);
} else {
console.warn(
`⚠️ Warning: Unknown runtime(s) in "${fileName}": ${invalidRuntimes.join(', ')}. ` +
`Defaulting to: ${defaultRuntimes.join('+')}`
);
runtimes = [...defaultRuntimes];
runtimeTokenIndex = i;
break;
}
}
if (validRuntimes.length > 0) {
runtimes = validRuntimes;
runtimeTokenIndex = i;
break;
}
}
// Check if this is a single runtime token
if (KNOWN_RUNTIMES.has(token)) {
runtimes = [token as Runtime];
runtimeTokenIndex = i;
break;
}
// If we've scanned past modifiers and haven't found a runtime, stop looking
if (modifiers.length > 0) {
break;
}
}
// Step 4: Determine base name
// Everything before the runtime token (if found) is the base name
const baseNameTokens = runtimeTokenIndex >= 0 ? tokens.slice(0, runtimeTokenIndex) : tokens;
const baseName = baseNameTokens.join('.');
// Step 5: Apply defaults if no runtime was detected
if (runtimes.length === 0) {
runtimes = [...defaultRuntimes];
}
return {
baseName: baseName || 'test',
runtimes,
modifiers,
extension,
isLegacy,
original,
};
}
/**
* Check if a filename uses legacy naming convention
*/
export function isLegacyFilename(fileName: string): boolean {
const tokens = fileName.split('.');
for (const token of tokens) {
if (LEGACY_RUNTIME_MAP[token]) {
return true;
}
}
return false;
}
/**
* Get the suggested new filename for a legacy filename
*/
export function getLegacyMigrationTarget(fileName: string): string | null {
const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
if (!parsed.isLegacy) {
return null;
}
// Reconstruct filename with new naming
const parts = [parsed.baseName];
if (parsed.runtimes.length > 0) {
parts.push(parsed.runtimes.join('+'));
}
if (parsed.modifiers.length > 0) {
parts.push(...parsed.modifiers);
}
parts.push(parsed.extension);
return parts.join('.');
}

View File

@@ -10,6 +10,14 @@ import { TestExecutionMode } from './index.js';
import { TsTestLogger } from './tstest.logging.js'; import { TsTestLogger } from './tstest.logging.js';
import type { LogOptions } from './tstest.logging.js'; import type { LogOptions } from './tstest.logging.js';
// Runtime adapters
import { parseTestFilename } from './tstest.classes.runtime.parser.js';
import { RuntimeAdapterRegistry } from './tstest.classes.runtime.adapter.js';
import { NodeRuntimeAdapter } from './tstest.classes.runtime.node.js';
import { ChromiumRuntimeAdapter } from './tstest.classes.runtime.chromium.js';
import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js';
import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js';
export class TsTest { export class TsTest {
public testDir: TestDirectory; public testDir: TestDirectory;
public executionMode: TestExecutionMode; public executionMode: TestExecutionMode;
@@ -28,6 +36,8 @@ export class TsTest {
public tsbundleInstance = new plugins.tsbundle.TsBundle(); public tsbundleInstance = new plugins.tsbundle.TsBundle();
public runtimeRegistry = new RuntimeAdapterRegistry();
constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) { constructor(cwdArg: string, testPathArg: string, executionModeArg: TestExecutionMode, logOptions: LogOptions = {}, tags: string[] = [], startFromFile: number | null = null, stopAtFile: number | null = null, timeoutSeconds: number | null = null) {
this.executionMode = executionModeArg; this.executionMode = executionModeArg;
this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg); this.testDir = new TestDirectory(cwdArg, testPathArg, executionModeArg);
@@ -36,6 +46,20 @@ export class TsTest {
this.startFromFile = startFromFile; this.startFromFile = startFromFile;
this.stopAtFile = stopAtFile; this.stopAtFile = stopAtFile;
this.timeoutSeconds = timeoutSeconds; this.timeoutSeconds = timeoutSeconds;
// Register runtime adapters
this.runtimeRegistry.register(
new NodeRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
);
this.runtimeRegistry.register(
new ChromiumRuntimeAdapter(this.logger, this.tsbundleInstance, this.smartbrowserInstance, this.timeoutSeconds)
);
this.runtimeRegistry.register(
new DenoRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
);
this.runtimeRegistry.register(
new BunRuntimeAdapter(this.logger, this.smartshellInstance, this.timeoutSeconds, this.filterTags)
);
} }
async run() { async run() {
@@ -175,29 +199,50 @@ export class TsTest {
} }
private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) { private async runSingleTest(fileNameArg: string, fileIndex: number, totalFiles: number, tapCombinator: TapCombinator) {
switch (true) { // Parse the filename to determine runtimes and modifiers
case process.env.CI && fileNameArg.includes('.nonci.'): const fileName = plugins.path.basename(fileNameArg);
this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`); const parsed = parseTestFilename(fileName, { strictUnknownRuntime: false });
break;
case fileNameArg.endsWith('.browser.ts') || fileNameArg.endsWith('.browser.nonci.ts'):
const tapParserBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserBrowser);
break;
case fileNameArg.endsWith('.both.ts') || fileNameArg.endsWith('.both.nonci.ts'):
this.logger.sectionStart('Part 1: Chrome');
const tapParserBothBrowser = await this.runInChrome(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserBothBrowser);
this.logger.sectionEnd();
this.logger.sectionStart('Part 2: Node'); // Check for nonci modifier in CI environment
const tapParserBothNode = await this.runInNode(fileNameArg, fileIndex, totalFiles); if (process.env.CI && parsed.modifiers.includes('nonci')) {
tapCombinator.addTapParser(tapParserBothNode); this.logger.tapOutput(`Skipping ${fileNameArg} - marked as non-CI`);
return;
}
// Show deprecation warning for legacy naming
if (parsed.isLegacy) {
console.warn('');
console.warn(cs('⚠️ DEPRECATION WARNING', 'orange'));
console.warn(cs(` File: ${fileName}`, 'orange'));
console.warn(cs(` Legacy naming detected. Please migrate to new naming convention.`, 'orange'));
console.warn(cs(` Suggested: ${fileName.replace('.browser.', '.chromium.').replace('.both.', '.node+chromium.')}`, 'green'));
console.warn(cs(` Run: tstest migrate --dry-run`, 'cyan'));
console.warn('');
}
// Get adapters for the specified runtimes
const adapters = this.runtimeRegistry.getAdaptersForRuntimes(parsed.runtimes);
if (adapters.length === 0) {
this.logger.tapOutput(`Skipping ${fileNameArg} - no runtime adapters available`);
return;
}
// Execute tests for each runtime
if (adapters.length === 1) {
// Single runtime - no sections needed
const adapter = adapters[0];
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParser);
} else {
// Multiple runtimes - use sections
for (let i = 0; i < adapters.length; i++) {
const adapter = adapters[i];
this.logger.sectionStart(`Part ${i + 1}: ${adapter.displayName}`);
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParser);
this.logger.sectionEnd(); this.logger.sectionEnd();
break; }
default:
const tapParserNode = await this.runInNode(fileNameArg, fileIndex, totalFiles);
tapCombinator.addTapParser(tapParserNode);
break;
} }
} }
@@ -319,20 +364,19 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> { private async findFreePorts(): Promise<{ httpPort: number; wsPort: number }> {
const smartnetwork = new plugins.smartnetwork.SmartNetwork(); const smartnetwork = new plugins.smartnetwork.SmartNetwork();
// Find HTTP port in range 30000-40000 // Find random free HTTP port in range 30000-40000 to minimize collision chance
const httpPort = await smartnetwork.findFreePort(30000, 40000); const httpPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true });
if (!httpPort) { if (!httpPort) {
throw new Error('Could not find a free HTTP port in range 30000-40000'); throw new Error('Could not find a free HTTP port in range 30000-40000');
} }
// Find WebSocket port in range 30000-40000 (different from HTTP port) // Find random free WebSocket port, excluding the HTTP port to ensure they're different
let wsPort = await smartnetwork.findFreePort(httpPort + 1, 40000); const wsPort = await smartnetwork.findFreePort(30000, 40000, {
randomize: true,
exclude: [httpPort]
});
if (!wsPort) { if (!wsPort) {
// Try again from the beginning of the range if we couldn't find one after httpPort throw new Error('Could not find a free WebSocket port in range 30000-40000');
wsPort = await smartnetwork.findFreePort(30000, httpPort - 1);
if (!wsPort) {
throw new Error('Could not find a free WebSocket port in range 30000-40000');
}
} }
// Log selected ports for debugging // Log selected ports for debugging