diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61e6ee4..3635f4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,5 @@ jobs: run: pnpm run format:check - name: Build run: pnpm run build + - name: Integration test + run: pnpm run test:integration diff --git a/package.json b/package.json index 0464248..39dae0f 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "scripts": { "build": "tsc", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "test:integration": "pnpm run build && tsc --noEmit -p tsconfig.test.json && vitest run test/integration" }, "dependencies": { "@langfuse/otel": "^5.4.1", @@ -50,7 +51,9 @@ "effect": "^3.21.2" }, "devDependencies": { + "@types/node": "^26.1.2", "prettier": "^3.8.3", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "4.1.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a14c02..580cf3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,12 +27,18 @@ importers: specifier: ^3.21.2 version: 3.21.2 devDependencies: + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 prettier: specifier: ^3.8.3 version: 3.8.3 typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: 4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(yaml@2.9.0)) packages: @@ -45,6 +51,9 @@ packages: engines: {node: '>=6'} hasBin: true + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} @@ -277,6 +286,9 @@ packages: resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -307,11 +319,142 @@ packages: '@protobufjs/utf8@1.1.1': resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@types/node@25.9.1': - resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} @@ -331,6 +474,14 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} @@ -345,6 +496,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -371,10 +525,20 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -383,9 +547,23 @@ packages: resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} engines: {node: '>=12.17.0'} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -408,12 +586,89 @@ packages: kubernetes-types@1.30.0: resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -430,14 +685,37 @@ packages: multipasta@0.2.7: resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -461,6 +739,11 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -469,6 +752,19 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -477,6 +773,21 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + toml@4.1.1: resolution: {integrity: sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==} engines: {node: '>=20'} @@ -486,18 +797,107 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} uuid@13.0.2: resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} hasBin: true + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + 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 + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -536,6 +936,8 @@ snapshots: protobufjs: 7.6.2 yargs: 17.7.2 + '@jridgewell/sourcemap-codec@1.5.5': {} + '@js-sdsl/ordered-map@4.4.2': {} '@langfuse/core@5.4.1(@opentelemetry/api@1.9.1)': @@ -815,6 +1217,8 @@ snapshots: '@opentelemetry/semantic-conventions@1.41.1': {} + '@oxc-project/types@0.142.0': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -837,11 +1241,105 @@ snapshots: '@protobufjs/utf8@1.1.1': {} + '@rolldown/binding-android-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-x64@1.2.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.2': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} - '@types/node@25.9.1': + '@types/chai@5.2.3': dependencies: - undici-types: 7.24.6 + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.6(vite@8.2.0(@types/node@26.1.2)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@26.1.2)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: @@ -855,6 +1353,10 @@ snapshots: dependencies: color-convert: 2.0.1 + assertion-error@2.0.1: {} + + chai@6.2.2: {} + cjs-module-lexer@2.2.0: {} cliui@8.0.1: @@ -869,6 +1371,8 @@ snapshots: color-name@1.1.4: {} + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -879,8 +1383,7 @@ snapshots: dependencies: ms: 2.1.3 - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} effect@3.21.2: dependencies: @@ -902,8 +1405,16 @@ snapshots: emoji-regex@8.0.0: {} + es-module-lexer@2.3.1: {} + escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 @@ -912,8 +1423,15 @@ snapshots: dependencies: pure-rand: 8.4.0 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + find-my-way-ts@0.1.6: {} + fsevents@2.3.3: + optional: true + get-caller-file@2.0.5: {} import-in-the-middle@3.0.1: @@ -931,10 +1449,63 @@ snapshots: kubernetes-types@1.30.0: {} + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lodash.camelcase@4.3.0: {} long@5.3.2: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + module-details-from-path@1.0.4: {} ms@2.1.3: {} @@ -957,13 +1528,29 @@ snapshots: multipasta@0.2.7: {} + nanoid@3.3.17: {} + node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 optional: true + obug@2.1.4: {} + path-key@3.1.1: {} + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.17 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prettier@3.8.3: {} protobufjs@7.6.2: @@ -978,7 +1565,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.1 + '@types/node': 26.1.2 long: 5.3.2 pure-rand@6.1.0: {} @@ -994,12 +1581,40 @@ snapshots: transitivePeerDependencies: - supports-color + rolldown@1.2.2: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -1010,18 +1625,74 @@ snapshots: dependencies: ansi-regex: 5.0.1 + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + toml@4.1.1: {} typescript@6.0.3: {} - undici-types@7.24.6: {} + undici-types@8.3.0: {} uuid@13.0.2: {} + vite@8.2.0(@types/node@26.1.2)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.2.0(@types/node@26.1.2)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@26.1.2)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.1.2 + transitivePeerDependencies: + - msw + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/test/integration/plugin.test.ts b/test/integration/plugin.test.ts new file mode 100644 index 0000000..7829341 --- /dev/null +++ b/test/integration/plugin.test.ts @@ -0,0 +1,959 @@ +import { readFileSync } from "node:fs"; +import { createServer } from "node:http"; +import type { IncomingHttpHeaders, Server } from "node:http"; + +import { trace } from "@opentelemetry/api"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "vitest"; + +interface OtlpValue { + stringValue?: string; + boolValue?: boolean; + intValue?: number; +} + +interface OtlpSpan { + name: string; + traceId: string; + spanId: string; + parentSpanId?: string; + attributes: Array<{ key: string; value: OtlpValue }>; + status?: { code?: number; message?: string }; + events?: Array<{ name: string }>; +} + +interface CapturedRequest { + method?: string; + url?: string; + headers: IncomingHttpHeaders; + body: { + resourceSpans: Array<{ + scopeSpans: Array<{ spans: OtlpSpan[] }>; + }>; + }; +} + +type Plugin = (typeof import("../../dist/index.js"))["default"]; +type PluginHooks = Awaited>; +type PluginEvent = Parameters>[0]["event"]; +type SessionNextEvent = + | { + id: string; + type: "session.next.step.started"; + properties: { + sessionID: string; + timestamp: number; + agent: string; + model: { + id: string; + providerID: string; + variant?: string; + }; + snapshot?: string; + }; + } + | { + id: string; + type: "session.next.step.failed"; + properties: { + sessionID: string; + timestamp: number; + error: { message: string }; + }; + } + | { + id: string; + type: "session.next.retried"; + properties: { + sessionID: string; + timestamp: number; + attempt: number; + error: unknown; + }; + } + | { + id: string; + type: "session.next.reasoning.ended"; + properties: { + sessionID: string; + timestamp: number; + assistantMessageID: string; + reasoningID: string; + text: string; + }; + } + | { + id: string; + type: "session.next.compaction.ended"; + properties: { + sessionID: string; + timestamp: number; + text: string; + include?: string; + }; + }; +type TestPluginEvent = + | PluginEvent + | SessionNextEvent + | { + type: "session.error"; + properties: { + sessionID: string; + error: { name: "MessageAbortedError"; message: string }; + }; + }; + +const packageJson = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), +) as { version?: unknown }; + +if (typeof packageJson.version !== "string") { + throw new Error("Expected package.json to contain a version"); +} + +const packageVersion = packageJson.version; + +const originalEnvironment = { + publicKey: process.env.LANGFUSE_PUBLIC_KEY, + secretKey: process.env.LANGFUSE_SECRET_KEY, + baseUrl: process.env.LANGFUSE_BASE_URL, + legacyBaseUrl: process.env.LANGFUSE_BASEURL, + environment: process.env.LANGFUSE_ENVIRONMENT, + userId: process.env.LANGFUSE_USER_ID, +}; + +const requests: CapturedRequest[] = []; +const collectorErrors: unknown[] = []; +let server: Server; +let hooks: PluginHooks; +let plugin: Plugin; +let collectorStatus = 200; +let hooksDisposed = false; +let collectorBaseUrl: string; + +const startedAt = 1_750_000_000_000; + +const getSpans = (request: CapturedRequest) => + request.body.resourceSpans.flatMap((resourceSpan) => + resourceSpan.scopeSpans.flatMap((scopeSpan) => scopeSpan.spans), + ); + +const getAttributes = (span: OtlpSpan) => + Object.fromEntries( + span.attributes.map(({ key, value }) => [ + key, + value.stringValue ?? value.boolValue ?? value.intValue, + ]), + ); + +const getJsonAttribute = (span: OtlpSpan, key: string) => { + const value = getAttributes(span)[key]; + if (typeof value !== "string") { + throw new Error(`Expected ${span.name} attribute ${key} to be JSON`); + } + + return JSON.parse(value) as unknown; +}; + +const getSpan = (spans: OtlpSpan[], name: string) => { + const span = spans.find((candidate) => candidate.name === name); + if (!span) { + throw new Error( + `Expected a ${name} span, received: ${spans + .map((candidate) => candidate.name) + .join(", ")}`, + ); + } + + return span; +}; + +const emitEvent = async (event: TestPluginEvent) => { + // These events are emitted by current OpenCode versions but are not yet part of + // the PluginEvent union exported by our pinned @opencode-ai/plugin version. + await hooks.event?.({ event: event as PluginEvent }); +}; + +const flushSession = async (sessionID: string) => { + const requestCount = requests.length; + await emitEvent({ type: "session.idle", properties: { sessionID } }); + const exportedRequests = requests.slice(requestCount); + + expect(collectorErrors).toEqual([]); + expect(exportedRequests.length).toBeGreaterThan(0); + + return { + requests: exportedRequests, + spans: exportedRequests.flatMap(getSpans), + }; +}; + +const sendUserMessage = async (input: { + sessionID: string; + messageID: string; + text: string; + started: number; +}) => { + await hooks["chat.message"]?.( + { + sessionID: input.sessionID, + messageID: input.messageID, + agent: "build", + model: { providerID: "test-provider", modelID: "test-model" }, + }, + { + message: { + id: input.messageID, + sessionID: input.sessionID, + role: "user", + time: { created: input.started }, + agent: "build", + model: { providerID: "test-provider", modelID: "test-model" }, + }, + parts: [ + { + id: `${input.messageID}-part`, + sessionID: input.sessionID, + messageID: input.messageID, + type: "text", + text: input.text, + }, + ], + }, + ); +}; + +const startGeneration = async (input: { + id: string; + sessionID: string; + started: number; + snapshot?: string; +}) => { + await emitEvent({ + id: input.id, + type: "session.next.step.started", + properties: { + sessionID: input.sessionID, + timestamp: input.started, + agent: "build", + model: { + id: "test-model", + providerID: "test-provider", + variant: "high", + }, + snapshot: input.snapshot, + }, + }); +}; + +const completeGeneration = async (input: { + sessionID: string; + userMessageID: string; + assistantMessageID: string; + started: number; + completed: number; + text?: string; +}) => { + if (input.text) { + await emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: `${input.assistantMessageID}-part`, + sessionID: input.sessionID, + messageID: input.assistantMessageID, + type: "text", + text: input.text, + }, + }, + }); + } + + await emitEvent({ + type: "message.updated", + properties: { + info: { + id: input.assistantMessageID, + sessionID: input.sessionID, + parentID: input.userMessageID, + role: "assistant", + mode: "build", + modelID: "test-model", + providerID: "test-provider", + path: { cwd: "/test", root: "/test" }, + finish: "stop", + cost: 0.01, + tokens: { + input: 10, + output: 5, + reasoning: 2, + cache: { read: 3, write: 1 }, + }, + time: { created: input.started, completed: input.completed }, + }, + }, + }); +}; + +const createHooks = async (baseUrl: string) => { + process.env.LANGFUSE_BASE_URL = baseUrl; + const client = { + app: { + log: () => Promise.resolve(), + }, + } as unknown as Parameters[0]["client"]; + + return plugin({ client } as Parameters[0]); +}; + +const disposeHooks = async () => { + if (hooksDisposed) { + return; + } + + hooksDisposed = true; + try { + await hooks.dispose?.(); + } finally { + trace.disable(); + } +}; + +beforeAll(async () => { + server = createServer((request, response) => { + const chunks: Buffer[] = []; + + request.on("error", (error) => collectorErrors.push(error)); + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + try { + requests.push({ + method: request.method, + url: request.url, + headers: request.headers, + body: JSON.parse( + Buffer.concat(chunks).toString("utf8"), + ) as CapturedRequest["body"], + }); + response.writeHead(collectorStatus, { + "content-type": "application/json", + }); + response.end("{}"); + } catch (error) { + collectorErrors.push(error); + response.writeHead(400, { "content-type": "application/json" }); + response.end('{"error":"invalid OTLP payload"}'); + } + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected the test collector to listen on a TCP port"); + } + + process.env.LANGFUSE_PUBLIC_KEY = "pk-test"; + process.env.LANGFUSE_SECRET_KEY = "sk-test"; + collectorBaseUrl = `http://127.0.0.1:${address.port}`; + process.env.LANGFUSE_BASE_URL = collectorBaseUrl; + process.env.LANGFUSE_ENVIRONMENT = "integration-test"; + process.env.LANGFUSE_USER_ID = "test-user"; + delete process.env.LANGFUSE_BASEURL; + + ({ default: plugin } = await import("../../dist/index.js")); +}); + +beforeEach(async () => { + requests.length = 0; + collectorErrors.length = 0; + collectorStatus = 200; + hooksDisposed = false; + hooks = await createHooks(collectorBaseUrl); +}); + +afterEach(async () => { + await disposeHooks(); +}); + +afterAll(async () => { + try { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } finally { + for (const [name, value] of Object.entries({ + LANGFUSE_PUBLIC_KEY: originalEnvironment.publicKey, + LANGFUSE_SECRET_KEY: originalEnvironment.secretKey, + LANGFUSE_BASE_URL: originalEnvironment.baseUrl, + LANGFUSE_BASEURL: originalEnvironment.legacyBaseUrl, + LANGFUSE_ENVIRONMENT: originalEnvironment.environment, + LANGFUSE_USER_ID: originalEnvironment.userId, + })) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } +}); + +describe.sequential("built plugin", () => { + test("exports a complete multi-turn OpenCode session", async () => { + const sessionID = "happy-session"; + const started = startedAt; + const firstUserMessageID = "happy-user-1"; + const firstAssistantMessageID = "happy-assistant-1"; + + await sendUserMessage({ + sessionID, + messageID: firstUserMessageID, + text: "Inspect the repository", + started, + }); + await startGeneration({ + id: "happy-step-1", + sessionID, + started: started + 100, + snapshot: "snapshot-1", + }); + await completeGeneration({ + sessionID, + userMessageID: firstUserMessageID, + assistantMessageID: firstAssistantMessageID, + started: started + 100, + completed: started + 800, + text: "Repository inspected", + }); + + const secondUserMessageID = "happy-user-2"; + const secondAssistantMessageID = "happy-assistant-2"; + await sendUserMessage({ + sessionID, + messageID: secondUserMessageID, + text: "Summarize it", + started: started + 1_000, + }); + await startGeneration({ + id: "happy-step-2", + sessionID, + started: started + 1_100, + }); + await completeGeneration({ + sessionID, + userMessageID: secondUserMessageID, + assistantMessageID: secondAssistantMessageID, + started: started + 1_100, + completed: started + 1_500, + text: "A concise summary", + }); + + const { requests: exportedRequests, spans } = await flushSession(sessionID); + + for (const request of exportedRequests) { + expect(request).toMatchObject({ + method: "POST", + url: "/api/public/otel/v1/traces", + headers: { + authorization: `Basic ${Buffer.from("pk-test:sk-test").toString("base64")}`, + "content-type": "application/json", + }, + }); + } + expect(spans.map((span) => span.name).sort()).toEqual( + [ + "opencode.turn", + "opencode.message.user", + "opencode.generation", + "opencode.turn", + "opencode.message.user", + "opencode.generation", + ].sort(), + ); + + const firstGeneration = spans + .filter((span) => span.name === "opencode.generation") + .find((span) => { + const metadata = getJsonAttribute( + span, + "langfuse.observation.metadata", + ); + return ( + typeof metadata === "object" && + metadata !== null && + "messageID" in metadata && + metadata.messageID === firstAssistantMessageID + ); + }); + if (!firstGeneration) { + throw new Error("Expected the first generation span"); + } + + expect(getAttributes(firstGeneration)).toMatchObject({ + "langfuse.observation.type": "generation", + "langfuse.observation.model.name": "test-model", + "langfuse.plugin.version": packageVersion, + "langfuse.user.id": "test-user", + "session.id": sessionID, + }); + expect( + getJsonAttribute(firstGeneration, "langfuse.observation.usage_details"), + ).toEqual({ + input: 10, + output: 5, + reasoning: 2, + cache_read: 3, + cache_write: 1, + total: 17, + }); + expect( + getJsonAttribute(firstGeneration, "langfuse.observation.cost_details"), + ).toEqual({ total: 0.01 }); + expect( + getJsonAttribute(firstGeneration, "langfuse.observation.metadata"), + ).toMatchObject({ + messageID: firstAssistantMessageID, + parentID: firstUserMessageID, + providerID: "test-provider", + variant: "high", + snapshot: "snapshot-1", + }); + }); + + test("exports reasoning, tool, retry, and compaction spans", async () => { + const sessionID = "nested-observations-session"; + const userMessageID = "nested-observations-user"; + const assistantMessageID = "nested-observations-assistant"; + const started = startedAt; + + await sendUserMessage({ + sessionID, + messageID: userMessageID, + text: "Inspect the repository", + started, + }); + await startGeneration({ + id: "nested-observations-step", + sessionID, + started: started + 100, + }); + await emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: "nested-observations-reasoning-part", + sessionID, + messageID: assistantMessageID, + type: "reasoning", + text: "I should inspect the README.", + time: { start: started + 150, end: started + 200 }, + }, + }, + }); + await emitEvent({ + id: "nested-observations-reasoning-event", + type: "session.next.reasoning.ended", + properties: { + sessionID, + timestamp: started + 250, + assistantMessageID, + reasoningID: "nested-observations-reasoning-explicit", + text: "The README contains the project overview.", + }, + }); + await hooks["tool.execute.before"]?.( + { sessionID, callID: "nested-observations-call", tool: "read" }, + { args: { path: "README.md" } }, + ); + await hooks["tool.execute.after"]?.( + { + sessionID, + callID: "nested-observations-call", + tool: "read", + args: { path: "README.md" }, + }, + { title: "README.md", output: "# Project", metadata: {} }, + ); + await emitEvent({ + id: "nested-observations-retry", + type: "session.next.retried", + properties: { + sessionID, + timestamp: started + 300, + attempt: 2, + error: { message: "temporary failure" }, + }, + }); + await completeGeneration({ + sessionID, + userMessageID, + assistantMessageID, + started: started + 100, + completed: started + 800, + text: "Repository inspected", + }); + await emitEvent({ + id: "nested-observations-compaction", + type: "session.next.compaction.ended", + properties: { + sessionID, + timestamp: started + 900, + text: "Repository context", + include: "README.md", + }, + }); + + const { spans } = await flushSession(sessionID); + expect(spans.map((span) => span.name).sort()).toEqual( + [ + "opencode.turn", + "opencode.message.user", + "opencode.generation", + "opencode.generation.reasoning", + "opencode.generation.reasoning", + "read", + "opencode.generation.retry", + "opencode.generation.compaction", + ].sort(), + ); + + const generation = getSpan(spans, "opencode.generation"); + + const tool = getSpan(spans, "read"); + expect(getJsonAttribute(tool, "langfuse.observation.input")).toEqual({ + path: "README.md", + }); + expect(getJsonAttribute(tool, "langfuse.observation.output")).toEqual({ + title: "README.md", + output: "# Project", + }); + expect(tool.traceId).toBe(generation.traceId); + expect(tool.parentSpanId).toBe(generation.spanId); + + const retry = getSpan(spans, "opencode.generation.retry"); + expect(getJsonAttribute(retry, "langfuse.observation.metadata")).toEqual({ + attempt: 2, + }); + expect(retry.parentSpanId).toBe(generation.spanId); + + const compaction = getSpan(spans, "opencode.generation.compaction"); + expect(getJsonAttribute(compaction, "langfuse.observation.output")).toEqual( + { + text: "Repository context", + }, + ); + expect(compaction.parentSpanId).toBe(generation.spanId); + + const reasoningSpans = spans.filter((span) => { + if (span.name !== "opencode.generation.reasoning") { + return false; + } + + const metadata = getJsonAttribute(span, "langfuse.observation.metadata"); + return ( + typeof metadata === "object" && + metadata !== null && + "messageID" in metadata && + metadata.messageID === assistantMessageID + ); + }); + expect(reasoningSpans).toHaveLength(2); + for (const reasoning of reasoningSpans) { + expect(reasoning.parentSpanId).toBe(generation.spanId); + } + }); + + test("exports a failed generation as an error span", async () => { + const sessionID = "failed-session"; + const started = startedAt; + + await sendUserMessage({ + sessionID, + messageID: "failed-user", + text: "Trigger a failure", + started, + }); + await startGeneration({ + id: "failed-step-start", + sessionID, + started: started + 100, + }); + await emitEvent({ + id: "failed-step", + type: "session.next.step.failed", + properties: { + sessionID, + timestamp: started + 500, + error: { message: "Model unavailable" }, + }, + }); + + const { spans } = await flushSession(sessionID); + const generation = getSpan(spans, "opencode.generation"); + + expect(generation.status).toMatchObject({ + code: 2, + message: "Model unavailable", + }); + expect(getJsonAttribute(generation, "langfuse.observation.output")).toEqual( + { + error: { message: "Model unavailable" }, + }, + ); + expect(generation.events).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "exception" })]), + ); + }); + + test("suppresses generation completion after a session abort", async () => { + const sessionID = "aborted-session"; + const started = startedAt; + + await sendUserMessage({ + sessionID, + messageID: "aborted-user", + text: "Stop this request", + started, + }); + await startGeneration({ + id: "aborted-step", + sessionID, + started: started + 100, + }); + await emitEvent({ + type: "session.error", + properties: { + sessionID, + error: { name: "MessageAbortedError", message: "User cancelled" }, + }, + }); + await completeGeneration({ + sessionID, + userMessageID: "aborted-user", + assistantMessageID: "aborted-assistant", + started: started + 100, + completed: started + 500, + text: "This output must not be traced", + }); + + const { spans } = await flushSession(sessionID); + const generations = spans.filter( + (span) => span.name === "opencode.generation", + ); + + expect(generations).toHaveLength(1); + const attributes = getAttributes(generations[0]); + expect(attributes).not.toHaveProperty("langfuse.observation.output"); + expect(attributes).not.toHaveProperty("langfuse.observation.usage_details"); + expect(attributes).not.toHaveProperty("langfuse.observation.cost_details"); + expect( + getJsonAttribute(generations[0], "langfuse.observation.metadata"), + ).toEqual({ + agent: "build", + providerID: "test-provider", + variant: "high", + }); + expect(generations[0].status?.code ?? 0).not.toBe(2); + }); + + test("exports a generation without a step-start event", async () => { + const sessionID = "fallback-session"; + const started = startedAt; + + await sendUserMessage({ + sessionID, + messageID: "fallback-user", + text: "Complete without a step event", + started, + }); + await completeGeneration({ + sessionID, + userMessageID: "fallback-user", + assistantMessageID: "fallback-assistant", + started: started + 100, + completed: started + 500, + text: "Fallback output", + }); + + const { spans } = await flushSession(sessionID); + const generation = getSpan(spans, "opencode.generation"); + const turn = getSpan(spans, "opencode.turn"); + + expect(getJsonAttribute(generation, "langfuse.observation.output")).toEqual( + { + text: "Fallback output", + }, + ); + expect(generation.traceId).toBe(turn.traceId); + expect(generation.parentSpanId).toBe(turn.spanId); + }); + + test("deduplicates repeated OpenCode events", async () => { + const sessionID = "duplicate-session"; + const started = startedAt; + const userInput = { + sessionID, + messageID: "duplicate-user", + text: "Do this once", + started, + }; + + await sendUserMessage(userInput); + await sendUserMessage(userInput); + await startGeneration({ + id: "duplicate-step", + sessionID, + started: started + 100, + }); + + const retryEvent = { + id: "duplicate-retry", + type: "session.next.retried", + properties: { + sessionID, + timestamp: started + 200, + attempt: 2, + error: { message: "temporary failure" }, + }, + } satisfies SessionNextEvent; + await emitEvent(retryEvent); + await emitEvent(retryEvent); + + const reasoningEvent = { + id: "duplicate-reasoning-event", + type: "session.next.reasoning.ended", + properties: { + sessionID, + timestamp: started + 250, + assistantMessageID: "duplicate-assistant", + reasoningID: "duplicate-reasoning", + text: "Think once", + }, + } satisfies SessionNextEvent; + await emitEvent(reasoningEvent); + await emitEvent({ ...reasoningEvent, id: "duplicate-reasoning-event-2" }); + + const completion = { + sessionID, + userMessageID: "duplicate-user", + assistantMessageID: "duplicate-assistant", + started: started + 100, + completed: started + 500, + text: "One output", + }; + await completeGeneration(completion); + await completeGeneration(completion); + + const { spans } = await flushSession(sessionID); + + expect(spans.map((span) => span.name).sort()).toEqual( + [ + "opencode.turn", + "opencode.message.user", + "opencode.generation", + "opencode.generation.retry", + "opencode.generation.reasoning", + ].sort(), + ); + }); + + test("does not reject hooks when the collector returns an error", async () => { + collectorStatus = 503; + + await sendUserMessage({ + sessionID: "collector-error-session", + messageID: "collector-error-user", + text: "Export despite the collector error", + started: startedAt, + }); + + await expect( + emitEvent({ + type: "session.idle", + properties: { sessionID: "collector-error-session" }, + }), + ).resolves.toBeUndefined(); + expect(requests.length).toBeGreaterThan(0); + expect( + requests.every( + (request) => + request.method === "POST" && + request.url === "/api/public/otel/v1/traces", + ), + ).toBe(true); + expect(collectorErrors).toEqual([]); + }); + + test("does not reject hooks when the collector is unreachable", async () => { + await disposeHooks(); + + const originalExporterTimeout = + process.env.OTEL_EXPORTER_OTLP_TRACES_TIMEOUT; + process.env.OTEL_EXPORTER_OTLP_TRACES_TIMEOUT = "100"; + const unavailableServer = createServer(); + unavailableServer.on("connection", (socket) => socket.destroy()); + await new Promise((resolve, reject) => { + unavailableServer.once("error", reject); + unavailableServer.listen(0, "127.0.0.1", resolve); + }); + const address = unavailableServer.address(); + if (!address || typeof address === "string") { + throw new Error("Expected the unavailable collector to use a TCP port"); + } + hooksDisposed = false; + hooks = await createHooks(`http://127.0.0.1:${address.port}`); + try { + await sendUserMessage({ + sessionID: "unreachable-collector-session", + messageID: "unreachable-collector-user", + text: "Export without a collector", + started: startedAt, + }); + + await expect( + emitEvent({ + type: "session.idle", + properties: { sessionID: "unreachable-collector-session" }, + }), + ).resolves.toBeUndefined(); + expect(requests).toEqual([]); + } finally { + try { + await disposeHooks(); + await new Promise((resolve, reject) => + unavailableServer.close((error) => + error ? reject(error) : resolve(), + ), + ); + } finally { + if (originalExporterTimeout === undefined) { + delete process.env.OTEL_EXPORTER_OTLP_TRACES_TIMEOUT; + } else { + process.env.OTEL_EXPORTER_OTLP_TRACES_TIMEOUT = + originalExporterTimeout; + } + } + } + }, 10_000); + + test("can be disposed repeatedly", async () => { + expect(hooks.dispose).toBeDefined(); + await hooks.dispose?.(); + + await expect(hooks.dispose?.()).resolves.toBeUndefined(); + hooksDisposed = true; + trace.disable(); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..66fcd8f --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}