diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSES/jpeg-js.txt b/LICENSES/jpeg-js.txt new file mode 100644 index 0000000000..6dcbbfa4c0 --- /dev/null +++ b/LICENSES/jpeg-js.txt @@ -0,0 +1,53 @@ +jpeg-js 0.4.4 source-file notices + +lib/decoder.js: + + Copyright 2011 notmasteryet + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +The full Apache License 2.0 accompanies this notice. + +lib/encoder.js: + + Copyright (c) 2008, Adobe Systems Incorporated + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + * Neither the name of Adobe Systems Incorporated nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +JPEG encoder ported to JavaScript and optimized by Andreas Ritter, www.bytestrom.eu, 11/2009 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..7d8868279c --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,40 @@ +# Third-party material + +Third-party material retains its original license. The root MIT license does not +replace the licenses listed here. + +## Bundled JavaScript dependencies + +Each build generates `dist/src/THIRD_PARTY_NOTICES.txt` from the dependencies +included in the bundle. That file ships in the npm package with the dependencies' +license texts and applicable NOTICE contents. Dependencies resolved separately at +runtime retain the license files in their own packages. + +`jpeg-js` also carries source-file notices for its decoder and encoder, beyond +the package-level BSD license. These are retained in +[`LICENSES/jpeg-js.txt`](LICENSES/jpeg-js.txt) and included with the Apache License +2.0 in the generated notice file whenever `jpeg-js` is bundled. + +## Maestro conformance fixtures + +The 42 upstream flows in `scripts/maestro-conformance/corpus/upstream/` are copied +without modification from [Maestro v2.5.1](https://github.com/mobile-dev-inc/Maestro/tree/v2.5.1), +commit `a4c7c95f5ba1884858f7e35efa6b8e0165db9448`, under the Apache License 2.0. +Their original paths and SHA-256 hashes are recorded in the adjacent +`scripts/maestro-conformance/corpus/manifest.json`. + +The license is retained in [LICENSES/Apache-2.0.txt](LICENSES/Apache-2.0.txt). +The conformance fixtures are development inputs and do not ship in the npm package. + +## Gradle wrapper + +The Gradle wrapper in `scripts/maestro-conformance/jvm-harness/` is licensed under +the [Apache License 2.0](LICENSES/Apache-2.0.txt). +Its wrapper scripts retain the upstream copyright notices for the original authors. + +## Simulator accessibility bridge + +`apple/snapshot-bridge/SnapshotBridgeRuntime.m` contains code adapted from Meta's +idb v1.5.2. Its MIT copyright notice and license are retained in +[`apple/snapshot-bridge/LICENSE.idb`](apple/snapshot-bridge/LICENSE.idb), which also +ships with the bridge in the npm package. diff --git a/package.json b/package.json index 7035902303..0f62669662 100644 --- a/package.json +++ b/package.json @@ -367,6 +367,7 @@ "oxlint": "^1.79.0", "pngjs": "^7.0.0", "publint": "^0.3.24", + "rollup-plugin-license": "^3.7.1", "tar-stream": "^3.2.0", "tsdown": "^0.22.14", "typescript": "^7.0.2", diff --git a/packages/apple-runner/src/__tests__/runner-lifecycle-request-cancellation.test.ts b/packages/apple-runner/src/__tests__/runner-lifecycle-request-cancellation.test.ts index d2ce983b8d..9493297cbd 100644 --- a/packages/apple-runner/src/__tests__/runner-lifecycle-request-cancellation.test.ts +++ b/packages/apple-runner/src/__tests__/runner-lifecycle-request-cancellation.test.ts @@ -387,7 +387,7 @@ test('a wedged build with no caller left is ended by the default start budget an (build.signal?.reason as AppError | undefined)?.details?.reason, 'runner_start_budget_exhausted', ); - await settleUnderFakeTimers(next); + await next; }); assert.match(diagnostics, /ios_runner_detached_start_failed/); diff --git a/packages/platform-android/src/__tests__/test-utils/fake-timers.ts b/packages/platform-android/src/__tests__/test-utils/fake-timers.ts index cbb60f6584..d4a9ad8bc6 100644 --- a/packages/platform-android/src/__tests__/test-utils/fake-timers.ts +++ b/packages/platform-android/src/__tests__/test-utils/fake-timers.ts @@ -1,8 +1,16 @@ import { vi } from 'vitest'; /** - * Runs `run` with its sleeps advanced instead of waited out. The clock moves in slices shorter than - * a poll interval, so a helper session's own timers never fire ahead of its replies. + * How far the clock moves per step. One step awaits a real event-loop turn, so every turn a session + * reply still needs burns this much of the clock. A reply crossing a real loopback socket takes + * several turns: at 10 ms the polling helper's own interval fired ahead of the reply it was waiting + * for, and the deadline was reached a sample or two early. + */ +const CLOCK_STEP_MS = 1; + +/** + * Runs `run` with its sleeps advanced instead of waited out. The clock moves in slices much shorter + * than a poll interval, so a helper session's own timers never fire ahead of its replies. */ export async function underFakeTimers(run: () => Promise): Promise { vi.useFakeTimers(); @@ -12,7 +20,7 @@ export async function underFakeTimers(run: () => Promise): Promise { done = true; }); outcome.catch(() => {}); - while (!done) await vi.advanceTimersByTimeAsync(10); + while (!done) await vi.advanceTimersByTimeAsync(CLOCK_STEP_MS); return await outcome; } finally { vi.useRealTimers(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e4bdab090..8158af07c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: publint: specifier: ^0.3.24 version: 0.3.24 + rollup-plugin-license: + specifier: ^3.7.1 + version: 3.7.1(picomatch@4.0.7)(rollup@4.66.0) tar-stream: specifier: ^3.2.0 version: 3.2.0 @@ -2098,6 +2101,13 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -2737,6 +2747,144 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.66.0': + resolution: {integrity: sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.66.0': + resolution: {integrity: sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.66.0': + resolution: {integrity: sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.66.0': + resolution: {integrity: sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.66.0': + resolution: {integrity: sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.66.0': + resolution: {integrity: sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.66.0': + resolution: {integrity: sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.66.0': + resolution: {integrity: sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.66.0': + resolution: {integrity: sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.66.0': + resolution: {integrity: sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.66.0': + resolution: {integrity: sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.66.0': + resolution: {integrity: sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.66.0': + resolution: {integrity: sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.66.0': + resolution: {integrity: sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.66.0': + resolution: {integrity: sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.66.0': + resolution: {integrity: sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.66.0': + resolution: {integrity: sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.66.0': + resolution: {integrity: sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.66.0': + resolution: {integrity: sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.66.0': + resolution: {integrity: sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.66.0': + resolution: {integrity: sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.66.0': + resolution: {integrity: sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.66.0': + resolution: {integrity: sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.66.0': + resolution: {integrity: sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.66.0': + resolution: {integrity: sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==} + cpu: [x64] + os: [win32] + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -3445,6 +3593,10 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-find-index@1.0.2: + resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} + engines: {node: '>=0.10.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3656,6 +3808,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commenting@1.1.0: + resolution: {integrity: sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA==} + common-ancestor-path@2.0.0: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} @@ -4439,6 +4594,9 @@ packages: lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4450,6 +4608,9 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} @@ -4617,6 +4778,9 @@ packages: resolution: {integrity: sha512-t+Vn6DX4wPCxS23y03C0uhF7S1jpi1GfqPrnlEipCJaA2ykiNbMkVlDutZtmvLfRcUN6R8AYVlzTsQrT4RZoLQ==} engines: {node: 18 || 20 || >=22} + moment@2.31.0: + resolution: {integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4786,6 +4950,10 @@ packages: package-manager-detector@1.9.0: resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + package-name-regex@2.0.6: + resolution: {integrity: sha512-gFL35q7kbE/zBaPA3UKhp2vSzcPYx2ecbYuwv1ucE9Il6IIgBDweBlH8D68UFGZic2MkllKa2KHCfC1IQBQUYA==} + engines: {node: '>=12'} + pagefind@1.5.2: resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} hasBin: true @@ -5008,6 +5176,17 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup-plugin-license@3.7.1: + resolution: {integrity: sha512-FcGXUbAmPvRSLxjVdjp/r/MUtKBlttVQd+ApUyvKfREnsoAfAZA6Ic2fE1Tz4RL0f9XqEQU9UIRNUMdtQtliDw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + + rollup@4.66.0: + resolution: {integrity: sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -5102,6 +5281,27 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdx-compare@1.0.0: + resolution: {integrity: sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-expression-validate@2.0.0: + resolution: {integrity: sha512-b3wydZLM+Tc6CFvaRDBOF9d76oGIHNCLYFeHbftFXUWjnfZWganmDmvtM5sm1cRwJc/VDBMLyGGrsLFd1vOxbg==} + + spdx-license-ids@3.0.24: + resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==} + + spdx-ranges@2.1.1: + resolution: {integrity: sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA==} + + spdx-satisfies@5.0.1: + resolution: {integrity: sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw==} + starlight-llms-txt@0.12.0: resolution: {integrity: sha512-ya4c0ZoiPUXuHDzd3AYCKh78Y3II3EToSE9Yp6uVN0c2UUKoNpqTjijhW86W/4toZzSUhWSnxeYs7QBcXNIPGg==} peerDependencies: @@ -6711,6 +6911,9 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.11.3 @@ -7046,6 +7249,81 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.66.0': + optional: true + + '@rollup/rollup-android-arm64@4.66.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.66.0': + optional: true + + '@rollup/rollup-darwin-x64@4.66.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.66.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.66.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.66.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.66.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.66.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.66.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.66.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.66.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.66.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.66.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.66.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.66.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.66.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.66.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.66.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.66.0': + optional: true + '@sec-ant/readable-stream@0.4.1': {} '@shikijs/core@4.0.2': @@ -7669,6 +7947,8 @@ snapshots: aria-query@5.3.2: {} + array-find-index@1.0.2: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.7(patch_hash=8f07585d066beb92a665ea78ec8bef87af3af32de9076c9e23122fbabd9fb475): @@ -7927,6 +8207,8 @@ snapshots: commander@14.0.3: {} + commenting@1.1.0: {} + common-ancestor-path@2.0.0: {} convert-source-map@2.0.0: {} @@ -8696,12 +8978,18 @@ snapshots: lodash.groupby@4.6.0: {} + lodash@4.18.1: {} + longest-streak@3.1.0: {} lru-cache@11.5.2: {} lru-cache@11.5.3: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -9075,6 +9363,8 @@ snapshots: '@isaacs/nonrecursive-glob-match': 1.0.3 brace-expansion: 5.0.12 + moment@2.31.0: {} + mri@1.2.0: {} mrmime@2.0.1: {} @@ -9271,6 +9561,8 @@ snapshots: package-manager-detector@1.9.0: {} + package-name-regex@2.0.6: {} + pagefind@1.5.2: optionalDependencies: '@pagefind/darwin-arm64': 1.5.2 @@ -9526,6 +9818,52 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.5 '@rolldown/binding-win32-x64-msvc': 1.2.5 + rollup-plugin-license@3.7.1(picomatch@4.0.7)(rollup@4.66.0): + dependencies: + commenting: 1.1.0 + fdir: 6.5.0(picomatch@4.0.7) + lodash: 4.18.1 + magic-string: 0.30.21 + moment: 2.31.0 + package-name-regex: 2.0.6 + rollup: 4.66.0 + spdx-expression-validate: 2.0.0 + spdx-satisfies: 5.0.1 + transitivePeerDependencies: + - picomatch + + rollup@4.66.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.66.0 + '@rollup/rollup-android-arm64': 4.66.0 + '@rollup/rollup-darwin-arm64': 4.66.0 + '@rollup/rollup-darwin-x64': 4.66.0 + '@rollup/rollup-freebsd-arm64': 4.66.0 + '@rollup/rollup-freebsd-x64': 4.66.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.66.0 + '@rollup/rollup-linux-arm-musleabihf': 4.66.0 + '@rollup/rollup-linux-arm64-gnu': 4.66.0 + '@rollup/rollup-linux-arm64-musl': 4.66.0 + '@rollup/rollup-linux-loong64-gnu': 4.66.0 + '@rollup/rollup-linux-loong64-musl': 4.66.0 + '@rollup/rollup-linux-ppc64-gnu': 4.66.0 + '@rollup/rollup-linux-ppc64-musl': 4.66.0 + '@rollup/rollup-linux-riscv64-gnu': 4.66.0 + '@rollup/rollup-linux-riscv64-musl': 4.66.0 + '@rollup/rollup-linux-s390x-gnu': 4.66.0 + '@rollup/rollup-linux-x64-gnu': 4.66.0 + '@rollup/rollup-linux-x64-musl': 4.66.0 + '@rollup/rollup-openbsd-x64': 4.66.0 + '@rollup/rollup-openharmony-arm64': 4.66.0 + '@rollup/rollup-win32-arm64-msvc': 4.66.0 + '@rollup/rollup-win32-ia32-msvc': 4.66.0 + '@rollup/rollup-win32-x64-gnu': 4.66.0 + '@rollup/rollup-win32-x64-msvc': 4.66.0 + fsevents: 2.3.3 + rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -9669,6 +10007,33 @@ snapshots: space-separated-tokens@2.0.2: {} + spdx-compare@1.0.0: + dependencies: + array-find-index: 1.0.2 + spdx-expression-parse: 3.0.1 + spdx-ranges: 2.1.1 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.24 + + spdx-expression-validate@2.0.0: + dependencies: + spdx-expression-parse: 3.0.1 + + spdx-license-ids@3.0.24: {} + + spdx-ranges@2.1.1: {} + + spdx-satisfies@5.0.1: + dependencies: + spdx-compare: 1.0.0 + spdx-expression-parse: 3.0.1 + spdx-ranges: 2.1.1 + starlight-llms-txt@0.12.0(@astrojs/markdown-satteri@0.4.5)(@astrojs/starlight@0.42.7(astro@7.4.2(@types/node@22.20.1)(yaml@2.9.1))(supports-color@7.2.0)(typescript@5.9.3))(astro@7.4.2(@types/node@22.20.1)(yaml@2.9.1))(supports-color@7.2.0): dependencies: '@astrojs/mdx': 8.1.1(@astrojs/markdown-satteri@0.4.5)(astro@7.4.2(@types/node@22.20.1)(yaml@2.9.1)) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts new file mode 100644 index 0000000000..104d7a1c8d --- /dev/null +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -0,0 +1,301 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { test } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; + +const execFileAsync = promisify(execFile); + +async function fixture(files: Record): Promise { + const root = await fs.realpath(await mkdtempForTest('bundled-licenses-')); + const contents = { + 'package.json': JSON.stringify({ name: 'fixture-project', type: 'module' }), + 'tsconfig.json': JSON.stringify({ + compilerOptions: { target: 'es2022', module: 'nodenext', skipLibCheck: true }, + include: ['input.ts'], + }), + ...files, + }; + for (const [relative, source] of Object.entries(contents)) { + const file = path.join(root, relative); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, source); + } + return root; +} + +async function bundle(root: string, dts = false, outputDirectory = 'dist'): Promise { + await execFileAsync(process.execPath, [ + '--experimental-strip-types', + fileURLToPath(new URL('./fixtures/bundled-license-build.mts', import.meta.url)), + root, + String(dts), + outputDirectory, + ]); +} + +test('bundled licenses retain full license and NOTICE text after minification', async () => { + const root = await fixture({ + 'input.ts': [ + "import { value } from 'included';", + "import { ignored } from 'unused';", + "import { external } from 'external';", + 'export const result = value + external;', + ].join('\n'), + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.2.3', + license: 'MIT', + author: 'Fixture author ', + main: 'index.js', + }), + 'node_modules/included/index.js': + "export const value = process.env.FIXTURE_NUMBER ?? 'fixture';", + 'node_modules/included/LICENSE': 'Copyright fixture author\nFull license terms retained.', + 'node_modules/included/NOTICE': 'Upstream attribution retained.', + 'node_modules/unused/package.json': JSON.stringify({ + name: 'unused', + version: '1.0.0', + main: 'index.js', + sideEffects: false, + }), + 'node_modules/unused/index.js': 'export const ignored = 99;', + 'node_modules/external/package.json': JSON.stringify({ + name: 'external', + version: '1.0.0', + main: 'index.js', + }), + 'node_modules/external/index.js': 'export const external = 1;', + }); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(notices, /included/); + assert.match(notices, /1\.2\.3/); + assert.match(notices, /Copyright fixture author\nFull license terms retained\./); + assert.match(notices, /Upstream attribution retained\./); + assert.match(notices, /Author: Fixture author /); + assert.doesNotMatch(notices, /unused|external|fixture-project/); + // The populated notice ends like any other text file: exactly one trailing newline, so a reader + // who cats it and a tool that counts its lines both see a finished last license block. + assert.ok(notices.endsWith('\n'), 'notices end with a newline'); + assert.ok(!notices.endsWith('\n\n'), 'notices carry exactly one trailing newline'); +}); + +test('bundling fails when a dependency has a license identifier but no license text', async () => { + const root = await fixture({ + 'input.ts': "export { value } from 'included';", + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.0.0', + license: 'MIT', + main: 'index.js', + }), + 'node_modules/included/index.js': 'export const value = 42;', + }); + await assert.rejects(bundle(root), /included@1\.0\.0 is missing its license or license text/); +}); + +// `rollup-plugin-license` reports `version: null` for a module whose `package.json` has none, which +// is what an unresolvable or path-linked module looks like to it. Before the version was checked, a +// fully licensed such dependency shipped a notice reading `included@null (MIT)`, attributing code to +// a release that never existed, and one missing its license too was reported as a license problem — +// sending the reader to the fixture's LICENSE file instead of to the version-less module. +test.each([ + { name: 'no version field', version: undefined }, + { name: 'blank version', version: ' ' }, +])('bundling rejects a licensed dependency with a missing version: $name', async ({ version }) => { + const manifest: Record = { name: 'included', license: 'MIT', main: 'index.js' }; + if (version !== undefined) manifest.version = version; + const root = await fixture({ + 'input.ts': "export { value } from 'included';", + 'node_modules/included/package.json': JSON.stringify(manifest), + 'node_modules/included/index.js': 'export const value = 42;', + 'node_modules/included/LICENSE': 'Copyright fixture author\nFull license terms.', + }); + await assert.rejects(bundle(root), /Bundled dependency included is missing its version/); + await assert.rejects(fs.stat(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt')), { + code: 'ENOENT', + }); +}); + +// A module whose `package.json` carries neither a version nor a license identifier never reaches +// `renderNotices`: the plugin reports no dependencies for it at all, and the bundle gets the generic +// empty notice. That gap belongs to the publish gate, not to this formatter — see the +// `size-report-package` notices content test. + +test('identical notices are shared without losing packages or distinct copyrights', async () => { + const sharedLicense = 'Copyright shared author\nFull shared license terms.'; + const sharedNotice = 'Shared upstream attribution.'; + const distinctLicense = 'Copyright different author\nFull shared license terms.'; + const files: Record = { + 'input.ts': [ + "export { value as first } from 'included';", + "export { value as second } from 'shared';", + "export { value as third } from 'distinct';", + ].join('\n'), + }; + for (const name of ['included', 'shared', 'distinct']) { + files[`node_modules/${name}/package.json`] = JSON.stringify({ + name, + version: '1.0.0', + license: 'MIT', + main: 'index.js', + }); + files[`node_modules/${name}/index.js`] = `export const value = process.env.${name};`; + files[`node_modules/${name}/LICENSE`] = name === 'distinct' ? distinctLicense : sharedLicense; + files[`node_modules/${name}/NOTICE`] = sharedNotice; + } + const root = await fixture(files); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.equal(notices.split(sharedLicense).length - 1, 1); + assert.equal(notices.split(sharedNotice).length - 1, 1); + assert.equal(notices.split(distinctLicense).length - 1, 1); + const references = ['included', 'shared', 'distinct'].map((name) => { + const line = new RegExp(`^${name}@1\\.0\\.0 \\(MIT\\): (.+)$`, 'm'); + assert.match(notices, line); + return notices.match(line)?.[1]; + }); + assert.equal(references[0], references[1]); + assert.notEqual(references[0], references[2]); + await fs.writeFile( + path.join(root, 'input.ts'), + [ + "export { value as third } from 'distinct';", + "export { value as second } from 'shared';", + "export { value as first } from 'included';", + ].join('\n'), + ); + await bundle(root); + assert.equal(await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'), notices); +}); + +test('notice references use code-unit ordering for package names', async () => { + const names = ['included_extra', 'included-extra', 'included.extra']; + const files: Record = { + 'input.ts': names + .map((name, index) => `export { value as value${index} } from '${name}';`) + .join('\n'), + }; + for (const name of names) { + files[`node_modules/${name}/package.json`] = JSON.stringify({ + name, + version: '1.0.0', + license: 'MIT', + main: 'index.js', + }); + files[`node_modules/${name}/index.js`] = `export const value = '${name}';`; + files[`node_modules/${name}/LICENSE`] = 'Copyright ordering fixture\nShared license terms.'; + } + const root = await fixture(files); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.equal( + notices.split('\n\n')[0], + [ + 'included-extra@1.0.0 (MIT): [1]', + 'included.extra@1.0.0 (MIT): [1]', + 'included_extra@1.0.0 (MIT): [1]', + ].join('\n'), + ); +}); + +test('declaration generation preserves JavaScript dependency notices', async () => { + const root = await fixture({ + 'input.ts': "export { value } from 'included';", + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.0.0', + license: 'MIT', + main: 'index.js', + types: 'index.d.ts', + }), + 'node_modules/included/index.js': "export const value = 'included';", + 'node_modules/included/index.d.ts': 'export declare const value: string;', + 'node_modules/included/LICENSE': 'Copyright included author\nIncluded license terms.', + }); + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(notices, /included@1\.0\.0/); + await bundle(root, true); + assert.ok((await fs.stat(path.join(root, 'dist/input.d.ts'))).size > 0); + assert.equal(await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'), notices); +}); + +test.each(['0.4.4', '0.4.5'])( + 'bundled jpeg-js@%s requires matching source-file notices', + async (version) => { + const [sourceNotices, apacheLicense] = await Promise.all([ + fs.readFile(new URL('../../LICENSES/jpeg-js.txt', import.meta.url), 'utf8'), + fs.readFile(new URL('../../LICENSES/Apache-2.0.txt', import.meta.url), 'utf8'), + ]); + const root = await fixture({ + 'input.ts': "export { value } from 'jpeg-js';", + 'node_modules/jpeg-js/package.json': JSON.stringify({ + name: 'jpeg-js', + version, + license: 'BSD-3-Clause', + main: 'index.js', + }), + 'node_modules/jpeg-js/index.js': 'export const value = process.env.FIXTURE_NUMBER;', + 'node_modules/jpeg-js/LICENSE': 'Package-level BSD terms.', + 'LICENSES/jpeg-js.txt': sourceNotices, + 'LICENSES/Apache-2.0.txt': apacheLicense, + }); + if (version === '0.4.5') { + await assert.rejects(bundle(root), /LICENSES\/jpeg-js\.txt.*jpeg-js@0\.4\.5/); + return; + } + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(notices, /Package-level BSD terms/); + assert.match(notices, /Copyright 2011 notmasteryet/); + assert.match(notices, /Adobe Systems Incorporated/); + assert.match(notices, /JPEG encoder ported to JavaScript and optimized by Andreas Ritter/); + assert.ok(notices.includes(sourceNotices.replaceAll('\r\n', '\n').trim())); + assert.ok(notices.includes(apacheLicense.replaceAll('\r\n', '\n').trim())); + }, +); + +// A project that genuinely bundles nothing keeps getting the generic notice: the content policy that +// holds the published CLI to naming somebody is the publish gate's, not this formatter's. Pinning it +// here keeps a future "reject the placeholder" edit at the formatter from breaking an empty build. +test('a bundle with no third-party code emits the generic notice unchanged', async () => { + const root = await fixture({ + 'input.ts': "import { external } from 'external';\nexport const result = external;", + 'node_modules/external/package.json': JSON.stringify({ + name: 'external', + version: '1.0.0', + main: 'index.js', + }), + 'node_modules/external/index.js': 'export const external = 1;', + }); + await bundle(root); + assert.equal( + await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'), + 'No third-party dependencies are bundled.\n', + ); +}); + +test('output overrides write notices beside the bundle without creating the default dist', async () => { + const root = await fixture({ + 'input.ts': "export { value } from 'included';", + 'node_modules/included/package.json': JSON.stringify({ + name: 'included', + version: '1.0.0', + license: 'MIT', + main: 'index.js', + }), + 'node_modules/included/index.js': 'export const value = 42;', + 'node_modules/included/LICENSE': 'Copyright fixture author\nFull license terms.', + }); + await bundle(root, false, 'redirected'); + assert.match( + await fs.readFile(path.join(root, 'redirected/THIRD_PARTY_NOTICES.txt'), 'utf8'), + /included@1\.0\.0/, + ); + await assert.rejects(fs.stat(path.join(root, 'dist')), { code: 'ENOENT' }); +}); diff --git a/scripts/__tests__/fixtures/bundled-license-build.mts b/scripts/__tests__/fixtures/bundled-license-build.mts new file mode 100644 index 0000000000..09938d8aaa --- /dev/null +++ b/scripts/__tests__/fixtures/bundled-license-build.mts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { build } from 'tsdown'; +import { bundledLicenseNotices } from '../../lib/bundled-license-notices.ts'; + +const root = process.argv[2]; +if (!root) throw new Error('Missing license fixture root.'); + +await build({ + config: false, + cwd: root, + tsconfig: path.join(root, 'tsconfig.json'), + entry: ['input.ts'], + outDir: process.argv[4] ?? 'dist', + outExtensions: () => ({ js: '.js', dts: '.d.ts' }), + dts: process.argv[3] === 'true', + minify: true, + deps: { + onlyBundle: [ + 'included', + 'shared', + 'distinct', + 'unused', + 'jpeg-js', + 'included-extra', + 'included.extra', + 'included_extra', + ], + }, + plugins: [bundledLicenseNotices(root)], + logLevel: 'silent', +}); diff --git a/scripts/__tests__/fixtures/size-report-npm-pack.json b/scripts/__tests__/fixtures/size-report-npm-pack.json index 8494a3fcc3..2a229a1a22 100644 --- a/scripts/__tests__/fixtures/size-report-npm-pack.json +++ b/scripts/__tests__/fixtures/size-report-npm-pack.json @@ -1,6 +1,10 @@ { "unpackedSize": 2319, "files": [ + { + "path": "dist/src/THIRD_PARTY_NOTICES.txt", + "size": 100 + }, { "path": "dist/src/index.js", "size": 401 @@ -82,6 +86,6 @@ "size": 426 } ], - "entryCount": 20, + "entryCount": 21, "size": 3376 } diff --git a/scripts/__tests__/size-report-package.test.ts b/scripts/__tests__/size-report-package.test.ts index 137cadee5b..8c1dfe14f1 100644 --- a/scripts/__tests__/size-report-package.test.ts +++ b/scripts/__tests__/size-report-package.test.ts @@ -1,14 +1,23 @@ import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { promisify } from 'node:util'; import { test } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; import { formatMarkdown } from '../size-report.mjs'; -import { assertPublishPackageContents } from '../size-report-package.mjs'; +import { + assertBundledLicenseNotices, + assertPublishPackageContents, + parseNpmPackOutput, +} from '../size-report-package.mjs'; const fixturePack = JSON.parse( await readFile(join(import.meta.dirname, 'fixtures', 'size-report-npm-pack.json'), 'utf8'), ); +const execFileAsync = promisify(execFile); + // The fixture pack ships the macOS helper (manifest, sources, and per-target PackStub.swift), // so every assertion here names the manifest source the guard would read from the packed tree; // without it the guard falls back to this checkout's manifest via repoRoot. @@ -23,6 +32,441 @@ const helperManifestSource = [ ' name: "AgentDeviceMacOSInputTests",', ].join('\n'); +test('publish package requires bundled dependency license notices', () => { + assert.throws( + () => + assertPublishPackageContents( + fixturePack.files.filter((entry) => entry.path !== 'dist/src/THIRD_PARTY_NOTICES.txt'), + { macosHelperManifestSource: helperManifestSource }, + ), + /THIRD_PARTY_NOTICES\.txt/, + ); + assert.throws( + () => + assertPublishPackageContents( + fixturePack.files.map((entry) => + entry.path === 'dist/src/THIRD_PARTY_NOTICES.txt' ? { ...entry, size: 0 } : entry, + ), + { macosHelperManifestSource: helperManifestSource }, + ), + /non-empty.*THIRD_PARTY_NOTICES\.txt/, + ); +}); + +// The publish gate's own copy of what a shipped notices file must contain. Written out rather than +// produced by the bundler: a fixture the producer generates cannot show the producer emitted the wrong +// thing. The attribution lines, the reference markers and the license bodies are the shape +// `renderNotices` in scripts/lib/bundled-license-notices.ts writes for a bundled build, and the +// placeholder below is the line that same function writes when the bundle holds no third-party code. +const REAL_NOTICES = [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + 'undici@7.1.1 (MIT): [1]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', +].join('\n'); +const EMPTY_BUNDLE_NOTICES = 'No third-party dependencies are bundled.\n'; + +// Two distinct license bodies, one of them shared by two attributions: the shape the bundler writes +// when it deduplicates identical texts, which the gate must keep accepting. +const TWO_LICENSES_NOTICES = [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + 'undici@7.1.1 (MIT): [2]', + 'yauzl@3.4.0 (MIT): [1], [2]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + '', + '[2]', + 'Copyright (c) 2018 Nathan LaFreniere and other contributors', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', +].join('\n'); + +/** The pack manifest's notices entry — non-empty by bytes, which is all a manifest can say. */ +function noticesEntry() { + return fixturePack.files.find((entry) => entry.path === 'dist/src/THIRD_PARTY_NOTICES.txt'); +} + +/** + * A pack manifest plus the tree it was built from, so the gate is asked about real content and not + * only about a byte count. `noticesRoot` is the half the placeholder slipped past before. + */ +async function treeWithNotices(contents: string | null): Promise { + const root = await mkdtempForTest('notices-tree-'); + await mkdir(join(root, 'dist', 'src'), { recursive: true }); + if (contents !== null) { + await writeFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), contents); + } + return root; +} + +test('pack gate rejects the zero-dependency placeholder a non-empty manifest reports', async () => { + // The failure this exists for: the bundler stops bundling dependencies, the bundler reports none, + // and the tarball still ships a non-empty notices file. The manifest entry below is exactly the one + // a healthy build produces, so only the bytes tell the two apart. + const root = await treeWithNotices(EMPTY_BUNDLE_NOTICES); + assert.throws( + () => assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + /no bundled dependency notices[\s\S]*found 0 attributions and 0 license texts/, + ); + // Attribution lines with no license text behind them are the same defect by half. + const halfWritten = await treeWithNotices('@agent-device/proxy@0.21.37-dev (MIT): [1]\n'); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: halfWritten, + context: 'npm pack', + }), + /found 1 attributions and 0 license texts/, + ); +}); + +test('pack gate accepts a tree whose notices attribute the bundled dependencies', async () => { + const root = await treeWithNotices(REAL_NOTICES); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + +test('pack gate rejects attributions whose references hold no license text', async () => { + // A marker is a heading, not a license: this file has the shape the bundler writes and zero terms + // in it, and a gate that only counts the two kinds of line lets it through. + const markerWithoutTerms = await treeWithNotices( + '@agent-device/proxy@0.21.37-dev (MIT): [1]\n\n[1]\n', + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: markerWithoutTerms, + context: 'npm pack', + }), + /no bundled dependency notices[\s\S]*1 attributions and 0 license texts[\s\S]*unresolved: @agent-device\/proxy@0\.21\.37-dev \(MIT\) -> \[1\]/, + ); + // The other half: the reference names an id the file never writes. + const dangling = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): [3]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: dangling, context: 'npm pack' }), + /unresolved: @agent-device\/proxy@0\.21\.37-dev \(MIT\) -> \[3\]/, + ); + // An attribution with nothing after the colon promises a license and carries none. + const noReference = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): ', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: noReference, + context: 'npm pack', + }), + /unresolved: @agent-device\/proxy@0\.21\.37-dev \(MIT\) -> no reference/, + ); +}); + +test('pack gate accepts every reference a deduplicated notices file makes', async () => { + // The bundler numbers each distinct license text once, so one package can point at two texts and + // two packages can share one. Resolving references must not read that as a missing license. + const root = await treeWithNotices(TWO_LICENSES_NOTICES); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); + // The shapes the real build writes and a naive reader would reject: an attribution that names an + // author after the references, and an Apache-style license whose body carries blank lines. + await writeFile( + join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), + [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]; Author: Callstack', + 'undici@7.1.1 (Apache-2.0): [1], [2]; Author: Nathan LaFreniere ', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + '', + '[2]', + 'Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file', + 'except in compliance with the License.', + '', + 'You may obtain a copy of the License at', + '', + ' https://www.apache.org/licenses/LICENSE-2.0', + ].join('\n'), + ); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + +test('pack gate names the tree it could not read notices from', async () => { + const root = await treeWithNotices(null); + assert.throws( + () => assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + /npm pack has no dist\/src\/THIRD_PARTY_NOTICES\.txt/, + ); +}); + +test('pack gate reads content without a tree, staying a manifest-only check', () => { + // The manifest entry alone is a presence-and-bytes claim, so that is all this call can prove; the + // content policy needs the tree whose bytes it is standing in for. Production callers — `collectNpmPack` + // and both calls in `check-package.ts` — always name that tree, so the packed and installed copies + // are read, never only the manifest. + assert.doesNotThrow(() => assertBundledLicenseNotices([noticesEntry()])); +}); + +/** A dependency the license plugin bundles, with the manifest fields that shape its attribution. */ +function dependencyManifest(name: string, fields: Record): [string, string][] { + return [ + [ + `node_modules/${name}/package.json`, + JSON.stringify({ name, version: '1.2.3', main: 'index.js', ...fields }), + ], + [ + `node_modules/${name}/index.js`, + `export const ${name.replaceAll(/[^A-Za-z0-9_$]/g, '_')} = process.env.${name.toUpperCase()};`, + ], + [`node_modules/${name}/LICENSE`, `Copyright ${name} author\nFull ${name} license terms.`], + ]; +} + +/** + * Real renderer bytes: runs the same `bundledLicenseNotices` plugin the published build uses (through + * the shared fixture build script) over a fixture tree, so the attribution lines the pack gate reads + * are the ones `renderNotices` writes — grouped SPDX field, author clause and all — rather than a + * hand-written approximation of them. + */ +async function renderedNotices( + dependencies: [string, Record, Record?][], +): Promise { + const root = await realpath(await mkdtempForTest('pack-gate-render-')); + const files: [string, string][] = [ + ['package.json', JSON.stringify({ name: 'fixture-project', type: 'module' })], + [ + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { target: 'es2022', module: 'nodenext', skipLibCheck: true }, + include: ['input.ts'], + }), + ], + ['input.ts', dependencies.map(([name]) => `export { ${name} } from '${name}';`).join('\n')], + ]; + for (const [name, fields, extras] of dependencies) { + files.push(...dependencyManifest(name, fields)); + if (extras) files.push(...Object.entries(extras)); + } + for (const [relative, source] of files) { + const file = join(root, relative); + await mkdir(join(file, '..'), { recursive: true }); + await writeFile(file, source); + } + await execFileAsync( + process.execPath, + [ + '--experimental-strip-types', + join(import.meta.dirname, 'fixtures', 'bundled-license-build.mts'), + root, + 'false', + 'dist', + ], + { cwd: import.meta.dirname }, + ); + const tree = await treeWithNotices( + await readFile(join(root, 'dist', 'THIRD_PARTY_NOTICES.txt'), 'utf8'), + ); + return tree; +} + +test('pack gate accepts the attribution the renderer writes for a grouped SPDX expression', async () => { + // A `package.json` license may be a grouped SPDX expression, and `renderNotices` wraps whatever the + // plugin reports in its own parentheses, so the attribution carries nested ones. Those bytes are + // valid producer output: the gate has to read them, not report them as naming nobody. + const root = await renderedNotices([ + ['shared', { license: '(MIT OR Apache-2.0)', author: 'Grouped author ' }], + ]); + const rendered = await readFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(rendered, /^shared@1\.2\.3 \(\(MIT OR Apache-2\.0\)\): \[1\]; Author: /m); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + +test('pack gate reads the license reference, not the author clause, from real bytes', async () => { + // The author line is attribution metadata: a bracketed footnote inside it is not a license + // reference. Resolving it as one would both reject valid bytes (no such section) and let an + // attribution with no reference at all pass on an author's footnote. + const root = await renderedNotices([ + ['distinct', { license: 'MIT', author: 'Bracket author [7] ' }], + ]); + const rendered = await readFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(rendered, /\[1\]; Author: Bracket author \[7\]/); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + +test('pack gate reads no attribution from license or NOTICE body text', async () => { + // A numbered section holds upstream license or NOTICE text verbatim, at column zero: the renderer + // neither escapes nor indents it. Prose shaped like an attribution — an email contact, a line + // pointing at "section [9]" of some other package — is ordinary upstream content, and the only + // attributions the bundler itself writes are the block before the first heading. Reading a body + // line as an attribution would reject publishable bytes: the dangling [9] and the empty reference + // below are unparseable as anything but unresolved attributions. + const root = await renderedNotices([ + [ + 'included', + { license: 'MIT' }, + { + 'node_modules/included/NOTICE': [ + 'This distribution bundles software from several upstreams:', + 'helper-pkg@0.1.0 (MIT): see section [9] for those terms', + 'maintainer@example.test (Apache-2.0): ', + ].join('\n'), + }, + ], + ]); + const rendered = await readFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.match(rendered, /^helper-pkg@0\.1\.0 \(MIT\): see section \[9\]/m); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { noticesRoot: root, context: 'npm pack' }), + ); +}); + +test('pack gate rejects a file that attributes nobody but whose license text names somebody', async () => { + // The gate exists to catch a notices file that attributes nobody. A build that lost the leading + // attribution block keeps the heading and bodies, and one body can happen to carry a line shaped + // like an attribution pointing at its own section. That file must not clear the gate on prose it + // preserves verbatim: only the block the renderer writes before the first heading attributes. + const bodyOnly = await treeWithNotices( + [ + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: bodyOnly, + context: 'npm pack', + }), + /no bundled dependency notices[\s\S]*found 0 attributions and 1 license texts/, + ); +}); + +test('pack gate still rejects a grouped attribution whose reference is missing or absent', async () => { + // The mixed file is the point: a simple attribution must not stand in for the grouped one, so the + // grouped line has to be inspected and named rather than skipped as unparseable. + const groupedDangling = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + 'undici@7.1.1 (MIT OR (Apache-2.0 AND CC0-1.0)): [9]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: groupedDangling, + context: 'npm pack', + }), + /2 attributions and 1 license texts[\s\S]*unresolved: undici@7\.1\.1 \(MIT OR \(Apache-2\.0 AND CC0-1\.0\)\) -> \[9\]/, + ); + const groupedEmpty = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]', + 'undici@7.1.1 (MIT OR Apache-2.0): ', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: groupedEmpty, + context: 'npm pack', + }), + /unresolved: undici@7\.1\.1 \(MIT OR Apache-2\.0\) -> no reference/, + ); +}); + +test('pack gate takes no reference from an author clause', async () => { + // Written out rather than rendered: the shape the renderer only produces when a package's author + // field carries a footnote, which the real-bytes case above already covers end to end. + const authorSupplied = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): ; Author: Footnote author [1]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.throws( + () => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: authorSupplied, + context: 'npm pack', + }), + /unresolved: @agent-device\/proxy@0\.21\.37-dev \(MIT\) -> no reference/, + ); + // And the same file with a real reference beside the footnote passes: the author clause is + // ignored, not treated as a rejection by default. + const withReference = await treeWithNotices( + [ + '@agent-device/proxy@0.21.37-dev (MIT): [1]; Author: Footnote author [1]', + '', + '[1]', + 'Copyright (c) 2023 Callstack', + 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software.', + ].join('\n'), + ); + assert.doesNotThrow(() => + assertBundledLicenseNotices([noticesEntry()], { + noticesRoot: withReference, + context: 'npm pack', + }), + ); +}); + +test('publish package gate rejects placeholder notices from the tree it packed', async () => { + const root = await treeWithNotices(EMPTY_BUNDLE_NOTICES); + assert.throws( + () => + assertPublishPackageContents(fixturePack.files, { + noticesRoot: root, + macosHelperManifestSource: helperManifestSource, + }), + /no bundled dependency notices/, + ); + await writeFile(join(root, 'dist', 'src', 'THIRD_PARTY_NOTICES.txt'), REAL_NOTICES); + assert.doesNotThrow(() => + assertPublishPackageContents(fixturePack.files, { + noticesRoot: root, + macosHelperManifestSource: helperManifestSource, + }), + ); +}); + test('publish package requires both Android helpers and excludes benchmark scripts', () => { assert.doesNotThrow(() => assertPublishPackageContents(fixturePack.files, { @@ -171,3 +615,48 @@ test('base measurement uses the measured revision package asset policy', async ( /git checkout --detach[\s\S]*cp scripts\/size-report-package\.mjs \/tmp\/agent-device-size-report\/[\s\S]*node \/tmp\/agent-device-size-report\/size-report\.mjs/, ); }); + +test.each(['1', -1, 0.5, null])('npm pack rejects malformed file sizes (%s)', (size) => { + assert.throws( + () => + parseNpmPackOutput( + JSON.stringify([ + { + filename: 'fixture.tgz', + size: 1, + unpackedSize: 1, + files: [{ path: 'dist/src/THIRD_PARTY_NOTICES.txt', size }], + }, + ]), + ), + /invalid file entry/, + ); +}); + +test.each( + ['size', 'unpackedSize'].flatMap((field) => + [undefined, null, '1', -1, 0.5, Number.MAX_SAFE_INTEGER + 1].map((value) => ({ field, value })), + ), +)('npm pack rejects invalid aggregate $field ($value)', ({ field, value }) => { + assert.throws( + () => + parseNpmPackOutput( + JSON.stringify({ filename: 'fixture.tgz', ...fixturePack, [field]: value }), + ), + new RegExp(`invalid ${field}`), + ); +}); + +test.each([0, Number.MAX_SAFE_INTEGER])('npm pack retains valid aggregate sizes (%s)', (size) => { + const parsed = parseNpmPackOutput( + JSON.stringify({ filename: 'fixture.tgz', ...fixturePack, size, unpackedSize: size }), + ); + assert.equal(parsed.size, size); + assert.equal(parsed.unpackedSize, size); +}); + +test('npm pack parser shares validated file entries across both output shapes', () => { + const pack = { filename: 'fixture.tgz', ...fixturePack }; + assert.deepEqual(parseNpmPackOutput(JSON.stringify([pack])).files, fixturePack.files); + assert.deepEqual(parseNpmPackOutput(JSON.stringify(pack)).files, fixturePack.files); +}); diff --git a/scripts/check-affected/command.ts b/scripts/check-affected/command.ts index 46ab92f5ad..07540b2298 100644 --- a/scripts/check-affected/command.ts +++ b/scripts/check-affected/command.ts @@ -18,13 +18,5 @@ runEntrypoint('validation command', async () => { process.stderr.write(chunk); }, }); - await Promise.all( - [process.stdout, process.stderr].map( - (stream) => - new Promise((resolve, reject) => - stream.write('', (error) => (error ? reject(error) : resolve())), - ), - ), - ); return result.exitCode; }); diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 5b7291301e..0bb72f6733 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -31,6 +31,11 @@ import { type PackedManifest as PackedDependencies, } from './lib/shipped-imports.ts'; import { assertInstalledSnapshotBridge } from './size-report-install.mjs'; +import { + assertBundledLicenseNotices, + parseNpmPackOutput, + type NpmPackEntry, +} from './size-report-package.mjs'; import { testOnlyContentFailures, testOnlyPathFailures } from './lib/shipped-test-code.ts'; import { DAEMON_SOURCE_ENTRY, isSourceCheckoutProjectRoot } from '@agent-device/host-kit/version'; @@ -91,21 +96,21 @@ function step(message: string): void { * npm reports is the package's own declaration of what it ships, which is what the test-code guard * reads — a clean-install tree would also hold the installed dependencies' files. */ -function packTarball(): { tarball: string; packedPaths: string[] } { +function packTarball(): { tarball: string; packedEntries: readonly NpmPackEntry[] } { if (!fs.existsSync(path.join(repoRoot, 'dist', 'src'))) { throw new Error('No dist/src build found. Run `pnpm build` first.'); } fs.mkdirSync(packDestination, { recursive: true }); - const packed = JSON.parse( + const packed = parseNpmPackOutput( run( 'npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', packDestination], repoRoot, ), - ) as [{ filename: string; files: { path: string }[] }]; + ); return { - tarball: path.join(packDestination, packed[0].filename), - packedPaths: packed[0].files.map((file) => file.path), + tarball: path.join(packDestination, packed.filename), + packedEntries: packed.files, }; } @@ -237,9 +242,20 @@ function smokeTestBin(installedRoot: string, manifest: PackedManifest): void { step(`Packing and verifying agent-device in ${workDir}`); try { - const { tarball, packedPaths } = packTarball(); + const { tarball, packedEntries } = packTarball(); + assertBundledLicenseNotices(packedEntries, { + noticesRoot: repoRoot, + context: `The build packed from ${repoRoot}`, + }); + const packedPaths = packedEntries.map((file) => file.path); lintTarball(tarball); const installedRoot = installIntoCleanConsumer(tarball); + // A user's machine is the tree that has to carry the attribution, so the content policy runs again + // against the installed file, not only the working-tree copy the tarball was built from. + assertBundledLicenseNotices(packedEntries, { + noticesRoot: installedRoot, + context: `The installed package at ${installedRoot}`, + }); assertInstalledSnapshotBridge(installedRoot); assertInstalledTreeIsNotASourceCheckout(installedRoot); assertPackedTreeCarriesNoTestCode(installedRoot, packedPaths); diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts new file mode 100644 index 0000000000..9dbf99946f --- /dev/null +++ b/scripts/lib/bundled-license-notices.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import license, { type Dependency } from 'rollup-plugin-license'; + +/** Reads extra notices from root's LICENSES directory and emits them beside the bundle. */ +export function bundledLicenseNotices(root: string): ReturnType { + let source: string | undefined; + const collector = license({ + cwd: root, + sourcemap: false, + thirdParty: { + multipleVersions: true, + output(dependencies) { + source = renderNotices(root, dependencies); + }, + }, + }); + // rollup-plugin-license 3.7.1 exposes synchronous dependency output without a bundler context. + return { + ...collector, + async generateBundle(options, bundle, isWrite) { + source = undefined; + const hook = collector.generateBundle; + const generate = typeof hook === 'function' ? hook : hook?.handler; + await generate?.call(this, options, bundle, isWrite); + if (source === undefined) + this.error( + 'rollup-plugin-license did not report dependencies from generateBundle; expected its synchronous thirdParty.output callback.', + ); + this.emitFile({ type: 'asset', fileName: 'THIRD_PARTY_NOTICES.txt', source }); + }, + }; +} + +/** + * A bundled dependency must be attributable before it can be listed: `version` is optional to + * `rollup-plugin-license` and reports `null` for a module whose `package.json` has none, which is what + * an unresolvable or path-linked module looks like to it. Emitting `included@null (MIT)` would + * attribute code to a release that never existed, so the version is named first and every later + * diagnostic can name the package the way the notice file does. + */ +function assertBundledDependencyAttributable(dependency: Dependency): void { + if (!dependency.version?.trim()) { + throw new Error(`Bundled dependency ${dependency.name} is missing its version.`); + } + if (!dependency.license || !dependency.licenseText?.trim()) { + throw new Error( + `Bundled dependency ${dependency.name}@${dependency.version} is missing its license or license text.`, + ); + } +} + +function renderNotices(root: string, dependencies: readonly Dependency[]): string { + if (dependencies.length === 0) return 'No third-party dependencies are bundled.\n'; + const texts = new Map(); + const reference = (text: string): string => { + const normalized = text.replaceAll('\r\n', '\n').trim(); + let id = texts.get(normalized); + if (id === undefined) { + id = texts.size + 1; + texts.set(normalized, id); + } + return `[${id}]`; + }; + const packages: string[] = []; + const sortedDependencies = [...dependencies].sort((left, right) => { + const a = `${left.name}@${left.version}`; + const b = `${right.name}@${right.version}`; + return a < b ? -1 : a > b ? 1 : 0; + }); + for (const dependency of sortedDependencies) { + assertBundledDependencyAttributable(dependency); + const references = [reference(dependency.licenseText)]; + if (dependency.noticeText?.trim()) references.push(reference(dependency.noticeText)); + if (dependency.name === 'jpeg-js') { + const sourceNotices = fs.readFileSync(path.join(root, 'LICENSES/jpeg-js.txt'), 'utf8'); + const noticeVersion = sourceNotices + .split(/\r?\n/, 1)[0] + ?.match(/^jpeg-js (\S+) source-file notices$/)?.[1]; + if (noticeVersion !== dependency.version) { + throw new Error( + `Refresh LICENSES/jpeg-js.txt for bundled jpeg-js@${dependency.version}; its first line must name that version.`, + ); + } + references.push( + reference(sourceNotices), + reference(fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8')), + ); + } + packages.push( + `${dependency.name}@${dependency.version} (${dependency.license}): ${references.join(', ')}${dependency.author ? `; Author: ${dependency.author.text()}` : ''}`, + ); + } + // One trailing newline closes the last license block: the generic notice above carries one, and a + // reader who `cat`s the file or counts its lines sees a finished file either way. + const body = [packages.join('\n'), ...Array.from(texts, ([text, id]) => `[${id}]\n${text}`)].join( + '\n\n', + ); + return `${body}\n`; +} diff --git a/scripts/lib/cli-entrypoint.test.ts b/scripts/lib/cli-entrypoint.test.ts new file mode 100644 index 0000000000..7bf6a4be68 --- /dev/null +++ b/scripts/lib/cli-entrypoint.test.ts @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { test } from 'vitest'; + +test.each([false, true])('script exit flushes piped diagnostics (throws=%s)', async (throws) => { + const bytes = 4 * 1024 * 1024; + const stdoutTail = 'stdout diagnostic tail\n'; + const stderrTail = 'stderr diagnostic tail\n'; + const failure = 'fixture: expected failure\n'; + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + 'runEntrypoint("fixture", async () => {', + `process.stdout.write('o'.repeat(${bytes}) + ${JSON.stringify(stdoutTail)});`, + `process.stderr.write('e'.repeat(${bytes}) + ${JSON.stringify(stderrTail)});`, + throws ? 'throw new Error("expected failure");' : 'return 7;', + '});', + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk)); + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, throws ? 1 : 7); + assert.equal(stdout.length, bytes + stdoutTail.length); + assert.ok(stdout.endsWith(stdoutTail)); + assert.equal(stderr.length, bytes + stderrTail.length + (throws ? failure.length : 0)); + assert.ok(stderr.endsWith(throws ? failure : stderrTail)); +}); + +test.each([0, 7])('closed output pipe preserves command exit code %s', async (exitCode) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + 'process.once("message", () => {', + ' process.disconnect();', + ` runEntrypoint("fixture", async () => { process.stdout.write("closed-consumer output"); return ${exitCode}; });`, + '});', + 'process.send("ready");', + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const ready = once(child, 'message'); + const outputClosed = once(child.stdout, 'close'); + child.stdout.destroy(); + await outputClosed; + await ready; + const exited = once(child, 'close'); + child.send('run'); + const [code, signal] = await exited; + assert.equal(signal, null); + assert.equal(code, exitCode); + assert.equal(stderr, ''); +}); + +test.each( + ['EIO', 'ENOSPC', 'ERR_STREAM_DESTROYED'].flatMap((errorCode) => + [0, 7].map((exitCode) => ({ errorCode, exitCode })), + ), +)( + 'unexpected output error is reported without losing command failure ($errorCode, code=$exitCode)', + async ({ errorCode, exitCode }) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + `const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: ${JSON.stringify(errorCode)} })); } });`, + "Object.defineProperty(process, 'stdout', { value: output });", + `runEntrypoint('fixture', async () => ${exitCode});`, + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, exitCode || 1); + assert.equal(stderr, 'fixture: unable to flush stdout: fixture write failure\n'); + }, +); + +test.each( + ['EIO', 'EPIPE'].flatMap((errorCode) => + [0, 7].flatMap((exitCode) => + [false, true].map((settles) => ({ errorCode, exitCode, settles })), + ), + ), +)( + 'early output error preserves status and reports non-EPIPE failures once ($errorCode, code=$exitCode, settles=$settles)', + async ({ errorCode, exitCode, settles }) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + `const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: ${JSON.stringify(errorCode)} })); } });`, + "Object.defineProperty(process, 'stdout', { value: output });", + `process.exitCode = ${exitCode};`, + `runEntrypoint('fixture', () => { process.stdout.write('trigger'); return ${settles ? `new Promise(resolve => setImmediate(() => resolve(${exitCode})))` : 'new Promise(() => {})'}; });`, + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, exitCode || (errorCode === 'EPIPE' ? 0 : 1)); + assert.equal( + stderr, + errorCode === 'EPIPE' ? '' : 'fixture: unable to flush stdout: fixture write failure\n', + ); + }, +); + +test.each([0, 7])( + 'an early diagnostic queued after the initial drain is flushed (code=%s)', + async (exitCode) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + 'const stderr = process.stderr;', + "const output = new Writable({ write(_chunk, _encoding, done) { done(Object.assign(new Error('fixture write failure'), { code: 'EIO' })); } });", + 'const delayed = new Writable({ write(chunk, encoding, done) {', + ' if (chunk.length > 0) setTimeout(() => stderr.write(chunk, encoding, done), 50);', + ' else stderr.write(chunk, encoding, done);', + '} });', + "Object.defineProperty(process, 'stdout', { value: output });", + "Object.defineProperty(process, 'stderr', { value: delayed });", + `runEntrypoint('fixture', async () => ${exitCode});`, + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, exitCode || 1); + assert.equal(stderr, 'fixture: unable to flush stdout: fixture write failure\n'); + }, +); + +test.each( + ['stdout', 'stderr'].flatMap((stream) => + ['EIO', 'EPIPE'].flatMap((errorCode) => + [0, 7].map((exitCode) => ({ stream, errorCode, exitCode })), + ), + ), +)( + 'a late $stream error during the other drain preserves status ($errorCode, code=$exitCode)', + async ({ stream, errorCode, exitCode }) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + 'const [errorCode, stream, exitCode] = process.argv.slice(1);', + 'const stderr = process.stderr;', + 'const faulty = new Writable({ write(_chunk, _encoding, done) { done(); } });', + 'let injected = false;', + 'const waiting = new Writable({ write(chunk, encoding, done) {', + ' if (!injected && chunk.length === 0) {', + ' injected = true;', + " setImmediate(() => { faulty.destroy(Object.assign(new Error('late write failure'), { code: errorCode })); setImmediate(done); });", + ' } else stderr.write(chunk, encoding, done);', + '} });', + 'Object.defineProperty(process, stream, { value: faulty });', + "Object.defineProperty(process, stream === 'stdout' ? 'stderr' : 'stdout', { value: waiting });", + "runEntrypoint('fixture', async () => Number(exitCode));", + ].join('\n'); + const child = spawn( + process.execPath, + [ + '--experimental-strip-types', + '--input-type=module', + '--eval', + source, + errorCode, + stream, + String(exitCode), + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const [code, signal] = await once(child, 'close'); + assert.equal(signal, null); + assert.equal(code, exitCode || (errorCode === 'EPIPE' ? 0 : 1)); + assert.equal( + stderr, + stream === 'stdout' && errorCode !== 'EPIPE' + ? 'fixture: unable to flush stdout: late write failure\n' + : '', + ); + }, +); + +// A real pipe must stay unread: synthetic write callbacks cannot prove process termination under backpressure. +test.each([ + { exitCode: 0, diagnosticDelayMs: 0 }, + { exitCode: 7, diagnosticDelayMs: 0 }, + { exitCode: 0, diagnosticDelayMs: 50 }, +])( + 'a stalled output reader cannot keep a finished command alive (code=$exitCode, diagnostic delay=$diagnosticDelayMs)', + async ({ exitCode, diagnosticDelayMs }) => { + const source = [ + `import { runEntrypoint } from ${JSON.stringify(new URL('./cli-entrypoint.ts', import.meta.url).href)};`, + "import { Writable } from 'node:stream';", + `if (${diagnosticDelayMs} > 0) {`, + ' const stderr = process.stderr;', + ' const delayed = new Writable({ write(chunk, encoding, done) {', + ` if (chunk.length > 0) setTimeout(() => stderr.write(chunk, encoding, done), ${diagnosticDelayMs});`, + ' else stderr.write(chunk, encoding, done);', + ' } });', + " Object.defineProperty(process, 'stderr', { value: delayed });", + '}', + 'setTimeout(() => process.exit(99), 3000);', + 'runEntrypoint("fixture", async () => {', + " process.stdout.write('o'.repeat(4 * 1024 * 1024));", + ' process.send({ queuedBytes: process.stdout.writableLength });', + ` return ${exitCode};`, + '});', + ].join('\n'); + const child = spawn( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '--eval', source], + { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + const stderrEnded = once(child.stderr, 'end'); + const exited = once(child, 'exit'); + try { + const [message] = await once(child, 'message'); + assert.ok( + message.queuedBytes > 0, + 'the real producer must have queued output behind the unread pipe', + ); + const [code, signal] = await exited; + await stderrEnded; + assert.equal(signal, null); + assert.equal( + code, + exitCode || 1, + 'the flush deadline must win before the independent leak guard', + ); + assert.equal(stderr, 'fixture: unable to flush stdout: output drain timed out\n'); + } finally { + child.stdout.destroy(); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } + }, +); diff --git a/scripts/lib/cli-entrypoint.ts b/scripts/lib/cli-entrypoint.ts index f75fcd7849..1e68b9d70d 100644 --- a/scripts/lib/cli-entrypoint.ts +++ b/scripts/lib/cli-entrypoint.ts @@ -1,12 +1,83 @@ -// Shared bootstrap for script entrypoints: run `main`, exit on its code, and turn a -// thrown error into one prefixed line rather than a stack trace. +const OUTPUT_DRAIN_TIMEOUT_MS = 2000; +function isClosedPipe(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EPIPE'; +} + +function outputFailure(stream: NodeJS.WriteStream, error?: unknown): unknown { + const cause = stream.errored ?? error; + return cause != null && !isClosedPipe(cause) ? cause : undefined; +} + +function drainOutput(stream: NodeJS.WriteStream, deadline: number): Promise { + return new Promise((resolve) => { + const finish = (error?: unknown): void => { + clearTimeout(timer); + resolve(outputFailure(stream, error)); + }; + const timer = setTimeout( + () => finish(new Error('output drain timed out')), + Math.max(0, deadline - Date.now()), + ); + if (stream.destroyed || !stream.writable) { + finish(stream.errored); + return; + } + stream.once('error', finish); + try { + stream.write('', finish); + } catch (error) { + finish(error); + } + }); +} + +// Shared bootstrap for script entrypoints. Output failure fails a successful command; a closed +// pipe preserves its code. Primary output has a hard deadline, even for a slow reader. +// A failure diagnostic gets one separate bounded drain window. export function runEntrypoint(prefix: string, main: () => Promise): void { - main().then( - (code) => process.exit(code), - (error: unknown) => { - process.stderr.write(`${prefix}: ${error instanceof Error ? error.message : error}\n`); - process.exit(1); - }, - ); + // A pending main promise does not keep Node alive; retain early failures for natural exit. + const retainOutputFailure = (error: unknown): void => { + if (!isClosedPipe(error) && !Number(process.exitCode)) process.exitCode = 1; + }; + let stdoutDiagnosticAttempted = false; + const reportStdoutFailure = (error: unknown): void => { + if ( + stdoutDiagnosticAttempted || + process.stderr.destroyed || + !process.stderr.writable || + process.stderr.errored != null + ) + return; + stdoutDiagnosticAttempted = true; + const message = error instanceof Error ? error.message : String(error); + try { + process.stderr.write(`${prefix}: unable to flush stdout: ${message}\n`); + } catch (error) { + retainOutputFailure(error); + } + }; + process.stderr.on('error', retainOutputFailure); + process.stdout.on('error', (error: unknown) => { + retainOutputFailure(error); + if (!isClosedPipe(error)) reportStdoutFailure(error); + }); + const exitAfterOutput = async (code: number): Promise => { + const deadline = Date.now() + OUTPUT_DRAIN_TIMEOUT_MS; + const [initialStdoutError, initialStderrError] = await Promise.all([ + drainOutput(process.stdout, deadline), + drainOutput(process.stderr, deadline), + ]); + const stdoutError = initialStdoutError ?? outputFailure(process.stdout); + let stderrError = initialStderrError ?? outputFailure(process.stderr); + if (stdoutError != null && stderrError == null) { + reportStdoutFailure(stdoutError); + stderrError = await drainOutput(process.stderr, Date.now() + OUTPUT_DRAIN_TIMEOUT_MS); + } + process.exit(code || (stdoutError != null || stderrError != null ? 1 : 0)); + }; + main().then(exitAfterOutput, (error: unknown) => { + process.stderr.write(`${prefix}: ${error instanceof Error ? error.message : error}\n`); + return exitAfterOutput(1); + }); } diff --git a/scripts/size-report-package.d.mts b/scripts/size-report-package.d.mts index 3ba2bff28a..2056499a20 100644 --- a/scripts/size-report-package.d.mts +++ b/scripts/size-report-package.d.mts @@ -4,11 +4,23 @@ export const DOCUMENTED_PACK_STUBS: readonly string[]; export function readMacosHelperTestTargets(manifestSource: string): string[]; export type NpmPackEntry = Readonly<{ path: string; size: number }>; +export function assertBundledLicenseNotices( + entries: readonly NpmPackEntry[], + options?: Readonly<{ noticesRoot?: string; context?: string }>, +): void; export function assertPublishPackageContents( entries: readonly NpmPackEntry[], options?: Readonly<{ + noticesRoot?: string; repoRoot?: string; requireSnapshotBridge?: boolean; macosHelperManifestSource?: string; }>, ): void; + +export function parseNpmPackOutput(stdout: string): Readonly<{ + filename: string; + size: number; + unpackedSize: number; + files: readonly NpmPackEntry[]; +}>; diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index 787f624012..e0a62a289d 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -50,8 +50,9 @@ export function collectNpmPack(root) { { cwd: root, encoding: 'utf8' }, ); const pack = parseNpmPackOutput(stdout); - const entries = normalizeNpmPackEntries(pack); + const entries = pack.files; assertPublishPackageContents(entries, { + noticesRoot: root, repoRoot: root, requireSnapshotBridge: fs.existsSync(path.join(root, 'apple', 'snapshot-bridge')), }); @@ -65,7 +66,143 @@ export function collectNpmPack(root) { }; } +/** + * The notices asset a packed tree ships, and the build output it comes from. One path keeps the + * manifest check and the content check naming the same file. + */ +const BUNDLED_LICENSE_NOTICES_PATH = 'dist/src/THIRD_PARTY_NOTICES.txt'; + +/** + * The heading that opens a numbered license section. The renderer writes every attribution it + * produces before the first one, so this line is also the boundary between the attributions the + * gate reads and the license text it preserves. + */ +const LICENSE_SECTION_HEADING = /^\[(\d+)\]$/; + +/** + * The ids of the numbered license sections that hold text. A section runs from its `[n]` heading to + * the next heading, so a license whose body carries blank lines keeps that body. A line shaped like a + * heading inside a license body only shortens the section it sits in; it cannot fill an empty one. + */ +function readLicenseTextIds(lines) { + const idsWithText = new Set(); + let openId; + let seenText = false; + for (const line of lines) { + const heading = line.match(LICENSE_SECTION_HEADING); + if (heading) { + if (seenText) idsWithText.add(openId); + openId = Number(heading[1]); + seenText = false; + } else if (openId !== undefined && line.trim()) { + seenText = true; + } + } + if (seenText) idsWithText.add(openId); + return idsWithText; +} + +/** + * The lines the attributions can live on: everything before the first numbered section, which is the + * block the renderer writes first. A section holds a dependency's license or NOTICE text verbatim — + * the renderer neither escapes nor indents it — so its content is upstream's, and prose there can + * carry a contact address or a pointer to "section [9]" shaped like an attribution line. Reading + * those as attributions would reject publishable bytes, and one such line would stand in for the + * real attributions a build failed to write. + */ +function readAttributionLines(lines) { + const firstSection = lines.findIndex((line) => LICENSE_SECTION_HEADING.test(line)); + return firstSection === -1 ? lines : lines.slice(0, firstSection); +} + +/** + * The `name@version (license): [n]` line the bundler writes, or undefined for any other line. The + * license field is whatever the plugin reports, wrapped in the renderer's own parentheses, so a + * grouped SPDX expression like `(MIT OR (Apache-2.0 AND CC0-1.0))` nests inside it: the field ends + * at the first `):` boundary, which only that closing parenthesis can reach. What follows the colon + * up to the renderer's optional `; Author: ` clause is the reference list — one package names + * several ids when it ships a NOTICE or source-file notices with its license. The author clause is + * attribution metadata: an author field holding a bracketed footnote must not read as a license + * reference, in either direction. + */ +function readAttribution(line) { + const attribution = line.match(/^(\S+@\S+ \((.+?)\)): ?(.*)$/); + if (!attribution) return undefined; + const references = attribution[3].split('; Author: ', 1)[0]; + return { + label: attribution[1], + references: [...references.matchAll(/\[(\d+)\]/g)].map((reference) => Number(reference[1])), + }; +} + +function describeUnresolvedAttribution(attribution) { + return ( + `${attribution.label} -> ` + + (attribution.references.length > 0 + ? `[${attribution.references.join('], [')}]` + : 'no reference') + ); +} + +/** + * The published CLI bundles its runtime dependencies into `dist`, so the notices it ships must name + * them and carry the terms behind every name: each `name@version (license): [n]` attribution must + * point at a numbered section that holds text. The bundler's zero-dependency output — the generic + * "nothing bundled" line — names nobody, and a file of bare `[n]` headings has the right shape with + * no license terms in it, so a build that stopped bundling dependencies, or wrote headings without + * bodies, would otherwise keep clearing a gate that only counts the two kinds of line. + * + * This is the published CLI's policy, not the bundler's: a project that genuinely bundles nothing + * still gets the generic notice, and only this package is held to naming somebody. + */ +function assertLicenseNoticeContents(contents, context) { + const lines = contents.replaceAll('\r\n', '\n').split('\n'); + const licenseTextIds = readLicenseTextIds(lines); + const attributions = readAttributionLines(lines) + .map(readAttribution) + .filter((attribution) => attribution !== undefined); + const unresolved = attributions.filter( + (attribution) => + attribution.references.length === 0 || + attribution.references.some((reference) => !licenseTextIds.has(reference)), + ); + if (attributions.length > 0 && unresolved.length === 0) return; + throw new Error( + `${context} ships ${BUNDLED_LICENSE_NOTICES_PATH} with no bundled dependency notices: ` + + `expected every "name@version (license): [n]" attribution to reference a non-empty license ` + + `text, found ${attributions.length} attributions and ${licenseTextIds.size} license texts` + + (unresolved.length > 0 + ? `; unresolved: ${unresolved.map(describeUnresolvedAttribution).join('; ')}` + : '') + + '.', + ); +} + +/** + * Requires the notices asset the `npm pack` manifest declares, and — when `noticesRoot` names the tree + * that was packed or installed — that its contents attribute somebody. A manifest says only that a + * path exists and how many bytes it is, which the bundler's zero-dependency placeholder satisfies; + * reading the bytes is what lets the gate see the attribution a user actually receives. A tree without + * the file fails naming the path, rather than with a bare ENOENT from a read. + */ +export function assertBundledLicenseNotices(entries, options = {}) { + if (!entries.some((entry) => entry.path === BUNDLED_LICENSE_NOTICES_PATH && entry.size > 0)) { + throw new Error(`npm pack is missing non-empty ${BUNDLED_LICENSE_NOTICES_PATH}`); + } + if (!options.noticesRoot) return; + const noticesPath = path.join(options.noticesRoot, BUNDLED_LICENSE_NOTICES_PATH); + const context = options.context ?? options.noticesRoot; + if (!fs.existsSync(noticesPath)) { + throw new Error(`${context} has no ${BUNDLED_LICENSE_NOTICES_PATH}.`); + } + assertLicenseNoticeContents(fs.readFileSync(noticesPath, 'utf8'), context); +} + export function assertPublishPackageContents(entries, options = {}) { + assertBundledLicenseNotices(entries, { + noticesRoot: options.noticesRoot, + context: options.noticesRoot ? `npm pack of ${options.noticesRoot}` : undefined, + }); const paths = entries.map((entry) => entry.path); const requiredAssets = [ { directory: 'android/snapshot-helper/dist/', suffix: '.apk' }, @@ -157,9 +294,18 @@ export function assertPublishPackageContents(entries, options = {}) { } } -function parseNpmPackOutput(stdout) { +export function parseNpmPackOutput(stdout) { const parsed = JSON.parse(stdout); - return Array.isArray(parsed) ? parsed[0] : parsed; + const pack = Array.isArray(parsed) ? parsed[0] : parsed; + if (!pack || typeof pack.filename !== 'string' || pack.filename.length === 0) { + throw new Error('npm pack did not return a filename'); + } + for (const field of ['size', 'unpackedSize']) { + if (!Number.isSafeInteger(pack[field]) || pack[field] < 0) { + throw new Error(`npm pack returned an invalid ${field}: ${JSON.stringify(pack[field])}`); + } + } + return { ...pack, files: normalizeNpmPackEntries(pack) }; } function normalizeNpmPackEntries(pack) { diff --git a/tsdown.config.ts b/tsdown.config.ts index 26b29b5a65..4310dfdf72 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,6 +2,7 @@ import { buildEntries } from './scripts/build-entries.ts'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { defineConfig, type TsdownPluginOption } from 'tsdown'; +import { bundledLicenseNotices } from './scripts/lib/bundled-license-notices.ts'; const typeScriptPackageJsonUrl = import.meta.resolve('typescript/package.json'); const { default: getTypeScript7ExePath } = await import( @@ -174,7 +175,7 @@ export default defineConfig({ }, outExtensions: () => ({ js: '.js', dts: '.d.ts' }), minify: true, - plugins: [dropAmbientDeclarationImports], + plugins: [dropAmbientDeclarationImports, bundledLicenseNotices(import.meta.dirname)], dts: { tsgo: { path: getTypeScript7ExePath(), diff --git a/vitest.config.ts b/vitest.config.ts index d3becdd644..06d7375b6f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -184,6 +184,8 @@ export default defineConfig({ // Package attribution models npm-pack output. Keep it in the fast lane so every new // package path remains accounted for. 'scripts/__tests__/size-report-package.test.ts', + 'scripts/__tests__/bundled-license-notices.test.ts', + 'scripts/lib/cli-entrypoint.test.ts', // Publish preparation spawns only fixture-owned scripts and proves both Android // helper families are rebuilt through the shared release/size-report owner. 'scripts/__tests__/prepare-publish-assets.test.ts',